92 lines
2.6 KiB
Go
92 lines
2.6 KiB
Go
// 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
|
|
}
|