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