diff --git a/cmd/config/config.go b/cmd/config/config.go index 598423696..5f98bd5ed 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -20,6 +21,14 @@ type config struct { } func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ollama", "config.json"), nil +} + +func legacyConfigPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err @@ -27,6 +36,46 @@ func configPath() (string, error) { return filepath.Join(home, ".ollama", "config", "config.json"), nil } +// migrateConfig moves the config from the legacy path to ~/.ollama/config.json +func migrateConfig() (bool, error) { + oldPath, err := legacyConfigPath() + if err != nil { + return false, err + } + + oldData, err := os.ReadFile(oldPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + var js json.RawMessage + if err := json.Unmarshal(oldData, &js); err != nil { + slog.Warn("legacy config has invalid JSON, skipping migration", "path", oldPath, "error", err) + return false, nil + } + + newPath, err := configPath() + if err != nil { + return false, err + } + + if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(newPath, oldData, 0o644); err != nil { + return false, fmt.Errorf("write new config: %w", err) + } + + _ = os.Remove(oldPath) + _ = os.Remove(filepath.Dir(oldPath)) // clean up empty directory + + slog.Info("migrated config", "from", oldPath, "to", newPath) + return true, nil +} + func load() (*config, error) { path, err := configPath() if err != nil { @@ -34,6 +83,11 @@ func load() (*config, error) { } data, err := os.ReadFile(path) + if err != nil && os.IsNotExist(err) { + if migrated, merr := migrateConfig(); merr == nil && migrated { + data, err = os.ReadFile(path) + } + } if err != nil { if os.IsNotExist(err) { return &config{Integrations: make(map[string]*integration)}, nil diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 2f9823f9e..ae87c6a40 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -200,12 +200,10 @@ func TestLoadIntegration_CorruptedJSON(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) - // Create corrupted config.json file - dir := filepath.Join(tmpDir, ".ollama", "config") + dir := filepath.Join(tmpDir, ".ollama") os.MkdirAll(dir, 0o755) os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{corrupted json`), 0o644) - // Corrupted file is treated as empty, so loadIntegration returns not found _, err := loadIntegration("test") if err == nil { t.Error("expected error for nonexistent integration in corrupted file") @@ -267,7 +265,7 @@ func TestConfigPath(t *testing.T) { t.Fatal(err) } - expected := filepath.Join(tmpDir, ".ollama", "config", "config.json") + expected := filepath.Join(tmpDir, ".ollama", "config.json") if path != expected { t.Errorf("expected %s, got %s", expected, path) } @@ -322,6 +320,183 @@ func TestLoad(t *testing.T) { }) } +func TestMigrateConfig(t *testing.T) { + t.Run("migrates legacy file to new location", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + data := []byte(`{"integrations":{"claude":{"models":["llama3.2"]}}}`) + os.WriteFile(filepath.Join(legacyDir, "config.json"), data, 0o644) + + migrated, err := migrateConfig() + if err != nil { + t.Fatal(err) + } + if !migrated { + t.Fatal("expected migration to occur") + } + + newPath, _ := configPath() + got, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("new config not found: %v", err) + } + if string(got) != string(data) { + t.Errorf("content mismatch: got %s", got) + } + + if _, err := os.Stat(filepath.Join(legacyDir, "config.json")); !os.IsNotExist(err) { + t.Error("legacy file should have been removed") + } + + if _, err := os.Stat(legacyDir); !os.IsNotExist(err) { + t.Error("legacy directory should have been removed") + } + }) + + t.Run("no-op when no legacy file exists", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + migrated, err := migrateConfig() + if err != nil { + t.Fatal(err) + } + if migrated { + t.Error("expected no migration") + } + }) + + t.Run("skips corrupt legacy file", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{corrupt`), 0o644) + + migrated, err := migrateConfig() + if err != nil { + t.Fatal(err) + } + if migrated { + t.Error("should not migrate corrupt file") + } + + if _, err := os.Stat(filepath.Join(legacyDir, "config.json")); os.IsNotExist(err) { + t.Error("corrupt legacy file should not have been deleted") + } + }) + + t.Run("new path takes precedence over legacy", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{"old":{"models":["old-model"]}}}`), 0o644) + + newDir := filepath.Join(tmpDir, ".ollama") + os.WriteFile(filepath.Join(newDir, "config.json"), []byte(`{"integrations":{"new":{"models":["new-model"]}}}`), 0o644) + + cfg, err := load() + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Integrations["new"]; !ok { + t.Error("expected new-path integration to be loaded") + } + if _, ok := cfg.Integrations["old"]; ok { + t.Error("legacy integration should not have been loaded") + } + }) + + t.Run("idempotent when called twice", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{}}`), 0o644) + + if _, err := migrateConfig(); err != nil { + t.Fatal(err) + } + + migrated, err := migrateConfig() + if err != nil { + t.Fatal(err) + } + if migrated { + t.Error("second migration should be a no-op") + } + }) + + t.Run("legacy directory preserved if not empty", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{}}`), 0o644) + os.WriteFile(filepath.Join(legacyDir, "other-file.txt"), []byte("keep me"), 0o644) + + if _, err := migrateConfig(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(legacyDir); os.IsNotExist(err) { + t.Error("directory with other files should not have been removed") + } + if _, err := os.Stat(filepath.Join(legacyDir, "other-file.txt")); os.IsNotExist(err) { + t.Error("other files in legacy directory should be untouched") + } + }) + + t.Run("save writes to new path after migration", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{"claude":{"models":["llama3.2"]}}}`), 0o644) + + // load triggers migration, then save should write to new path + if err := saveIntegration("codex", []string{"qwen2.5"}); err != nil { + t.Fatal(err) + } + + newPath := filepath.Join(tmpDir, ".ollama", "config.json") + if _, err := os.Stat(newPath); os.IsNotExist(err) { + t.Error("save should write to new path") + } + + // old path should not be recreated + if _, err := os.Stat(filepath.Join(legacyDir, "config.json")); !os.IsNotExist(err) { + t.Error("save should not recreate legacy path") + } + }) + + t.Run("load triggers migration transparently", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + legacyDir := filepath.Join(tmpDir, ".ollama", "config") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"integrations":{"claude":{"models":["llama3.2"]}}}`), 0o644) + + cfg, err := load() + if err != nil { + t.Fatal(err) + } + if cfg.Integrations["claude"] == nil || cfg.Integrations["claude"].Models[0] != "llama3.2" { + t.Error("migration via load() did not preserve data") + } + }) +} + func TestSave(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir)