package preset import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) // Store owns presets.json. Every mutation goes through it so the file is // written atomically and the in-memory copy never drifts from disk. type Store struct { path string mu sync.RWMutex file File } // NewStore opens (or creates) the preset file at path. func NewStore(path string) (*Store, error) { s := &Store{path: path, file: File{Version: SchemaVersion}} if err := s.Load(); err != nil { return nil, err } return s, nil } // Load re-reads the file from disk. A missing file is not an error: a fresh // install has no presets yet and should start with an empty list rather than // refusing to launch. func (s *Store) Load() error { b, err := os.ReadFile(s.path) if os.IsNotExist(err) { s.mu.Lock() s.file = File{Version: SchemaVersion} s.mu.Unlock() return nil } if err != nil { return fmt.Errorf("reading %s: %w", s.path, err) } f, err := Decode(b) if err != nil { return fmt.Errorf("reading %s: %w", s.path, err) } s.mu.Lock() s.file = f s.mu.Unlock() return nil } // Decode parses and fully validates a preset file. Import uses it too, which is // why it rejects the whole document on the first bad preset rather than // dropping the offender and continuing. func Decode(b []byte) (File, error) { var f File dec := json.NewDecoder(strings.NewReader(string(b))) dec.DisallowUnknownFields() if err := dec.Decode(&f); err != nil { return File{}, fmt.Errorf("not a valid preset file: %w", err) } if f.Version != SchemaVersion { return File{}, fmt.Errorf("preset file is version %d, this build understands version %d", f.Version, SchemaVersion) } seen := make(map[string]bool, len(f.Presets)) for _, p := range f.Presets { if err := p.Validate(); err != nil { return File{}, err } if seen[p.ID] { return File{}, fmt.Errorf("duplicate preset id %q", p.ID) } seen[p.ID] = true } return f, nil } // Save writes the file atomically: a temp file in the same directory, then a // rename. A half-written presets.json after a crash would lose every preset, // and this is the user's only copy. func (s *Store) Save() error { s.mu.RLock() b, err := json.MarshalIndent(s.file, "", " ") s.mu.RUnlock() if err != nil { return fmt.Errorf("encoding presets: %w", err) } b = append(b, '\n') if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return fmt.Errorf("creating preset directory: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(s.path), ".presets-*.json") if err != nil { return fmt.Errorf("creating temp file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op once the rename below succeeds if _, err := tmp.Write(b); err != nil { tmp.Close() return fmt.Errorf("writing temp file: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("syncing temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("closing temp file: %w", err) } if err := os.Rename(tmpName, s.path); err != nil { return fmt.Errorf("replacing %s: %w", s.path, err) } return nil } // All returns a copy of the presets, sorted by group then name so the tray menu // and the editor agree on ordering. func (s *Store) All() []Preset { s.mu.RLock() out := make([]Preset, len(s.file.Presets)) copy(out, s.file.Presets) s.mu.RUnlock() sort.SliceStable(out, func(i, j int) bool { gi, gj := out[i].GroupOrUngrouped(), out[j].GroupOrUngrouped() if gi != gj { // Ungrouped sinks to the bottom of the tray menu. if gi == "Ungrouped" { return false } if gj == "Ungrouped" { return true } return strings.ToLower(gi) < strings.ToLower(gj) } return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } // Get returns the preset with the given id. func (s *Store) Get(id string) (Preset, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, p := range s.file.Presets { if p.ID == id { return p, true } } return Preset{}, false } // Put inserts or replaces a preset and saves. An empty ID means "new". func (s *Store) Put(p Preset) (Preset, error) { if strings.TrimSpace(p.ID) == "" { p.ID = NewID() } if err := p.Validate(); err != nil { return Preset{}, err } s.mu.Lock() replaced := false for i := range s.file.Presets { if s.file.Presets[i].ID == p.ID { s.file.Presets[i] = p replaced = true break } } if !replaced { s.file.Presets = append(s.file.Presets, p) } s.mu.Unlock() return p, s.Save() } // Delete removes a preset by id and saves. func (s *Store) Delete(id string) error { s.mu.Lock() kept := s.file.Presets[:0] found := false for _, p := range s.file.Presets { if p.ID == id { found = true continue } kept = append(kept, p) } s.file.Presets = kept s.mu.Unlock() if !found { return fmt.Errorf("no preset with id %q", id) } return s.Save() } // Groups lists the distinct groups in tray-menu order. func (s *Store) Groups() []string { seen := map[string]bool{} var out []string for _, p := range s.All() { g := p.GroupOrUngrouped() if !seen[g] { seen[g] = true out = append(out, g) } } return out } // NewID mints a preset id. claude.md shows ULID-shaped ids in the example file, // but nothing depends on them being sortable or on their length — an id only // has to be unique and stable — so this stays dependency-free: a millisecond // timestamp for rough ordering plus 64 bits of randomness. func NewID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { // crypto/rand does not fail in practice on Windows; if it somehow // does, a timestamp-only id is still unique enough to save with. return fmt.Sprintf("%013x", time.Now().UnixMilli()) } return fmt.Sprintf("%013x%s", time.Now().UnixMilli(), hex.EncodeToString(b[:])) }