diff --git a/data/.gitignore b/data/.gitignore index f05d7a5..96fcb32 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -8,3 +8,4 @@ parity_mismatches/ stats/ patterns.json settings.json +update-check.json diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 5abe8dd..04d0621 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -2720,6 +2720,62 @@ func (ds *DataStore) SaveSettings(settings Settings) error { return ds.atomicWriteFile(path, data) } +// UpdateCheckState is the small persisted state for the opt-in periodic +// update check (#591, _/i591/design-update-check.md): when it last ran and +// what it last saw, so a restart doesn't lose the "already logged this +// version" and "don't hammer GitHub on every startup" context. Separate +// from Settings, which is operator-editable config, not runtime state. +type UpdateCheckState struct { + LastCheckedAt string `json:"last_checked_at,omitempty"` + LastSeenVersion string `json:"last_seen_version,omitempty"` +} + +// GetUpdateCheckState retrieves the persisted update-check state. Same +// missing-file-is-not-an-error shape as GetSettings — a fresh install (or +// one that has never had the check enabled) has no file yet. +func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) { + if ds == nil || ds.DataDir == "" { + return UpdateCheckState{}, nil + } + + path := filepath.Join(ds.DataDir, "update-check.json") + if !ds.rootExists(path) { + return UpdateCheckState{}, nil + } + + data, err := ds.rootReadFile(path) + if err != nil { + return UpdateCheckState{}, err + } + + var state UpdateCheckState + if err := json.Unmarshal(data, &state); err != nil { + return UpdateCheckState{}, err + } + + return state, nil +} + +// SaveUpdateCheckState persists the update-check state. +func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error { + if ds == nil || ds.DataDir == "" { + return nil + } + + if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + path := filepath.Join(ds.DataDir, "update-check.json") + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return ds.atomicWriteFile(path, data) +} + // SaveUsageStats saves usage statistics to the datastore. func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { dir := filepath.Join(ds.DataDir, "stats", "usage") diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index e857db8..a17b6f6 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -458,6 +458,57 @@ func TestSettingsPersistence(t *testing.T) { } } +// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install +// (or one where the update check has never run) gets a zero-value state, +// not an error — same shape as GetSettings on a missing settings.json. +func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) { + tempDir, err := os.MkdirTemp("", "update-check-missing-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + + state, err := ds.GetUpdateCheckState() + if err != nil { + t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err) + } + if state != (UpdateCheckState{}) { + t.Errorf("Expected zero-value state, got %+v", state) + } +} + +// TestUpdateCheckState_Persistence is the roundtrip test, mirroring +// TestSettingsPersistence. +func TestUpdateCheckState_Persistence(t *testing.T) { + tempDir, err := os.MkdirTemp("", "update-check-persist-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + + state := UpdateCheckState{ + LastCheckedAt: "2026-08-09T12:00:00Z", + LastSeenVersion: "v0.122.0", + } + + if err := ds.SaveUpdateCheckState(state); err != nil { + t.Fatalf("SaveUpdateCheckState failed: %v", err) + } + + loaded, err := ds.GetUpdateCheckState() + if err != nil { + t.Fatalf("GetUpdateCheckState failed: %v", err) + } + + if loaded != state { + t.Errorf("Expected %+v, got %+v", state, loaded) + } +} + // TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords // for a kind that was never recorded returns an empty, non-error result — // the "nothing recorded yet" case, not a failure.