diff --git a/cmd/gnoma/upgrade_config_cmd.go b/cmd/gnoma/upgrade_config_cmd.go index b8f785b..c8de1b4 100644 --- a/cmd/gnoma/upgrade_config_cmd.go +++ b/cmd/gnoma/upgrade_config_cmd.go @@ -15,31 +15,67 @@ import ( // // Single-file mode only. `--all-projects` is deferred to the // project-registry work in the 2026-05-24 config-migration plan. +// +// Target selection: +// - `gnoma upgrade-config` (no args) → project config +// - `gnoma upgrade-config --global` → global config +// - `gnoma upgrade-config ` → the given path +// - `gnoma upgrade-config --global ` → error (mutually exclusive) +// +// If the default target (project or global config) doesn't exist, +// print a friendly "nothing to upgrade" message and exit 0 — not +// a hard error. The user can pass an explicit path to upgrade a +// different file. func runUpgradeConfigCommand(args []string) int { + // Walk args in a single pass, building pathArgs into a fresh + // slice. Using args[:i] / args[i+1:] in-place would alias the + // underlying array and corrupt subsequent iterations' `a` + // reads (a known Go slice footgun). The fresh-slice approach + // keeps the parsing correct regardless of flag ordering. + var pathArgs []string dryRun := false - pathArgs := args - for i, a := range args { - if a == "--dry-run" { + global := false + for _, a := range args { + switch a { + case "--dry-run": dryRun = true - pathArgs = append(args[:i], args[i+1:]...) - break + case "--global": + global = true + default: + pathArgs = append(pathArgs, a) } } - // Default to the project config if no path given — matches - // the convention of `gnoma config set` writing to the project - // file by default. + // --global and an explicit path are mutually exclusive. + if global && len(pathArgs) > 0 { + fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [--global | ]") + return 1 + } target := "" - switch len(pathArgs) { - case 0: + switch { + case global: + target = gnomacfg.GlobalConfigPath() + case len(pathArgs) == 0: target = gnomacfg.ProjectConfigPath() - case 1: + case len(pathArgs) == 1: target = pathArgs[0] default: - fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [path]") + fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [--global | ]") return 1 } + // Friendly "nothing to upgrade" when the default target + // doesn't exist. We only do this for the default targets + // (project/global); an explicit path the user typed that + // doesn't exist is a real error surfaced by Upgrade() below. + if global || len(pathArgs) == 0 { + if _, err := os.Stat(target); os.IsNotExist(err) { + fmt.Printf("%s: no such file, nothing to upgrade\n", target) + fmt.Println("hint: pass an explicit path, or use --global for the user-level config") + return 0 + } + } + if dryRun { return runUpgradeConfigDryRun(target) } diff --git a/cmd/gnoma/upgrade_config_cmd_test.go b/cmd/gnoma/upgrade_config_cmd_test.go index 5b2a410..cf73546 100644 --- a/cmd/gnoma/upgrade_config_cmd_test.go +++ b/cmd/gnoma/upgrade_config_cmd_test.go @@ -140,3 +140,81 @@ func TestRunUpgradeConfig_AlreadyCleanIsNoOp(t *testing.T) { } } } + +// TestRunUpgradeConfig_MissingProjectConfigIsFriendly verifies the +// user-experience fix for the 2026-06-04 follow-up: when the +// project .gnoma/config.toml doesn't exist, print a friendly +// "nothing to upgrade" message and exit 0 instead of a hard +// "no such file or directory" error. The user can pass an +// explicit path or use --global. +func TestRunUpgradeConfig_MissingProjectConfigIsFriendly(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + origDir, _ := os.Getwd() + projectDir := filepath.Join(dir, "project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // No .gnoma/ dir at all — Upgrade() would error. + if rc := runUpgradeConfigCommand(nil); rc != 0 { + t.Errorf("rc = %d, want 0 for missing project config (friendly exit)", rc) + } +} + +// TestRunUpgradeConfig_MissingGlobalConfigIsFriendly mirrors +// the above for --global. The user-level config not existing +// is also "nothing to upgrade", not an error. +func TestRunUpgradeConfig_MissingGlobalConfigIsFriendly(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + // Don't create the global config dir either. + + if rc := runUpgradeConfigCommand([]string{"--global"}); rc != 0 { + t.Errorf("rc = %d, want 0 for missing global config (friendly exit)", rc) + } +} + +// TestRunUpgradeConfig_GlobalFlagUpgradesGlobalConfig verifies +// the --global flag actually points at the global config and +// upgrades it. +func TestRunUpgradeConfig_GlobalFlagUpgradesGlobalConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + // Seed a global config with a default-equivalent field. + globalDir := filepath.Join(dir, "gnoma") + if err := os.MkdirAll(globalDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + globalPath := filepath.Join(globalDir, "config.toml") + if err := os.WriteFile(globalPath, []byte("[provider]\nmax_tokens = 8192\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + if rc := runUpgradeConfigCommand([]string{"--global"}); rc != 0 { + t.Errorf("rc = %d, want 0", rc) + } + + got, _ := os.ReadFile(globalPath) + if strings.Contains(string(got), "max_tokens") { + t.Errorf("max_tokens at default not dropped from global config, got:\n%s", got) + } +} + +// TestRunUpgradeConfig_GlobalWithExplicitPathIsError verifies +// the mutually-exclusive-flag handling: --global and an +// explicit path can't both be supplied. +func TestRunUpgradeConfig_GlobalWithExplicitPathIsError(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + if rc := runUpgradeConfigCommand([]string{"--global", "/tmp/somewhere/config.toml"}); rc != 1 { + t.Errorf("rc = %d, want 1 for --global + explicit path", rc) + } +}