package dyzurbot import ( "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "sync" "time" ) // persisted is the on-disk JSON shape of the cache. type persisted struct { LastSynced time.Time `json:"last_synced"` Rota Rota `json:"rota"` } // Store holds the rota in memory and mirrors it to a JSON file on disk. // All access is guarded so readers (queries, reminders) and the syncer // can run concurrently. type Store struct { mu sync.RWMutex path string rota Rota lastSynced time.Time } // NewStore returns a Store backed by the JSON file at path. func NewStore(path string) *Store { return &Store{path: path} } // Load reads the JSON snapshot into memory. A missing file is treated as an // empty cache (cold start), not an error. func (s *Store) Load() error { data, err := os.ReadFile(s.path) // #nosec G304 -- path is operator-controlled config if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("read cache %q: %w", s.path, err) } var p persisted if err := json.Unmarshal(data, &p); err != nil { log.Printf("load cache %q: corrupt, ignoring: %v", s.path, err) return nil } s.mu.Lock() defer s.mu.Unlock() s.rota = p.Rota s.lastSynced = p.LastSynced return nil } // Snapshot returns a copy of the cached rota plus the last successful sync // time. The copy lets callers read without holding the lock and shields the // cache from caller-side mutation. func (s *Store) Snapshot() (Rota, time.Time) { s.mu.RLock() defer s.mu.RUnlock() weeks := make([]DutyWeek, len(s.rota.Weeks)) copy(weeks, s.rota.Weeks) people := make([]Person, len(s.rota.People)) copy(people, s.rota.People) return Rota{Weeks: weeks, People: people}, s.lastSynced } // Replace swaps the in-memory state and persists it to disk via a // temp-file-and-rename so a crash mid-write cannot corrupt the cache. func (s *Store) Replace(rota Rota, syncedAt time.Time) error { s.mu.Lock() defer s.mu.Unlock() if err := writeJSONAtomic(s.path, persisted{LastSynced: syncedAt, Rota: rota}); err != nil { return err } s.rota = rota s.lastSynced = syncedAt return nil } // writeJSONAtomic marshals v and writes it to path atomically. func writeJSONAtomic(path string, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return fmt.Errorf("encode cache: %w", err) } dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".rota-*.tmp") // #nosec G304 -- dir derived from operator config if err != nil { return fmt.Errorf("create temp cache: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() // harmless no-op after a successful rename if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("write temp cache: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp cache: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("rename temp cache: %w", err) } return nil }