refactor: extract config persistence into Store abstraction

Kujtim Hoxha created

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 <crush@charm.land>

Change summary

internal/config/config.go | 56 ++++-------------------
internal/config/load.go   |  1 
internal/config/store.go  | 97 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 109 insertions(+), 45 deletions(-)

Detailed changes

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.

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)
 

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))
+}