From 2a196bcdb29dab4b15e59f75d6f5dfb9a8457ea3 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 20:10:47 -0400 Subject: [PATCH] Add GET/POST /settings handlers with injected applier Co-Authored-By: Claude Opus 4.8 --- internal/web/settings_handlers.go | 61 +++++++++++++++++ internal/web/settings_handlers_test.go | 90 ++++++++++++++++++++++++++ internal/web/web.go | 8 +++ 3 files changed, 159 insertions(+) create mode 100644 internal/web/settings_handlers.go create mode 100644 internal/web/settings_handlers_test.go diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go new file mode 100644 index 0000000..f0ecd75 --- /dev/null +++ b/internal/web/settings_handlers.go @@ -0,0 +1,61 @@ +package web + +import ( + "net/http" + + "antidrift/internal/settings" + + "github.com/gin-gonic/gin" +) + +// SetSettings injects the persisted settings, their file path, and the applier +// closure (built by main, which owns adapter construction). Mirrors the +// controller's Set* injection so web never imports ai/tasks/knowledge. +func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) { + s.settingsMu.Lock() + s.settingsPath = path + s.settings = current + s.applyFn = apply + s.settingsMu.Unlock() +} + +func (s *Server) handleGetSettings(c *gin.Context) { + s.settingsMu.Lock() + cur := s.settings + s.settingsMu.Unlock() + c.JSON(http.StatusOK, cur) +} + +// handlePostSettings validates-and-applies atomically, then persists. The applier +// checks the backend before mutating any state, so an invalid backend yields 400 +// with nothing saved and the prior wiring intact. +func (s *Server) handlePostSettings(c *gin.Context) { + var req settings.Settings + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.settingsMu.Lock() + apply := s.applyFn + path := s.settingsPath + s.settingsMu.Unlock() + if apply == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) + return + } + if err := apply(req); err != nil { + // Apply validates the backend before mutating anything, so any error + // (invalid backend or otherwise) means nothing changed; surface it as 400. + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := settings.Save(path, req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + s.settingsMu.Lock() + s.settings = req + s.settingsMu.Unlock() + s.broadcast() + c.JSON(http.StatusOK, req) +} diff --git a/internal/web/settings_handlers_test.go b/internal/web/settings_handlers_test.go new file mode 100644 index 0000000..3813669 --- /dev/null +++ b/internal/web/settings_handlers_test.go @@ -0,0 +1,90 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "antidrift/internal/settings" +) + +// fakeApplier records the last settings it was asked to apply and can be told to +// reject with ErrInvalidBackend. +type fakeApplier struct { + called int + last settings.Settings + reject bool +} + +func (f *fakeApplier) apply(s settings.Settings) error { + if f.reject { + return settings.ErrInvalidBackend + } + f.called++ + f.last = s + return nil +} + +func TestGetSettingsReturnsCurrent(t *testing.T) { + s := newTestServer(t) + cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} + fa := &fakeApplier{} + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) + r := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got settings.Settings + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got != cur { + t.Errorf("got %+v, want %+v", got, cur) + } +} + +func TestPostSettingsValidAppliesAndSaves(t *testing.T) { + s := newTestServer(t) + path := filepath.Join(t.TempDir(), "settings.json") + fa := &fakeApplier{} + s.SetSettings(path, settings.Settings{}, fa.apply) + r := s.Router() + + body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}` + w := post(t, r, "/settings", body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String()) + } + if fa.called != 1 { + t.Fatalf("applier called %d times, want 1", fa.called) + } + if fa.last.AIBackend != "codex" { + t.Errorf("applied backend = %q, want codex", fa.last.AIBackend) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("settings file not written: %v", err) + } +} + +func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { + s := newTestServer(t) + path := filepath.Join(t.TempDir(), "settings.json") + fa := &fakeApplier{reject: true} + s.SetSettings(path, settings.Settings{}, fa.apply) + r := s.Router() + + w := post(t, r, "/settings", `{"ai_backend":"bogus"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("settings file should not exist after rejected save, stat err = %v", err) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index f03f02e..6679b78 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -14,6 +14,7 @@ import ( "antidrift/internal/domain" "antidrift/internal/session" + "antidrift/internal/settings" "antidrift/internal/statemachine" "github.com/gin-gonic/gin" @@ -29,6 +30,11 @@ type Server struct { mu sync.Mutex timer *time.Timer + + settingsMu sync.Mutex + settings settings.Settings + settingsPath string + applyFn func(settings.Settings) error } func NewServer(ctrl *session.Controller) *Server { @@ -69,6 +75,8 @@ func (s *Server) Router() *gin.Engine { r.POST("/refocus", s.handleRefocus) r.POST("/ontask", s.handleOnTask) r.POST("/knowledge/path", s.handleKnowledgePath) + r.GET("/settings", s.handleGetSettings) + r.POST("/settings", s.handlePostSettings) return r }