package promotions import ( "encoding/json" "fmt" "os" "path/filepath" "sync" ) // Store is a thread-safe, file-backed collection of promotion rules. It is the // single source of truth shared by the cart's mutation processor (which reads a // snapshot on every recompute) and the promotion MCP tools (which mutate it and // persist to disk). Because the cart re-reads the snapshot each time totals are // recomputed, MCP edits take effect on the next cart mutation — no restart. type Store struct { mu sync.RWMutex path string version int promotions []PromotionRule } // NewStore loads the state file at path (an absent file yields an empty store) // and returns a Store that persists back to the same path. func NewStore(path string) (*Store, error) { sf, err := LoadStateFile(path) if err != nil { return nil, err } version := sf.Version if version == 0 { version = 1 } return &Store{ path: path, version: version, promotions: sf.State.Promotions, }, nil } // Snapshot returns a copy of the current rules, safe to evaluate without holding // the lock. The PromotionRule values are passed by value to EvaluateAll, so a // shallow copy of the slice is enough to avoid racing with concurrent mutations. func (s *Store) Snapshot() []PromotionRule { s.mu.RLock() defer s.mu.RUnlock() out := make([]PromotionRule, len(s.promotions)) copy(out, s.promotions) return out } // List returns a copy of all rules (for the list_promotions tool). func (s *Store) List() []PromotionRule { return s.Snapshot() } // Get returns the rule with the given id. func (s *Store) Get(id string) (PromotionRule, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, r := range s.promotions { if r.ID == id { return r, true } } return PromotionRule{}, false } // Upsert inserts the rule or replaces the existing one with the same ID, then // persists. Returns true if an existing rule was replaced. func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) { if rule.ID == "" { return false, fmt.Errorf("promotion id is required") } s.mu.Lock() defer s.mu.Unlock() for i := range s.promotions { if s.promotions[i].ID == rule.ID { s.promotions[i] = rule return true, s.saveLocked() } } s.promotions = append(s.promotions, rule) return false, s.saveLocked() } // SetStatus changes the status of a rule and persists. Returns false if no rule // with the id exists. func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.promotions { if s.promotions[i].ID == id { s.promotions[i].Status = status return true, s.saveLocked() } } return false, nil } // Delete removes the rule with the id and persists. Returns false if not found. func (s *Store) Delete(id string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.promotions { if s.promotions[i].ID == id { s.promotions = append(s.promotions[:i], s.promotions[i+1:]...) return true, s.saveLocked() } } return false, nil } // saveLocked writes the current state to disk atomically. Caller holds s.mu. func (s *Store) saveLocked() error { sf := StateFile{ Version: s.version, State: PromotionState{Promotions: s.promotions}, } data, err := json.MarshalIndent(sf, "", " ") if err != nil { return err } dir := filepath.Dir(s.path) tmp, err := os.CreateTemp(dir, ".promotions-*.json") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op once renamed if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, s.path) }