// Package config provides JSON-file backed persistence for AppConfig. // // Design rationale: // - JSON keeps the file human-readable, which is useful while the MVP has // no settings UI for every option. // - Storage location follows os.UserConfigDir() so each platform places it // in the canonical spot (macOS: ~/Library/Application Support/SnapGo). // - File mode 0600 / dir mode 0700 mitigate accidental leakage of access // keys before we wire up the OS keychain (deferred to a later spec). package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "github.com/mmmy/snapgo/internal/domain" ) // FileStore implements load/save of AppConfig on the local filesystem. // // All methods are safe for concurrent use. type FileStore struct { mu sync.RWMutex path string } // NewFileStore resolves the on-disk path and creates the parent directory // with permissions 0700 if missing. func NewFileStore() (*FileStore, error) { dir, err := os.UserConfigDir() if err != nil { return nil, fmt.Errorf("resolve user config dir: %w", err) } appDir := filepath.Join(dir, "SnapGo") if err := os.MkdirAll(appDir, 0o700); err != nil { return nil, fmt.Errorf("ensure config dir: %w", err) } return &FileStore{path: filepath.Join(appDir, "config.json")}, nil } // Path exposes the absolute config file path (mainly for diagnostics / UI). func (s *FileStore) Path() string { return s.path } // Load reads the config file. If the file does not exist OR cannot be parsed, // the default config is returned with a non-nil error so the caller can decide // whether to surface the parse failure to the user. func (s *FileStore) Load() (domain.AppConfig, error) { s.mu.RLock() defer s.mu.RUnlock() cfg := domain.DefaultAppConfig() data, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return cfg, nil } return cfg, fmt.Errorf("read config: %w", err) } if err := json.Unmarshal(data, &cfg); err != nil { return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err) } cfg.Normalize() return cfg, nil } // Save persists the supplied config atomically (write-temp + rename) so // crashes mid-write cannot leave a half-written file. func (s *FileStore) Save(cfg domain.AppConfig) error { s.mu.Lock() defer s.mu.Unlock() cfg.Normalize() data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("marshal config: %w", err) } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("write tmp config: %w", err) } if err := os.Rename(tmp, s.path); err != nil { return fmt.Errorf("commit config: %w", err) } return nil }