From e3351b3d70cde2e4a8085791029d983cef8afec4 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 20:07:42 -0400 Subject: [PATCH] Add settings package: Settings file load/save/seed Co-Authored-By: Claude Opus 4.8 --- internal/settings/settings.go | 80 ++++++++++++++++++++++++++++++ internal/settings/settings_test.go | 60 ++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 internal/settings/settings.go create mode 100644 internal/settings/settings_test.go diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..f49e8e6 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,80 @@ +// Package settings persists the daemon's user-editable configuration as a single +// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a +// leaf type package: it imports nothing else in the app so any layer may depend +// on the Settings value without pulling in adapters. +package settings + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// ErrInvalidBackend marks a settings value whose ai_backend is not a known +// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400 +// without importing the ai package. +var ErrInvalidBackend = errors.New("settings: invalid ai backend") + +// Settings is the user-editable configuration. Field names mirror the env vars +// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE. +type Settings struct { + AIBackend string `json:"ai_backend"` + MarvinCmd string `json:"marvin_cmd"` + KnowledgePath string `json:"knowledge_path"` +} + +// DefaultPath returns ~/.antidrift/settings.json. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".antidrift", "settings.json"), nil +} + +// Load reads a settings file. A missing file is an error; callers treat that as +// "first run" and seed instead. +func Load(path string) (Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + return Settings{}, err + } + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + return Settings{}, err + } + return s, nil +} + +// Save writes settings atomically (temp file + rename), creating the directory +// if needed. Mirrors store.Save. +func Save(path string, s Settings) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying +// the same defaults the daemon used before the settings file existed. An unset +// AI backend defaults to "claude" (matching ai.NewBackend("")). +func SeedFromEnv() Settings { + backend := os.Getenv("ANTIDRIFT_AI_BACKEND") + if backend == "" { + backend = "claude" + } + return Settings{ + AIBackend: backend, + MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), + KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), + } +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..b283187 --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,60 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} + if err := Save(path, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Errorf("round trip = %+v, want %+v", got, want) + } +} + +func TestLoadMissingFileIsError(t *testing.T) { + _, err := Load(filepath.Join(t.TempDir(), "nope.json")) + if err == nil { + t.Fatal("want error loading missing file, got nil") + } +} + +func TestSeedFromEnvReadsVars(t *testing.T) { + t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") + t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") + t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") + got := SeedFromEnv() + want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} + if got != want { + t.Errorf("SeedFromEnv = %+v, want %+v", got, want) + } +} + +func TestSeedFromEnvDefaultsBackend(t *testing.T) { + t.Setenv("ANTIDRIFT_AI_BACKEND", "") + t.Setenv("ANTIDRIFT_MARVIN_CMD", "") + t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") + got := SeedFromEnv() + if got.AIBackend != "claude" { + t.Errorf("default backend = %q, want claude", got.AIBackend) + } +} + +func TestSaveCreatesDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "settings.json") + if err := Save(path, Settings{AIBackend: "claude"}); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("file not written: %v", err) + } +}