diff --git a/src/cmd/uninstall.go b/src/cmd/uninstall.go index f24a92e..4441ea4 100644 --- a/src/cmd/uninstall.go +++ b/src/cmd/uninstall.go @@ -24,7 +24,10 @@ This command removes a specific version of a runtime from your system. The version directory and all its contents will be deleted. Safety features: - - Cannot uninstall the currently active global version + - Cannot uninstall the global version while other versions are installed + (set a different global version first) + - Uninstalling the only installed version clears the global version, so + the runtime's commands fall back to your system PATH - Prompts for confirmation before deletion - Automatically regenerates shims after uninstall @@ -58,23 +61,41 @@ Examples: return } - // Check if this is the currently active global version + // The global version can only go while nothing else could replace + // it: with other versions installed the user picks the new global + // first, and with none the global setting is cleared afterwards. + // Install sets the first version as global, so refusing outright + // would leave the last version impossible to remove. + clearGlobal := false globalVersion, err := provider.GlobalVersion() if err == nil && globalVersion == version { - ui.Error("Cannot uninstall the currently active global version") - ui.Info("Current global version: v%s", globalVersion) - ui.Info("Set a different global version first: dtvem global %s ", runtimeName) - return + installed, err := provider.ListInstalled() + if err != nil { + ui.Error("Could not list installed %s versions: %v", provider.DisplayName(), err) + return + } + if others := otherVersions(installed, version); len(others) > 0 { + ui.Error("Cannot uninstall the global version while other versions are installed") + ui.Info("Current global version: v%s", globalVersion) + ui.Info("Other installed versions: %s", strings.Join(others, ", ")) + ui.Info("Set a different global version first: dtvem global %s ", runtimeName) + return + } + clearGlobal = true } - // Check if this is the currently active local version - localVersion, err := provider.LocalVersion() - if err == nil && localVersion == version { + // Check if a project file pins this version for the working directory + if source := localPinSource(provider.Name(), version); source != "" { ui.Warning("This is the currently active local version in this directory") - ui.Info("Local version file: dtvem.config.json") + ui.Info("Local version file: %s", source) ui.Info("You may need to update or remove it after uninstalling") } + if clearGlobal { + ui.Warning("v%s is the global %s version and the only one installed", version, provider.DisplayName()) + ui.Info("The global %s version will be cleared", provider.DisplayName()) + } + // Prompt for confirmation (unless --yes flag is provided) if !uninstallYes { fmt.Printf("\n") @@ -104,6 +125,15 @@ Examples: spinner.Success(fmt.Sprintf("%s v%s removed", provider.DisplayName(), version)) + if clearGlobal { + if err := config.UnsetGlobalVersion(provider.Name()); err != nil { + ui.Warning("Could not clear the global %s version: %v", provider.DisplayName(), err) + ui.Info("Remove the %q entry from %s", provider.Name(), config.GlobalConfigPath()) + } else { + ui.Info("Cleared the global %s version", provider.DisplayName()) + } + } + // Regenerate shims shimSpinner := ui.NewSpinner("Regenerating shims...") shimSpinner.Start() @@ -125,6 +155,30 @@ Examples: }, } +// localPinSource returns the file that sets version for runtimeName in the +// working directory or one of its parents, or "" when none does. The global +// config does not count: provider.LocalVersion falls back to it, which made +// the global version look pinned locally everywhere. +func localPinSource(runtimeName, version string) string { + res, err := config.Resolve(runtimeName) + if err != nil || res.Version != version || res.Source == config.GlobalConfigPath() { + return "" + } + return res.Source +} + +// otherVersions returns the installed versions other than version, in the +// order given. +func otherVersions(installed []runtime.InstalledVersion, version string) []string { + var others []string + for _, iv := range installed { + if iv.Version.Raw != version { + others = append(others, iv.Version.Raw) + } + } + return others +} + func init() { uninstallCmd.Flags().BoolVarP(&uninstallYes, "yes", "y", false, "Skip confirmation prompt") rootCmd.AddCommand(uninstallCmd) diff --git a/src/cmd/uninstall_test.go b/src/cmd/uninstall_test.go index 96db50e..6a77664 100644 --- a/src/cmd/uninstall_test.go +++ b/src/cmd/uninstall_test.go @@ -3,10 +3,12 @@ package cmd import ( "os" "path/filepath" + "slices" "strings" "testing" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" ) func TestUninstallCommand_VersionStripping(t *testing.T) { @@ -208,3 +210,130 @@ func TestUninstallCommand_ConfirmationResponse(t *testing.T) { }) } } + +func TestOtherVersions(t *testing.T) { + installed := func(versions ...string) []runtime.InstalledVersion { + out := make([]runtime.InstalledVersion, 0, len(versions)) + for _, v := range versions { + out = append(out, runtime.InstalledVersion{Version: runtime.NewVersion(v)}) + } + return out + } + + tests := []struct { + name string + installed []runtime.InstalledVersion + version string + want []string + }{ + { + name: "Only version installed", + installed: installed("3.4.7"), + version: "3.4.7", + want: nil, + }, + { + name: "Other versions installed", + installed: installed("22.0.0", "22.12.0", "25.2.0"), + version: "25.2.0", + want: []string{"22.0.0", "22.12.0"}, + }, + { + name: "Version not in the list", + installed: installed("3.12.12", "3.9.9"), + version: "3.8.9", + want: []string{"3.12.12", "3.9.9"}, + }, + { + name: "Nothing installed", + installed: nil, + version: "3.4.7", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := otherVersions(tt.installed, tt.version) + if !slices.Equal(got, tt.want) { + t.Errorf("otherVersions(%v, %q) = %v, want %v", tt.installed, tt.version, got, tt.want) + } + }) + } +} + +func TestLocalPinSource(t *testing.T) { + tmp := t.TempDir() + t.Setenv("DTVEM_ROOT", filepath.Join(tmp, "dtvem")) + config.ResetPathsCache() + t.Cleanup(config.ResetPathsCache) + + if err := os.MkdirAll(config.RuntimeVersionPath("ruby", "3.4.7"), 0755); err != nil { + t.Fatalf("Failed to create installed version: %v", err) + } + if err := config.SetGlobalVersion("ruby", "3.4.7"); err != nil { + t.Fatalf("SetGlobalVersion() setup error: %v", err) + } + + pinned := filepath.Join(tmp, "pinned") + unpinned := filepath.Join(tmp, "unpinned") + for _, dir := range []string{pinned, unpinned} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create %s: %v", dir, err) + } + } + versionFile := filepath.Join(pinned, ".ruby-version") + if err := os.WriteFile(versionFile, []byte("3.4\n"), 0644); err != nil { + t.Fatalf("Failed to write .ruby-version: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + + chdir := func(dir string) { + t.Helper() + if err := os.Chdir(dir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + } + + t.Run("Global version is not a local pin", func(t *testing.T) { + chdir(unpinned) + if got := localPinSource("ruby", "3.4.7"); got != "" { + t.Errorf("localPinSource() with only a global version = %q, want \"\"", got) + } + }) + + t.Run("Project file pins the version", func(t *testing.T) { + chdir(pinned) + got := localPinSource("ruby", "3.4.7") + if got == "" || !sameFile(t, got, versionFile) { + t.Errorf("localPinSource() = %q, want %q", got, versionFile) + } + }) + + t.Run("Project file pins a different version", func(t *testing.T) { + chdir(pinned) + if got := localPinSource("ruby", "3.3.0"); got != "" { + t.Errorf("localPinSource() for another version = %q, want \"\"", got) + } + }) +} + +// sameFile reports whether two paths name the same file. Temp paths can +// differ only by symlinks (macOS reports /var/... as /private/var/...). +func sameFile(t *testing.T, a, b string) bool { + t.Helper() + ai, err := os.Stat(a) + if err != nil { + t.Fatalf("Failed to stat %s: %v", a, err) + } + bi, err := os.Stat(b) + if err != nil { + t.Fatalf("Failed to stat %s: %v", b, err) + } + return os.SameFile(ai, bi) +} diff --git a/src/internal/config/version.go b/src/internal/config/version.go index bb0a9dc..fd69ce8 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -253,6 +253,40 @@ func SetGlobalVersion(runtimeName, version string) error { return os.WriteFile(configPath, data, 0644) } +// UnsetGlobalVersion removes a runtime's entry from the global config, so +// the runtime has no global version. Other runtimes' entries are kept. +// It does nothing when there is no global config or no entry for the +// runtime, and returns an error rather than rewriting a config it cannot +// parse. +func UnsetGlobalVersion(runtimeName string) error { + configPath := GlobalConfigPath() + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read global config: %w", err) + } + + config := make(RuntimesConfig) + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse global config %s: %w", configPath, err) + } + + if _, ok := config[runtimeName]; !ok { + return nil + } + delete(config, runtimeName) + + data, err = json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0644) +} + // SetLocalVersion sets the local version for a runtime in the current directory func SetLocalVersion(runtimeName, version string) error { configDir := LocalConfigDir() diff --git a/src/internal/config/version_test.go b/src/internal/config/version_test.go index c133be1..3c02d74 100644 --- a/src/internal/config/version_test.go +++ b/src/internal/config/version_test.go @@ -482,6 +482,110 @@ func TestGlobalVersion_RuntimeNotInConfig(t *testing.T) { } } +func TestUnsetGlobalVersion_RemovesOnlyThatRuntime(t *testing.T) { + useTempRoot(t) + + for runtimeName, version := range map[string]string{"ruby": "3.4.7", "node": "22.12.0"} { + if err := SetGlobalVersion(runtimeName, version); err != nil { + t.Fatalf("SetGlobalVersion(%q) setup error: %v", runtimeName, err) + } + } + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Fatalf("UnsetGlobalVersion() error: %v", err) + } + + if version, err := GlobalVersion("ruby"); err != nil || version != "" { + t.Errorf("GlobalVersion(ruby) after unset = (%q, %v), want (\"\", nil)", version, err) + } + if version, err := GlobalVersion("node"); err != nil || version != "22.12.0" { + t.Errorf("GlobalVersion(node) after unsetting ruby = (%q, %v), want (%q, nil)", version, err, "22.12.0") + } +} + +func TestUnsetGlobalVersion_LastRuntimeLeavesEmptyConfig(t *testing.T) { + useTempRoot(t) + + if err := SetGlobalVersion("ruby", "3.4.7"); err != nil { + t.Fatalf("SetGlobalVersion() setup error: %v", err) + } + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Fatalf("UnsetGlobalVersion() error: %v", err) + } + + data, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + var config RuntimesConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("Config is not valid JSON after unset: %v\n%s", err, data) + } + if len(config) != 0 { + t.Errorf("Config after unsetting the only runtime = %v, want empty", config) + } +} + +func TestUnsetGlobalVersion_NoConfigFile(t *testing.T) { + useTempRoot(t) + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Errorf("UnsetGlobalVersion() with no config file error: %v", err) + } + if _, err := os.Stat(GlobalConfigPath()); !os.IsNotExist(err) { + t.Errorf("UnsetGlobalVersion() with no config file should not create one (stat err: %v)", err) + } +} + +func TestUnsetGlobalVersion_RuntimeNotInConfig(t *testing.T) { + useTempRoot(t) + + if err := SetGlobalVersion("python", "3.12.12"); err != nil { + t.Fatalf("SetGlobalVersion() setup error: %v", err) + } + before, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + if err := UnsetGlobalVersion("ruby"); err != nil { + t.Errorf("UnsetGlobalVersion() for a runtime not in config error: %v", err) + } + + after, err := os.ReadFile(GlobalConfigPath()) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + if string(after) != string(before) { + t.Errorf("Config changed when unsetting a runtime not in it:\nbefore: %s\nafter: %s", before, after) + } +} + +func TestUnsetGlobalVersion_CorruptConfigIsNotRewritten(t *testing.T) { + useTempRoot(t) + + configPath := GlobalConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + corrupt := []byte(`{"ruby": "3.4.7", `) + if err := os.WriteFile(configPath, corrupt, 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + if err := UnsetGlobalVersion("ruby"); err == nil { + t.Error("UnsetGlobalVersion() with corrupt config should return an error") + } + + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + if string(after) != string(corrupt) { + t.Errorf("Corrupt config was rewritten: got %s", after) + } +} + func TestSetLocalVersion_CreatesDirectoryAndFile(t *testing.T) { // Create temp directory and change to it tmpRoot := t.TempDir()