diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..d03b028 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,109 @@ +// Package state persists a per-run log used only for resume hints in the UI. +// Correctness always comes from live Detect() calls. +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// RunRecord is the JSON document written to runs/.json. +type RunRecord struct { + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Modules []ModuleRecord `json:"modules"` +} + +// ModuleRecord captures how one module fared. +type ModuleRecord struct { + ID string `json:"id"` + Status string `json:"status"` // succeeded|failed|skipped|planned + Duration string `json:"duration"` + Error string `json:"error,omitempty"` + At time.Time `json:"at"` +} + +// Store is a file-backed run log. +type Store struct { + Root string // e.g. ~/.local/state/archduino +} + +// DefaultStore returns a Store rooted at $XDG_STATE_HOME/archduino or +// ~/.local/state/archduino. +func DefaultStore() (*Store, error) { + if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { + return &Store{Root: filepath.Join(xdg, "archduino")}, nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + return &Store{Root: filepath.Join(home, ".local", "state", "archduino")}, nil +} + +// Save writes record as runs/.json and updates last.json. Both paths are +// created if missing. Old runs beyond `keep` are deleted. +func (s *Store) Save(rec RunRecord, keep int) error { + if keep <= 0 { + keep = 10 + } + runsDir := filepath.Join(s.Root, "runs") + if err := os.MkdirAll(runsDir, 0o755); err != nil { + return err + } + name := rec.StartedAt.UTC().Format("20060102T150405Z") + ".json" + path := filepath.Join(runsDir, name) + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(s.Root, "last.json"), data, 0o644); err != nil { + return err + } + return rotate(runsDir, keep) +} + +// LastRun reads last.json if present. +func (s *Store) LastRun() (RunRecord, bool, error) { + data, err := os.ReadFile(filepath.Join(s.Root, "last.json")) + if err != nil { + if os.IsNotExist(err) { + return RunRecord{}, false, nil + } + return RunRecord{}, false, err + } + var rec RunRecord + if err := json.Unmarshal(data, &rec); err != nil { + return RunRecord{}, false, fmt.Errorf("parse last.json: %w", err) + } + return rec, true, nil +} + +func rotate(dir string, keep int) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + names = append(names, e.Name()) + } + } + if len(names) <= keep { + return nil + } + sort.Strings(names) // lexicographic == chronological (UTC stamp) + for _, n := range names[:len(names)-keep] { + _ = os.Remove(filepath.Join(dir, n)) + } + return nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..6b75603 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,63 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func newStore(t *testing.T) *Store { + t.Helper() + return &Store{Root: t.TempDir()} +} + +func TestSaveAndLoadLast(t *testing.T) { + s := newStore(t) + rec := RunRecord{ + StartedAt: time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC), + EndedAt: time.Date(2026, 4, 13, 10, 1, 0, 0, time.UTC), + Modules: []ModuleRecord{{ID: "hello", Status: "succeeded", Duration: "1s"}}, + } + if err := s.Save(rec, 10); err != nil { + t.Fatal(err) + } + got, ok, err := s.LastRun() + if err != nil || !ok { + t.Fatalf("LastRun err=%v ok=%v", err, ok) + } + if len(got.Modules) != 1 || got.Modules[0].ID != "hello" { + t.Fatalf("modules = %+v", got.Modules) + } +} + +func TestLastRunMissingReturnsFalse(t *testing.T) { + s := newStore(t) + _, ok, err := s.LastRun() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected ok=false") + } +} + +func TestRotateKeepsNewest(t *testing.T) { + s := newStore(t) + for i := 0; i < 12; i++ { + rec := RunRecord{ + StartedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), + EndedAt: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), + } + if err := s.Save(rec, 5); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(filepath.Join(s.Root, "runs")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 5 { + t.Fatalf("runs count = %d, want 5", len(entries)) + } +}