From 1a3657de7637d754dd190dc17a8ca8a6a812ac9a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 6 Feb 2026 12:26:00 +0100 Subject: [PATCH] refactor: extract config persistence into Store abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Store interface and FileStore implementation that encapsulate JSON config read/write with pretty-printed output. Config.SetConfigField, RemoveConfigField, and HasConfigField now delegate to the store instead of doing raw file I/O inline. 🐾 Generated with Crush Assisted-by: Claude Opus 4.6 via Crush --- internal/config/config.go | 56 +++++----------------- internal/config/load.go | 1 + internal/config/store.go | 97 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 45 deletions(-) create mode 100644 internal/config/store.go diff --git a/internal/config/config.go b/internal/config/config.go index d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9..860143c9f805952409a1cb8572c5ac1c629d81a2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,8 +8,6 @@ import ( "maps" "net/http" "net/url" - "os" - "path/filepath" "slices" "strings" "time" @@ -22,8 +20,6 @@ import ( "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/invopop/jsonschema" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -391,6 +387,7 @@ type Config struct { workingDir string `json:"-"` // TODO: find a better way to do this this should probably not be part of the config resolver VariableResolver + store Store `json:"-"` dataConfigDir string `json:"-"` knownProviders []catwalk.Provider `json:"-"` } @@ -486,54 +483,23 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte return nil } -func (c *Config) HasConfigField(key string) bool { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return false +func (c *Config) configStore() Store { + if c.store == nil { + c.store = NewFileStore(c.dataConfigDir) } - return gjson.Get(string(data), key).Exists() + return c.store } -func (c *Config) SetConfigField(key string, value any) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - if os.IsNotExist(err) { - data = []byte("{}") - } else { - return fmt.Errorf("failed to read config file: %w", err) - } - } +func (c *Config) HasConfigField(key string) bool { + return HasField(c.configStore(), key) +} - newValue, err := sjson.Set(string(data), key, value) - if err != nil { - return fmt.Errorf("failed to set config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil +func (c *Config) SetConfigField(key string, value any) error { + return SetField(c.configStore(), key, value) } func (c *Config) RemoveConfigField(key string) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - newValue, err := sjson.Delete(string(data), key) - if err != nil { - return fmt.Errorf("failed to delete config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil + return RemoveField(c.configStore(), key) } // RefreshOAuthToken refreshes the OAuth token for the given provider. diff --git a/internal/config/load.go b/internal/config/load.go index a651f4846307ed9729ba8a10835e98aece486dbd..15b597a05a3d0a58b6ecba33250a24a74a447894 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -39,6 +39,7 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { } cfg.dataConfigDir = GlobalConfigData() + cfg.store = NewFileStore(cfg.dataConfigDir) cfg.setDefaults(workingDir, dataDir) diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000000000000000000000000000000000000..e29043546b46c248f51e0575c847c40f7bd1cb67 --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,97 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Store handles reading and writing raw config JSON data to persistent +// storage. +type Store interface { + Read() ([]byte, error) + Write(data []byte) error +} + +// FileStore is a Store backed by a file on disk. +type FileStore struct { + path string +} + +// NewFileStore creates a new FileStore at the given path. +func NewFileStore(path string) *FileStore { + return &FileStore{path: path} +} + +// Read returns the raw bytes from the backing file. If the file does not +// exist an empty JSON object is returned. +func (s *FileStore) Read() ([]byte, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return []byte("{}"), nil + } + return nil, fmt.Errorf("failed to read config file: %w", err) + } + return data, nil +} + +// Write persists raw bytes to the backing file, creating parent directories +// as needed. The JSON is pretty-printed with two-space indentation before +// writing. +func (s *FileStore) Write(data []byte) error { + var buf bytes.Buffer + if err := json.Indent(&buf, data, "", " "); err != nil { + return fmt.Errorf("failed to format config JSON: %w", err) + } + buf.WriteByte('\n') + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", s.path, err) + } + if err := os.WriteFile(s.path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// HasField returns true if the given dotted key path exists in the store. +func HasField(s Store, key string) bool { + data, err := s.Read() + if err != nil { + return false + } + return gjson.Get(string(data), key).Exists() +} + +// SetField sets a value at the given dotted key path and persists it. +func SetField(s Store, key string, value any) error { + data, err := s.Read() + if err != nil { + return err + } + + updated, err := sjson.Set(string(data), key, value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %w", key, err) + } + return s.Write([]byte(updated)) +} + +// RemoveField deletes a value at the given dotted key path and persists it. +func RemoveField(s Store, key string) error { + data, err := s.Read() + if err != nil { + return err + } + + updated, err := sjson.Delete(string(data), key) + if err != nil { + return fmt.Errorf("failed to delete config field %s: %w", key, err) + } + return s.Write([]byte(updated)) +}