fix(config): individual errors on json parse

Kieran Klukas created

Change summary

internal/config/load.go      |  6 ++++++
internal/config/load_test.go | 32 ++++++++++++++++++++++++++++++++
internal/config/store.go     |  3 +++
3 files changed, 41 insertions(+)

Detailed changes

internal/config/load.go 🔗

@@ -55,6 +55,9 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 
 	// Load workspace config last so it has highest priority.
 	if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
+		if !json.Valid(wsData) {
+			return nil, fmt.Errorf("invalid JSON in config file %s", store.workspacePath)
+		}
 		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
 		if mergeErr == nil {
 			// Preserve defaults that setDefaults already applied.
@@ -734,6 +737,9 @@ func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
 		if len(data) == 0 {
 			continue
 		}
+		if !json.Valid(data) {
+			return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
+		}
 		configs = append(configs, data)
 		loaded = append(loaded, path)
 	}

internal/config/load_test.go 🔗

@@ -36,6 +36,38 @@ func TestConfig_LoadFromBytes(t *testing.T) {
 	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
 }
 
+func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
+	t.Parallel()
+
+	t.Run("identifies the offending file", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+		good := filepath.Join(tmpDir, "good.json")
+		bad := filepath.Join(tmpDir, "bad.json")
+		require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
+		require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
+
+		_, _, err := loadFromConfigPaths([]string{good, bad})
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid JSON in config file")
+		require.Contains(t, err.Error(), "bad.json")
+	})
+
+	t.Run("skips missing and empty files", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+		empty := filepath.Join(tmpDir, "empty.json")
+		require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
+
+		cfg, _, err := loadFromConfigPaths([]string{
+			filepath.Join(tmpDir, "nonexistent.json"),
+			empty,
+		})
+		require.NoError(t, err)
+		require.NotNil(t, cfg)
+	})
+}
+
 // testStore wraps a Config in a minimal ConfigStore for testing.
 func testStore(cfg *Config) *ConfigStore {
 	return &ConfigStore{config: cfg}

internal/config/store.go 🔗

@@ -647,6 +647,9 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
 	// Merge workspace config if present
 	workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
 	if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
+		if !json.Valid(wsData) {
+			return fmt.Errorf("invalid JSON in config file %s", workspacePath)
+		}
 		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
 		if mergeErr == nil {
 			dataDir := cfg.Options.DataDirectory