diff --git a/internal/dependencies/installer.go b/internal/dependencies/installer.go index 8a3a1649c..8b90dc3fa 100644 --- a/internal/dependencies/installer.go +++ b/internal/dependencies/installer.go @@ -15,10 +15,13 @@ package dependencies import ( + "bytes" "errors" "fmt" "io" "os/exec" + "runtime" + "strings" "github.com/datarobot/cli/internal/log" "github.com/datarobot/cli/internal/repo" @@ -45,7 +48,9 @@ func InstallPrerequisites(w io.Writer, prerequisites []tools.Prerequisite) ([]st fmt.Fprintf(w, "πŸ“¦ Installing %s...\n", prerequisite.Name) - exitCode, err := ExecuteShLine(installCmd, w) + var cmdBuf bytes.Buffer + + exitCode, err := ExecuteShLine(installCmd, io.MultiWriter(w, &cmdBuf)) if err != nil { log.Debug("deps: install failed to start", "name", prerequisite.Name, "err", err) @@ -55,7 +60,13 @@ func InstallPrerequisites(w io.Writer, prerequisites []tools.Prerequisite) ([]st if exitCode != 0 { log.Debug("deps: install exited non-zero", "name", prerequisite.Name, "exit_code", exitCode) - return installed, fmt.Errorf("install failed for %q (exit code %d)\n Please run manually: %s\n Or check %s", prerequisite.Name, exitCode, installCmd, prerequisite.URL) + env := DetectEnvironment() + permDenied := isPermissionDenied(exitCode, cmdBuf.String()) + msg := buildInstallFailureMsg(prerequisite, exitCode, permDenied, env, runtime.GOOS) + + fmt.Fprint(w, msg) + + return installed, fmt.Errorf("install failed for %q (exit code %d)", prerequisite.Name, exitCode) } log.Debug("deps: tool installed", "name", prerequisite.Name) @@ -81,6 +92,102 @@ func InstallPrerequisites(w io.Writer, prerequisites []tools.Prerequisite) ([]st return installed, nil } +// isPermissionDenied inspects exit codes and stderr text across OS types. +func isPermissionDenied(exitCode int, stderr string) bool { + stderrLower := strings.ToLower(stderr) + + if strings.Contains(stderrLower, "permission denied") || + strings.Contains(stderrLower, "operation not permitted") || + strings.Contains(stderrLower, "access is denied") || + strings.Contains(stderrLower, "requires root privileges") || + strings.Contains(stderrLower, "unauthorizedaccessexception") { + return true + } + + // Unix-like systems (Linux & macOS) return 126 when a file lacks execute permissions + if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && exitCode == 126 { + return true + } + + return false +} + +// extractFailedManager heuristically identifies the package/version manager +// referenced in cmd (e.g. "brew" in "brew install uv"). Returns "" if none found. +func extractFailedManager(cmd string) string { + for _, m := range knownManagers { + if strings.Contains(cmd, m) { + return m + } + } + + return "" +} + +// buildInstallFailureMsg composes the user-facing failure message for a failed install. +// env and goos are injectable for testing. +func buildInstallFailureMsg(prerequisite tools.Prerequisite, exitCode int, permDenied bool, env map[string]bool, goos string) string { + toolName := prerequisite.Name + + installCmd, _ := prerequisite.PlatformInstallCommand() + + var sb strings.Builder + + if permDenied { + fmt.Fprintf(&sb, "βœ— %s install failed (exit code %d β€” permission denied)\n", toolName, exitCode) + } else { + fmt.Fprintf(&sb, "βœ— %s install failed (exit code %d)\n", toolName, exitCode) + } + + fmt.Fprintf(&sb, TAB+"Tried: %s\n", installCmd) + + if tip := buildInstallTip(prerequisite, permDenied, env, goos); tip != "" { + fmt.Fprintf(&sb, "%s\n", tip) + } + + fmt.Fprintf(&sb, TAB+"Raw command if you want to retry: %s\n", installCmd) + + if prerequisite.URL != "" { + fmt.Fprintf(&sb, TAB+"Refer to %s for manual installation instructions.\n", prerequisite.URL) + } + + return sb.String() +} + +// buildInstallTip returns the optional tip line for buildInstallFailureMsg, or "" when +// no actionable suggestion is available. +func buildInstallTip(prerequisite tools.Prerequisite, permDenied bool, env map[string]bool, goos string) string { + if permDenied { + switch goos { + case "windows": + return TAB + "Tip: This action requires Administrator privileges. Please restart your terminal/tool as Administrator." + case "darwin", "linux": + return TAB + "Tip: This action requires root privileges. Please re-run this tool using 'sudo'." + default: + return TAB + "Tip: Administrative or root privileges are required to perform this action." + } + } + + toolKey := prerequisite.Key + if toolKey == "" { + toolKey = NormalizeToolName(prerequisite.Name) + } + + if toolKey == "" { + return "" + } + + installCmd, _ := prerequisite.PlatformInstallCommand() + failedMgr := extractFailedManager(installCmd) + + strategy := selectInstallStrategy(toolKey, failedMgr, env) + if strategy == nil { + return "" + } + + return strategy.withVersion(prerequisite.MinimumVersion).getStrategyTip(goos) +} + // ExecuteShLine executes shellCmd via sh -c, streaming stdout and stderr // to w in real time. Handles pipe-based commands (e.g. curl ... | sh). // Returns the process exit code; a non-nil error indicates the process could diff --git a/internal/dependencies/installer_test.go b/internal/dependencies/installer_test.go index f6af0d9ea..fcb7f5157 100644 --- a/internal/dependencies/installer_test.go +++ b/internal/dependencies/installer_test.go @@ -167,9 +167,10 @@ func setupFakeRepo(t *testing.T) string { } // prereq builds a Prerequisite with the same install command on all platforms. -func prereq(name, installCmd string) tools.Prerequisite { +func prereq(name, installCmd, version string) tools.Prerequisite { return tools.Prerequisite{ - Name: name, + Name: name, + MinimumVersion: version, Install: tools.InstallCommands{ MacOS: installCmd, Linux: installCmd, @@ -195,7 +196,7 @@ func TestInstallPrerequisites_Success(t *testing.T) { var out bytes.Buffer - installed, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv")}) + installed, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv", "")}) require.NoError(t, err) assert.Equal(t, []string{"uv"}, installed) @@ -209,8 +210,8 @@ func TestInstallPrerequisites_ReturnsAllInstalledNames(t *testing.T) { var out bytes.Buffer deps := []tools.Prerequisite{ - prereq("uv", "echo install-uv"), - prereq("task", "echo install-task"), + prereq("uv", "echo install-uv", ""), + prereq("task", "echo install-task", ""), } installed, err := InstallPrerequisites(&out, deps) @@ -223,8 +224,8 @@ func TestInstallPrerequisites_ReturnsPartialInstalledOnFailure(t *testing.T) { var out bytes.Buffer deps := []tools.Prerequisite{ - prereq("uv", "echo install-uv"), - prereq("task", "exit 1"), + prereq("uv", "echo install-uv", ""), + prereq("task", "exit 1", ""), } installed, err := InstallPrerequisites(&out, deps) @@ -248,11 +249,11 @@ func TestInstallPrerequisites_ReturnsEmptyWhenNoPlatformCommand(t *testing.T) { func TestInstallPrerequisites_FailureShowsRawCommand(t *testing.T) { var out bytes.Buffer - installed, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "exit 1")}) + installed, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "exit 1", "")}) assert.Empty(t, installed) require.Error(t, err) - assert.Contains(t, err.Error(), "exit 1") + assert.Contains(t, out.String(), "exit 1") } func TestInstallPrerequisites_NoPlatformCommand(t *testing.T) { @@ -300,7 +301,7 @@ func TestInstallPrerequisites_WritesStateOnSuccess(t *testing.T) { var out bytes.Buffer - _, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv")}) + _, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv", "")}) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(repoRoot, ".datarobot", "cli", "state.yaml")) @@ -314,8 +315,8 @@ func TestInstallPrerequisites_PartialFailureDoesNotWriteState(t *testing.T) { var out bytes.Buffer deps := []tools.Prerequisite{ - prereq("failing", "exit 1"), - prereq("ok", "echo ok"), + prereq("failing", "exit 1", ""), + prereq("ok", "echo ok", ""), } _, err := InstallPrerequisites(&out, deps) @@ -330,10 +331,276 @@ func TestLastSuccessDepsCheck_UpdatedByInstallPrerequisites(t *testing.T) { var out bytes.Buffer - _, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv")}) + _, err := InstallPrerequisites(&out, []tools.Prerequisite{prereq("uv", "echo install-uv", "")}) require.NoError(t, err) data, err := os.ReadFile(filepath.Join(repoRoot, ".datarobot", "cli", "state.yaml")) require.NoError(t, err) assert.Contains(t, string(data), "last_success_deps_check") } + +// --- isPermissionDenied tests --- + +func TestIsPermissionDenied_ExitCode126(t *testing.T) { + assert.True(t, isPermissionDenied(126, "")) +} + +func TestIsPermissionDenied_NonSpecialExitCode(t *testing.T) { + assert.False(t, isPermissionDenied(1, "")) +} + +func TestIsPermissionDenied_StderrPermissionDenied(t *testing.T) { + assert.True(t, isPermissionDenied(1, "sh: permission denied")) +} + +func TestIsPermissionDenied_StderrOperationNotPermitted(t *testing.T) { + assert.True(t, isPermissionDenied(1, "operation not permitted")) +} + +func TestIsPermissionDenied_StderrAccessIsDenied(t *testing.T) { + assert.True(t, isPermissionDenied(1, "Access is denied")) +} + +func TestIsPermissionDenied_StderrRequiresRootPrivileges(t *testing.T) { + assert.True(t, isPermissionDenied(1, "requires root privileges")) +} + +func TestIsPermissionDenied_StderrUnauthorizedAccessException(t *testing.T) { + assert.True(t, isPermissionDenied(1, "UnauthorizedAccessException")) +} + +func TestIsPermissionDenied_StderrCaseInsensitive(t *testing.T) { + assert.True(t, isPermissionDenied(1, "PERMISSION DENIED")) +} + +func TestIsPermissionDenied_ExitCodeNonZero(t *testing.T) { + assert.False(t, isPermissionDenied(1, "Just a normal error")) +} + +func TestIsPermissionDenied_ExitCodeZeroNoText(t *testing.T) { + assert.False(t, isPermissionDenied(0, "")) +} + +// --- extractFailedManager tests --- + +func TestExtractFailedManager_Brew(t *testing.T) { + assert.Equal(t, "brew", extractFailedManager("brew install uv")) +} + +func TestExtractFailedManager_Pyenv(t *testing.T) { + assert.Equal(t, "pyenv", extractFailedManager("pyenv install 3.12")) +} + +func TestExtractFailedManager_Winget(t *testing.T) { + assert.Equal(t, "winget", extractFailedManager("winget install OpenJS.NodeJS")) +} + +func TestExtractFailedManager_NoMatch(t *testing.T) { + assert.Empty(t, extractFailedManager("curl -LsSf https://astral.sh/uv/install.sh | sh")) +} + +func TestExtractFailedManager_EmptyCmd(t *testing.T) { + assert.Empty(t, extractFailedManager("")) +} + +// --- buildInstallTip tests --- + +func TestBuildInstallTip_PermDenied_ContainsSudo(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), true, map[string]bool{}, "linux") + + assert.Contains(t, tip, "sudo") +} + +func TestBuildInstallTip_EmptyToolKey(t *testing.T) { + assert.Empty(t, buildInstallTip(prereq("", "brew install uv", ""), false, map[string]bool{"brew": true}, "linux")) +} + +func TestBuildInstallTip_UnknownTool(t *testing.T) { + p := tools.Prerequisite{Key: "nonexistent-tool", Install: tools.InstallCommands{MacOS: "brew install nonexistent", Linux: "brew install nonexistent", Windows: "brew install nonexistent"}} + + assert.Empty(t, buildInstallTip(p, false, map[string]bool{"brew": true}, "linux")) +} + +func TestBuildInstallTip_ManagerStrategy(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), false, map[string]bool{"pyenv": true}, "linux") + + assert.Contains(t, tip, "You have pyenv") + assert.Contains(t, tip, "pip install uv") +} + +func TestBuildInstallTip_ManagerStrategyMultipleCommands(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), false, map[string]bool{"asdf": true}, "linux") + + assert.Contains(t, tip, "You have asdf") + assert.Contains(t, tip, "asdf install uv latest") + assert.Contains(t, tip, TAB+TAB) +} + +func TestBuildInstallTip_FallbackStrategy(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), false, map[string]bool{}, "linux") + + assert.Contains(t, tip, "curl") + assert.NotContains(t, tip, "You have") +} + +func TestBuildInstallTip_FallbackStrategyWindows(t *testing.T) { + tip := buildInstallTip(prereq("uv", "winget install uv", ""), false, map[string]bool{}, "windows") + + assert.Contains(t, tip, "iex") +} + +func TestBuildInstallTip_SkipsFailedManager(t *testing.T) { + // brew failed, pyenv is available β€” should suggest pyenv, not brew + tip := buildInstallTip(prereq("uv", "brew install uv", ""), false, map[string]bool{"brew": true, "pyenv": true}, "linux") + + assert.Contains(t, tip, "pyenv") + assert.NotContains(t, tip, "brew") +} + +func TestBuildInstallTip_PermDenied_Windows(t *testing.T) { + tip := buildInstallTip(prereq("uv", "winget install uv", ""), true, map[string]bool{}, "windows") + + assert.Contains(t, tip, "Administrator") + assert.NotContains(t, tip, "sudo") +} + +func TestBuildInstallTip_PermDenied_Darwin(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), true, map[string]bool{}, "darwin") + + assert.Contains(t, tip, "sudo") + assert.NotContains(t, tip, "Administrator") +} + +func TestBuildInstallTip_PermDenied_UnknownOS(t *testing.T) { + tip := buildInstallTip(prereq("uv", "brew install uv", ""), true, map[string]bool{}, "plan9") + + assert.NotEmpty(t, tip) + assert.NotContains(t, tip, "sudo") + assert.NotContains(t, tip, "Administrator") +} + +func TestBuildInstallTip_KeyFieldOverridesName(t *testing.T) { + // Key = "uv" with an unrecognizable Name β€” Key must take precedence so the + // correct registry entry is found. + p := tools.Prerequisite{ + Key: "uv", + Name: "SomethingUnrecognized", + Install: tools.InstallCommands{ + MacOS: "brew install uv", + Linux: "brew install uv", + Windows: "brew install uv", + }, + } + + tip := buildInstallTip(p, false, map[string]bool{"pyenv": true}, "linux") + + assert.Contains(t, tip, "pip install uv") +} + +func TestBuildInstallTip_VersionSubstituted_Pyenv(t *testing.T) { + tip := buildInstallTip(prereq("python", "brew install python", "3.9.6"), false, map[string]bool{"pyenv": true}, "linux") + + assert.Contains(t, tip, "pyenv install 3.9.6") + assert.Contains(t, tip, "pyenv global 3.9.6") +} + +func TestBuildInstallTip_VersionSubstituted_Brew(t *testing.T) { + tip := buildInstallTip(prereq("python", "pyenv install 3.9.6", "3.9.6"), false, map[string]bool{"brew": true}, "linux") + + assert.Contains(t, tip, "brew install python@3.9") +} + +func TestBuildInstallTip_EmptyMinimumVersion_DefaultVersionUsed(t *testing.T) { + // node with nvm detected and no MinimumVersion β€” DefaultVersion "24" must be + // substituted so the tip is actionable. + tip := buildInstallTip(prereq("node", "nvm install 20.0.0", ""), false, map[string]bool{"nvm": true}, "linux") + + assert.Contains(t, tip, "nvm install 24") + assert.NotContains(t, tip, "{version}") +} + +// --- buildInstallFailureMsg tests --- + +func TestBuildInstallFailureMsg_AlternativeManagerDetected(t *testing.T) { + env := map[string]bool{"pyenv": true} + + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, env, "linux") + + assert.Contains(t, result, "You have pyenv") + assert.Contains(t, result, "pip install uv") + assert.NotContains(t, result, "permission denied") +} + +func TestBuildInstallFailureMsg_NoManagerDetected(t *testing.T) { + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, map[string]bool{}, "linux") + + assert.Contains(t, result, "βœ— uv install failed") + assert.Contains(t, result, "Raw command if you want to retry") + assert.NotContains(t, result, "You have") +} + +func TestBuildInstallFailureMsg_FallbackShownWhenNoManager(t *testing.T) { + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, map[string]bool{}, "linux") + + assert.Contains(t, result, "curl") +} + +func TestBuildInstallFailureMsg_MultiCommandTip(t *testing.T) { + env := map[string]bool{"asdf": true} + + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, env, "linux") + + assert.Contains(t, result, "You have asdf") + assert.Contains(t, result, TAB+TAB) +} + +func TestBuildInstallFailureMsg_PermissionDenied(t *testing.T) { + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 126, true, map[string]bool{}, "linux") + + assert.Contains(t, result, "permission denied") + assert.Contains(t, result, "sudo") +} + +func TestBuildInstallFailureMsg_AlwaysShowsTriedAndRawCommand(t *testing.T) { + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, map[string]bool{}, "linux") + + assert.Contains(t, result, " Tried: brew install uv") + assert.Contains(t, result, " Raw command if you want to retry: brew install uv") +} + +func TestBuildInstallFailureMsg_AcceptanceCriteria(t *testing.T) { + env := map[string]bool{"pyenv": true} + + result := buildInstallFailureMsg(prereq("uv", "brew install uv", ""), 1, false, env, "linux") + + expected := "βœ— uv install failed (exit code 1)\n" + + " Tried: brew install uv\n" + + " Tip: You have pyenv β€” try: pip install uv\n" + + " Raw command if you want to retry: brew install uv\n" + assert.Equal(t, expected, result) +} + +func TestBuildInstallFailureMsg_URLShown(t *testing.T) { + p := tools.Prerequisite{ + Name: "uv", + URL: "https://docs.astral.sh/uv/", + Install: tools.InstallCommands{ + MacOS: "brew install uv", + Linux: "brew install uv", + Windows: "brew install uv", + }, + } + + result := buildInstallFailureMsg(p, 1, false, map[string]bool{}, "linux") + + assert.Contains(t, result, "Refer to https://docs.astral.sh/uv/") +} + +func TestBuildInstallFailureMsg_VersionSubstituted_EndToEnd(t *testing.T) { + // A prerequisite with MinimumVersion set must produce substituted commands + // in the failure tip all the way through buildInstallFailureMsg. + result := buildInstallFailureMsg(prereq("python", "brew install python@3.9", "3.9.6"), 1, false, map[string]bool{"pyenv": true}, "linux") + + assert.Contains(t, result, "pyenv install 3.9.6") + assert.Contains(t, result, "pyenv global 3.9.6") +} diff --git a/internal/dependencies/registry.go b/internal/dependencies/registry.go new file mode 100644 index 000000000..cb2a479cf --- /dev/null +++ b/internal/dependencies/registry.go @@ -0,0 +1,372 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dependencies + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// TAB is the indent prefix used in user-facing tip and failure messages. +const TAB = " " + +// Strategy is implemented by ManagerStrategy and FallbackStrategy. +// getStrategyTip returns the user-facing tip line for an install failure, or "" +// when no actionable suggestion is available. +// withVersion returns a copy of the strategy with {version} and {version_mm} +// placeholders in commands replaced by the given version string. +type Strategy interface { + getStrategyTip(goos string) string + withVersion(version string) Strategy +} + +// ManagerStrategy provides install commands when a specific package/version manager +// is detected in the environment. +// DefaultVersion is substituted when the Prerequisite carries no MinimumVersion. +type ManagerStrategy struct { + Manager string + Commands []string + DefaultVersion string +} + +// FallbackStrategy is used when no manager-specific strategy matches. +// CommandsWindows overrides Commands on Windows when non-empty. +// DefaultVersion is substituted when the Prerequisite carries no MinimumVersion. +type FallbackStrategy struct { + Commands []string + CommandsWindows []string + DefaultVersion string + Message string + URL string +} + +// majorMinorVersion extracts the major.minor portion from a semver string. +// "3.9.6" β†’ "3.9", "24.0.0" β†’ "24.0", "" β†’ "". +func majorMinorVersion(v string) string { + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return v + } + + return parts[0] + "." + parts[1] +} + +// substituteCmds replaces {version_mm} and {version} placeholders in each command. +// {version_mm} is substituted first to avoid a partial match against {version}. +func substituteCmds(cmds []string, version string) []string { + if len(cmds) == 0 { + return cmds + } + + out := make([]string, len(cmds)) + mm := majorMinorVersion(version) + + for i, c := range cmds { + c = strings.ReplaceAll(c, "{version_mm}", mm) + out[i] = strings.ReplaceAll(c, "{version}", version) + } + + return out +} + +func (ms ManagerStrategy) withVersion(version string) Strategy { + if version == "" { + version = ms.DefaultVersion + } + + ms.Commands = substituteCmds(ms.Commands, version) + + return ms +} + +func (fs FallbackStrategy) withVersion(version string) Strategy { + if version == "" { + version = fs.DefaultVersion + } + + fs.Commands = substituteCmds(fs.Commands, version) + fs.CommandsWindows = substituteCmds(fs.CommandsWindows, version) + + return fs +} + +func (ms ManagerStrategy) getStrategyTip(_ string) string { + tipMsg := ms.Commands[0] + + if len(ms.Commands) > 1 { + tipMsg = "\n" + TAB + TAB + strings.Join(ms.Commands, "\n"+TAB+TAB) + } + + return fmt.Sprintf(TAB+"Tip: You have %s β€” try: %s", ms.Manager, tipMsg) +} + +func (fs FallbackStrategy) getStrategyTip(goos string) string { + cmds := fs.Commands + + if goos == "windows" && len(fs.CommandsWindows) > 0 { + cmds = fs.CommandsWindows + } + + switch len(cmds) { + case 0: + if fs.URL != "" { + return TAB + "See: " + fs.URL + } + + return "" + + case 1: + return TAB + "Try: " + cmds[0] + + default: + return TAB + "Try:\n" + TAB + TAB + strings.Join(cmds, "\n"+TAB+TAB) + } +} + +// ToolInfo holds installation information for a dependency. +type ToolInfo struct { + Name string + Strategies []Strategy +} + +// ToolRegistry maps tool keys (e.g. "python", "uv") to their installation info. +// Strategies are evaluated in order; the first matching one wins. +var ToolRegistry = map[string]ToolInfo{ + "python": { + Name: "Python", + Strategies: []Strategy{ + ManagerStrategy{Manager: "pyenv", DefaultVersion: "3.14", Commands: []string{"pyenv install {version}", "pyenv global {version}"}}, + ManagerStrategy{Manager: "asdf", DefaultVersion: "3.14", Commands: []string{"asdf install python {version}", "asdf global python {version}"}}, + ManagerStrategy{Manager: "brew", DefaultVersion: "3.14", Commands: []string{"brew install python@{version_mm}"}}, + ManagerStrategy{Manager: "winget", DefaultVersion: "3.14", Commands: []string{"winget install Python.Python.{version_mm}"}}, + ManagerStrategy{Manager: "choco", DefaultVersion: "3.14", Commands: []string{"choco install python --version={version}"}}, + FallbackStrategy{ + DefaultVersion: "3.14", + Message: "Install pyenv (recommended for managing Python versions):", + Commands: []string{ + "curl https://pyenv.run | bash", + "# Restart terminal, then:", + "pyenv install {version}", + "pyenv global {version}", + }, + CommandsWindows: []string{ + "# Install pyenv-win via PowerShell:", + `Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/pyenv-win/pyenv-win/master/pyenv-win/install-pyenv-win.ps1" -OutFile "./install-pyenv-win.ps1"; &"./install-pyenv-win.ps1"`, + "# Restart terminal, then:", + "pyenv install {version}", + "pyenv global {version}", + }, + URL: "https://www.python.org/downloads/", + }, + }, + }, + // uv: pyenv strategy first β€” if the user manages Python via pyenv, pip install + // is the most natural path; brew/asdf/curl follow in priority order. + "uv": { + Name: "uv", + Strategies: []Strategy{ + ManagerStrategy{Manager: "pyenv", Commands: []string{"pip install uv"}}, + ManagerStrategy{Manager: "brew", Commands: []string{"brew install uv"}}, + ManagerStrategy{Manager: "asdf", Commands: []string{ + "asdf plugin add uv https://github.com/asdf-community/asdf-uv.git", + "asdf install uv latest", + "asdf global uv latest", + }}, + ManagerStrategy{Manager: "winget", Commands: []string{"winget install astral-sh.uv"}}, + ManagerStrategy{Manager: "choco", Commands: []string{"choco install uv"}}, + FallbackStrategy{ + Commands: []string{"curl -LsSf https://astral.sh/uv/install.sh | sh"}, + CommandsWindows: []string{`powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`}, + URL: "https://docs.astral.sh/uv/getting-started/installation/", + }, + }, + }, + "node": { + Name: "Node.js", + Strategies: []Strategy{ + ManagerStrategy{Manager: "nvm", DefaultVersion: "24", Commands: []string{"nvm install {version}", "nvm use {version}"}}, + ManagerStrategy{Manager: "fnm", DefaultVersion: "24", Commands: []string{"fnm install {version}", "fnm use {version}"}}, + ManagerStrategy{Manager: "asdf", DefaultVersion: "24", Commands: []string{"asdf install nodejs {version}", "asdf global nodejs {version}"}}, + ManagerStrategy{Manager: "brew", Commands: []string{"brew install node"}}, + ManagerStrategy{Manager: "winget", Commands: []string{"winget install OpenJS.NodeJS"}}, + ManagerStrategy{Manager: "choco", Commands: []string{"choco install nodejs"}}, + FallbackStrategy{ + DefaultVersion: "24", + Message: "Install a version manager (recommended):", + Commands: []string{ + "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash", + "# Restart terminal, then:", + "nvm install {version}", + "nvm use {version}", + }, + URL: "https://nodejs.org/", + }, + }, + }, + "pulumi": { + Name: "Pulumi", + Strategies: []Strategy{ + ManagerStrategy{Manager: "brew", Commands: []string{"brew install pulumi"}}, + ManagerStrategy{Manager: "winget", Commands: []string{"winget install Pulumi.Pulumi"}}, + ManagerStrategy{Manager: "choco", Commands: []string{"choco install pulumi"}}, + FallbackStrategy{ + Commands: []string{"curl -fsSL https://get.pulumi.com | sh"}, + CommandsWindows: []string{"iwr -useb https://get.pulumi.com/install.ps1 | iex"}, + URL: "https://www.pulumi.com/docs/install/", + }, + }, + }, + "task": { + Name: "Task", + Strategies: []Strategy{ + ManagerStrategy{Manager: "brew", Commands: []string{"brew install go-task"}}, + ManagerStrategy{Manager: "winget", Commands: []string{"winget install Task.Task"}}, + ManagerStrategy{Manager: "choco", Commands: []string{"choco install go-task"}}, + ManagerStrategy{Manager: "scoop", Commands: []string{"scoop install task"}}, + FallbackStrategy{ + Commands: []string{`sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d`}, + CommandsWindows: []string{"# Download the executable from the releases page:"}, + URL: "https://taskfile.dev/installation/", + }, + }, + }, + "git": { + Name: "Git", + Strategies: []Strategy{ + ManagerStrategy{Manager: "brew", Commands: []string{"brew install git"}}, + ManagerStrategy{Manager: "winget", Commands: []string{"winget install Git.Git"}}, + ManagerStrategy{Manager: "choco", Commands: []string{"choco install git"}}, + FallbackStrategy{ + URL: "https://git-scm.com/downloads", + }, + }, + }, +} + +// knownManagers lists manager names checked by extractFailedManager. +var knownManagers = []string{"brew", "pyenv", "asdf", "nvm", "fnm", "winget", "choco", "scoop"} + +// toolNameMap maps lowercase dr CLI display names to ToolRegistry keys. +var toolNameMap = map[string]string{ + // Canonical keys + "python": "python", + "uv": "uv", + "node": "node", + "node.js": "node", + "nodejs": "node", + "pulumi": "pulumi", + "pulumi infrastructure as code tool": "pulumi", + "task": "task", + "taskfile task runner": "task", + "git": "git", + "git source control management tool": "git", + // Python aliases + "py": "python", + "py3": "python", + "python3": "python", + "python@3": "python", +} + +// NormalizeToolName maps a dr CLI display name (e.g. "Taskfile task runner") to +// the corresponding ToolRegistry key (e.g. "task"). +// Returns an empty string if the name is not recognized. +func NormalizeToolName(displayName string) string { + return toolNameMap[strings.ToLower(strings.TrimSpace(displayName))] +} + +// DetectEnvironment checks for available package/version managers and platform flags. +// The returned map uses manager names as keys (e.g. "brew", "pyenv") plus "is_windows". +func DetectEnvironment() map[string]bool { + return detectEnvironment( + exec.LookPath, + os.Getenv, + func(p string) bool { + info, err := os.Stat(p) + + return err == nil && info.IsDir() + }, + runtime.GOOS, + ) +} + +func detectEnvironment( + lookPath func(string) (string, error), + getenv func(string) string, + dirExists func(string) bool, + goos string, +) map[string]bool { + isWindows := goos == "windows" + + present := func(name string) bool { + _, err := lookPath(name) + + return err == nil + } + + nvmDir := getenv("NVM_DIR") + + if nvmDir == "" { + if home, err := os.UserHomeDir(); err == nil { + nvmDir = filepath.Join(home, ".nvm") + } + } + + nvmPresent := !isWindows && dirExists(nvmDir) + + return map[string]bool{ + "pyenv": present("pyenv"), + "nvm": nvmPresent, + "fnm": present("fnm"), + "asdf": present("asdf"), + "brew": present("brew") && !isWindows, + "winget": present("winget") && isWindows, + "choco": present("choco") && isWindows, + "scoop": present("scoop") && isWindows, + "is_windows": isWindows, + } +} + +// selectInstallStrategy returns the first matching Strategy for toolKey. +// ManagerStrategy entries whose Manager equals failedMgr are skipped. +// Returns ManagerStrategy when a detected manager matches, FallbackStrategy +// as last resort, or nil when toolKey is unknown. +func selectInstallStrategy(toolKey, failedMgr string, env map[string]bool) Strategy { + toolKey = NormalizeToolName(toolKey) + + tool, ok := ToolRegistry[toolKey] + if !ok { + return nil + } + + for _, s := range tool.Strategies { + switch strategy := s.(type) { + case ManagerStrategy: + // Do not provide a tip for the detected manager if it was involved in the failed install attempt, since that may be why the strategy failed; + // Instead, continue checking other strategies. + if strategy.Manager != failedMgr && env[strategy.Manager] { + return strategy + } + + case FallbackStrategy: + return strategy + } + } + + return nil +} diff --git a/internal/dependencies/registry_test.go b/internal/dependencies/registry_test.go new file mode 100644 index 000000000..462099e8c --- /dev/null +++ b/internal/dependencies/registry_test.go @@ -0,0 +1,583 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dependencies + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeLookPath returns a lookPath function that reports the given set of +// executables as present and everything else as absent. +func fakeLookPath(present ...string) func(string) (string, error) { + set := make(map[string]bool, len(present)) + + for _, name := range present { + set[name] = true + } + + return func(name string) (string, error) { + if set[name] { + return "/usr/bin/" + name, nil + } + + return "", fmt.Errorf("%s: not found", name) + } +} + +// fakeGetenv returns a getenv function that answers from the supplied keyβ†’value map. +func fakeGetenv(env map[string]string) func(string) string { + return func(key string) string { + return env[key] + } +} + +// noDirExists is a dirExists stub that always returns false. +func noDirExists(_ string) bool { return false } + +// ────────────────────────────────────────────────────────────── +// NormalizeToolName +// ────────────────────────────────────────────────────────────── + +func TestNormalizeToolName(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"python", "python"}, + {"Python", "python"}, + {"python3", "python"}, + {"py3", "python"}, + {"py", "python"}, + {"python@3", "python"}, + {"uv", "uv"}, + {"UV", "uv"}, + {"node", "node"}, + {"node.js", "node"}, + {"Node.js", "node"}, + {"nodejs", "node"}, + {"task", "task"}, + {"Taskfile task runner", "task"}, + {"TASKFILE TASK RUNNER", "task"}, + {"pulumi", "pulumi"}, + {"Pulumi infrastructure as code tool", "pulumi"}, + {"git", "git"}, + {"Git source control management tool", "git"}, + {" python ", "python"}, + {"unknown", ""}, + {"", ""}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, NormalizeToolName(tc.input)) + }) + } +} + +// ────────────────────────────────────────────────────────────── +// DetectEnvironment +// ────────────────────────────────────────────────────────────── + +func TestDetectEnvironment_BrewPresentOnDarwin(t *testing.T) { + env := detectEnvironment(fakeLookPath("brew"), fakeGetenv(nil), noDirExists, "darwin") + + assert.True(t, env["brew"]) + assert.False(t, env["winget"]) + assert.False(t, env["is_windows"]) +} + +func TestDetectEnvironment_BrewSuppressedOnWindows(t *testing.T) { + // Even if brew is on PATH, it must not be treated as available on Windows. + env := detectEnvironment(fakeLookPath("brew", "winget", "choco"), fakeGetenv(nil), noDirExists, "windows") + + assert.False(t, env["brew"]) + assert.True(t, env["winget"]) + assert.True(t, env["choco"]) + assert.True(t, env["is_windows"]) +} + +func TestDetectEnvironment_PyenvDetected(t *testing.T) { + env := detectEnvironment(fakeLookPath("pyenv"), fakeGetenv(nil), noDirExists, "linux") + + assert.True(t, env["pyenv"]) + assert.False(t, env["brew"]) +} + +func TestDetectEnvironment_NVM_ViaEnvVar(t *testing.T) { + getenv := fakeGetenv(map[string]string{"NVM_DIR": "/home/user/.nvm"}) + dirExists := func(p string) bool { return p == "/home/user/.nvm" } + + env := detectEnvironment(fakeLookPath(), getenv, dirExists, "linux") + + assert.True(t, env["nvm"]) +} + +func TestDetectEnvironment_NVM_ViaHomeFallback(t *testing.T) { + t.Setenv("HOME", "/home/user") + + dirExists := func(p string) bool { return p == "/home/user/.nvm" } + + env := detectEnvironment(fakeLookPath(), fakeGetenv(nil), dirExists, "linux") + + assert.True(t, env["nvm"]) +} + +func TestDetectEnvironment_NVM_HomeDirError_NVMAbsent(t *testing.T) { + // Unset HOME so os.UserHomeDir() cannot resolve a home directory; + // nvm must be reported absent rather than panicking. + t.Setenv("HOME", "") + + env := detectEnvironment(fakeLookPath(), fakeGetenv(nil), noDirExists, "linux") + + assert.False(t, env["nvm"]) +} + +func TestDetectEnvironment_NVM_AbsentOnWindows(t *testing.T) { + getenv := fakeGetenv(map[string]string{"NVM_DIR": "/home/user/.nvm"}) + dirExists := func(p string) bool { return true } + + // nvm must never be reported on Windows even if the directory exists. + env := detectEnvironment(fakeLookPath(), getenv, dirExists, "windows") + + assert.False(t, env["nvm"]) +} + +func TestDetectEnvironment_AllAbsent(t *testing.T) { + env := detectEnvironment(fakeLookPath(), fakeGetenv(nil), noDirExists, "linux") + + for key, val := range env { + if key == "is_windows" { + continue + } + + assert.False(t, val, "expected %q to be false when nothing is installed", key) + } +} + +// ────────────────────────────────────────────────────────────── +// majorMinorVersion +// ────────────────────────────────────────────────────────────── + +func TestMajorMinorVersion(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"3.9.6", "3.9"}, + {"24.0.0", "24.0"}, + {"1.2.3", "1.2"}, + {"3", "3"}, + {"", ""}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, majorMinorVersion(tc.input)) + }) + } +} + +// ────────────────────────────────────────────────────────────── +// substituteCmds +// ────────────────────────────────────────────────────────────── + +func TestSubstituteCmds_EmptySlice(t *testing.T) { + assert.Empty(t, substituteCmds(nil, "3.9.6")) +} + +func TestSubstituteCmds_VersionPlaceholder(t *testing.T) { + result := substituteCmds([]string{"pyenv install {version}", "pyenv global {version}"}, "3.9.6") + + assert.Equal(t, []string{"pyenv install 3.9.6", "pyenv global 3.9.6"}, result) +} + +func TestSubstituteCmds_VersionMmPlaceholder(t *testing.T) { + result := substituteCmds([]string{"brew install python@{version_mm}"}, "3.9.6") + + assert.Equal(t, []string{"brew install python@3.9"}, result) +} + +func TestSubstituteCmds_BothPlaceholders_OrderSafe(t *testing.T) { + // {version_mm} must be replaced before {version} to avoid partial match. + // If {version} were replaced first, "python@{version_mm}" would become + // "python@{3.9.6_mm}" (corrupted) rather than "python@3.9". + result := substituteCmds([]string{"pyenv install {version}", "brew install python@{version_mm}"}, "3.9.6") + + assert.Equal(t, []string{"pyenv install 3.9.6", "brew install python@3.9"}, result) +} + +func TestSubstituteCmds_NoPlaceholders(t *testing.T) { + cmds := []string{"brew install uv", "pip install uv"} + result := substituteCmds(cmds, "3.9.6") + + assert.Equal(t, cmds, result) +} + +// ────────────────────────────────────────────────────────────── +// getStrategyTip β€” ManagerStrategy +// ────────────────────────────────────────────────────────────── + +func TestManagerStrategy_GetStrategyTip_SingleCommand(t *testing.T) { + ms := ManagerStrategy{Manager: "pyenv", Commands: []string{"pip install uv"}} + + assert.Equal(t, " Tip: You have pyenv β€” try: pip install uv", ms.getStrategyTip("linux")) +} + +func TestManagerStrategy_GetStrategyTip_MultipleCommands(t *testing.T) { + ms := ManagerStrategy{ + Manager: "asdf", + Commands: []string{"asdf install uv latest", "asdf global uv latest"}, + } + + assert.Equal(t, TAB+"Tip: You have asdf β€” try: \n"+TAB+TAB+"asdf install uv latest\n"+TAB+TAB+"asdf global uv latest", ms.getStrategyTip("linux")) +} + +func TestManagerStrategy_GetStrategyTip_GoosIgnored(t *testing.T) { + ms := ManagerStrategy{Manager: "brew", Commands: []string{"brew install uv"}} + + assert.Equal(t, ms.getStrategyTip("linux"), ms.getStrategyTip("windows"), "goos must not affect ManagerStrategy tip") +} + +// ────────────────────────────────────────────────────────────── +// getStrategyTip β€” FallbackStrategy +// ────────────────────────────────────────────────────────────── + +func TestFallbackStrategy_GetStrategyTip_SingleCommand(t *testing.T) { + fs := FallbackStrategy{Commands: []string{"curl -LsSf https://astral.sh/uv/install.sh | sh"}} + + assert.Equal(t, " Try: curl -LsSf https://astral.sh/uv/install.sh | sh", fs.getStrategyTip("linux")) +} + +func TestFallbackStrategy_GetStrategyTip_MultipleCommands(t *testing.T) { + fs := FallbackStrategy{Commands: []string{"curl https://pyenv.run | bash", "pyenv install 3.12", "pyenv global 3.12"}} + + assert.Equal(t, TAB+"Try:\n"+TAB+TAB+"curl https://pyenv.run | bash\n"+TAB+TAB+"pyenv install 3.12\n"+TAB+TAB+"pyenv global 3.12", fs.getStrategyTip("linux")) +} + +func TestFallbackStrategy_GetStrategyTip_URLOnly(t *testing.T) { + fs := FallbackStrategy{URL: "https://git-scm.com/downloads"} + + assert.Equal(t, " See: https://git-scm.com/downloads", fs.getStrategyTip("linux")) +} + +func TestFallbackStrategy_GetStrategyTip_Empty(t *testing.T) { + assert.Empty(t, FallbackStrategy{}.getStrategyTip("linux")) +} + +func TestFallbackStrategy_GetStrategyTip_WindowsOverride(t *testing.T) { + fs := FallbackStrategy{ + Commands: []string{"curl -LsSf https://astral.sh/uv/install.sh | sh"}, + CommandsWindows: []string{`powershell -c "irm https://astral.sh/uv/install.ps1 | iex"`}, + } + + assert.Contains(t, fs.getStrategyTip("windows"), "iex") + assert.Contains(t, fs.getStrategyTip("linux"), "curl") +} + +// ────────────────────────────────────────────────────────────── +// selectInstallStrategy +// ────────────────────────────────────────────────────────────── + +// TestSelectInstallStrategy_PyenvPresentBrewAbsent_UV is the acceptance-criteria test: +// with pyenv on PATH and brew absent, selectInstallStrategy("uv") must return the +// pyenv ManagerStrategy (pip install uv). +func TestSelectInstallStrategy_PyenvPresentBrewAbsent_UV(t *testing.T) { + env := map[string]bool{ + "pyenv": true, + "brew": false, + } + + ms, ok := selectInstallStrategy("uv", "", env).(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, "pyenv", ms.Manager) + assert.Equal(t, []string{"pip install uv"}, ms.Commands) +} + +func TestSelectInstallStrategy_BrewPresent_UV(t *testing.T) { + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("uv", "", env).(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"brew install uv"}, ms.Commands) +} + +func TestSelectInstallStrategy_BrewPresent_Python(t *testing.T) { + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("python", "", env).(ManagerStrategy) + + require.True(t, ok) + // Without version the placeholder is returned as-is; callers call .withVersion(). + assert.Equal(t, []string{"brew install python@{version_mm}"}, ms.Commands) +} + +func TestSelectInstallStrategy_FallbackUnix_WhenNoManagerDetected(t *testing.T) { + env := map[string]bool{} + + fs, ok := selectInstallStrategy("uv", "", env).(FallbackStrategy) + + require.True(t, ok) + require.NotEmpty(t, fs.Commands) + assert.Contains(t, fs.Commands[0], "curl") +} + +func TestSelectInstallStrategy_FallbackWindows(t *testing.T) { + env := map[string]bool{"is_windows": true} + + fs, ok := selectInstallStrategy("uv", "", env).(FallbackStrategy) + + require.True(t, ok) + require.NotEmpty(t, fs.CommandsWindows) + assert.Contains(t, fs.CommandsWindows[0], "iex") +} + +func TestSelectInstallStrategy_UnknownTool(t *testing.T) { + env := map[string]bool{"brew": true} + + result := selectInstallStrategy("nonexistent-tool", "", env) + + assert.Nil(t, result) +} + +func TestSelectInstallStrategy_WingetPresent_Task(t *testing.T) { + env := map[string]bool{"winget": true, "is_windows": true} + + ms, ok := selectInstallStrategy("task", "", env).(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"winget install Task.Task"}, ms.Commands) +} + +func TestSelectInstallStrategy_NVMPresent_Node(t *testing.T) { + env := map[string]bool{"nvm": true} + + ms, ok := selectInstallStrategy("node", "", env).(ManagerStrategy) + + require.True(t, ok) + // Without version the placeholder is returned as-is; callers call .withVersion(). + assert.Equal(t, []string{"nvm install {version}", "nvm use {version}"}, ms.Commands) +} + +func TestSelectInstallStrategy_AllToolsHaveAtLeastOneFallback(t *testing.T) { + emptyEnv := map[string]bool{} + + for key := range ToolRegistry { + t.Run(key, func(t *testing.T) { + result := selectInstallStrategy(key, "", emptyEnv) + assert.NotNil(t, result, "tool %q must have a fallback strategy", key) + }) + } +} + +func TestSelectInstallStrategy_SkipsFailedMgr(t *testing.T) { + env := map[string]bool{"pyenv": true, "brew": true} + + ms, ok := selectInstallStrategy("uv", "pyenv", env).(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, "brew", ms.Manager, "should skip pyenv and return brew") +} + +// ────────────────────────────────────────────────────────────── +// withVersion +// ────────────────────────────────────────────────────────────── + +func TestWithVersion_ManagerStrategy_Pyenv_Python(t *testing.T) { + env := map[string]bool{"pyenv": true} + + ms, ok := selectInstallStrategy("python", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("3.9.6").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"pyenv install 3.9.6", "pyenv global 3.9.6"}, result.Commands) +} + +func TestWithVersion_ManagerStrategy_Asdf_Python(t *testing.T) { + env := map[string]bool{"asdf": true} + + ms, ok := selectInstallStrategy("python", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("3.9.6").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"asdf install python 3.9.6", "asdf global python 3.9.6"}, result.Commands) +} + +func TestWithVersion_ManagerStrategy_Brew_Python(t *testing.T) { + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("python", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("3.9.6").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"brew install python@3.9"}, result.Commands) +} + +func TestWithVersion_ManagerStrategy_EmptyVersion_UsesDefault(t *testing.T) { + // python/pyenv has DefaultVersion "3.14" β€” used when MinimumVersion is empty. + env := map[string]bool{"pyenv": true} + + ms, ok := selectInstallStrategy("python", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"pyenv install 3.14", "pyenv global 3.14"}, result.Commands) +} + +func TestWithVersion_FallbackStrategy_Python(t *testing.T) { + fs, ok := selectInstallStrategy("python", "", map[string]bool{}).(FallbackStrategy) + require.True(t, ok) + + result, ok := fs.withVersion("3.9.6").(FallbackStrategy) + + require.True(t, ok) + assert.Contains(t, result.Commands, "pyenv install 3.9.6") + assert.Contains(t, result.Commands, "pyenv global 3.9.6") +} + +func TestWithVersion_FallbackStrategy_WindowsCommandsAlsoSubstituted(t *testing.T) { + fs, ok := selectInstallStrategy("python", "", map[string]bool{}).(FallbackStrategy) + require.True(t, ok) + + result, ok := fs.withVersion("3.9.6").(FallbackStrategy) + + require.True(t, ok) + assert.Contains(t, result.CommandsWindows, "pyenv install 3.9.6") + assert.Contains(t, result.CommandsWindows, "pyenv global 3.9.6") +} + +func TestWithVersion_FallbackStrategy_URLOnlyUnchanged(t *testing.T) { + // git has a URL-only FallbackStrategy β€” withVersion must not panic and must + // leave the URL intact. + fs, ok := selectInstallStrategy("git", "", map[string]bool{}).(FallbackStrategy) + require.True(t, ok) + + result, ok := fs.withVersion("2.40.0").(FallbackStrategy) + + require.True(t, ok) + assert.Equal(t, "https://git-scm.com/downloads", result.URL) + assert.Empty(t, result.Commands) +} + +func TestWithVersion_ManagerStrategy_NoPlaceholders(t *testing.T) { + // uv's brew strategy has no version placeholders β€” commands must be returned as-is. + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("uv", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("0.11.20").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"brew install uv"}, result.Commands) +} + +func TestWithVersion_ManagerStrategy_EmptyVersion_NoDefaultVersion_NoPlaceholders(t *testing.T) { + // brew/node has no DefaultVersion and no {version} placeholder β€” commands pass + // through substituteCmds unchanged when both version and DefaultVersion are empty. + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("node", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"brew install node"}, result.Commands) +} + +func TestSelectInstallStrategy_DisplayNameNormalized(t *testing.T) { + // selectInstallStrategy calls NormalizeToolName internally, so display names + // like "Python" (capital P) must resolve to the "python" registry entry. + env := map[string]bool{"brew": true} + + ms, ok := selectInstallStrategy("Python", "", env).(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, "brew", ms.Manager) +} + +func TestSelectInstallStrategy_FailedMgrIsOnlyDetectedMgr_FallsToFallback(t *testing.T) { + // brew is the only detected manager but it already failed β€” must skip it and + // return the FallbackStrategy rather than nil. + env := map[string]bool{"brew": true} + + _, ok := selectInstallStrategy("uv", "brew", env).(FallbackStrategy) + + require.True(t, ok) +} + +func TestWithVersion_ManagerStrategy_DefaultVersion_UsedWhenVersionEmpty(t *testing.T) { + // nvm strategy for node has DefaultVersion "24" β€” used when MinimumVersion is empty. + env := map[string]bool{"nvm": true} + + ms, ok := selectInstallStrategy("node", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"nvm install 24", "nvm use 24"}, result.Commands) +} + +func TestWithVersion_ManagerStrategy_MinimumVersionOverridesDefault(t *testing.T) { + // When MinimumVersion is set, it takes precedence over DefaultVersion. + env := map[string]bool{"nvm": true} + + ms, ok := selectInstallStrategy("node", "", env).(ManagerStrategy) + require.True(t, ok) + + result, ok := ms.withVersion("20.0.0").(ManagerStrategy) + + require.True(t, ok) + assert.Equal(t, []string{"nvm install 20.0.0", "nvm use 20.0.0"}, result.Commands) +} + +func TestWithVersion_FallbackStrategy_DefaultVersionUsed(t *testing.T) { + // Node fallback has DefaultVersion "24" β€” substituted when MinimumVersion is empty. + fs, ok := selectInstallStrategy("node", "", map[string]bool{}).(FallbackStrategy) + require.True(t, ok) + + result, ok := fs.withVersion("").(FallbackStrategy) + + require.True(t, ok) + assert.Contains(t, result.Commands, "nvm install 24") + assert.Contains(t, result.Commands, "nvm use 24") +} + +func TestWithVersion_FallbackStrategy_MinimumVersionOverridesDefault(t *testing.T) { + // When MinimumVersion is set, it takes precedence over DefaultVersion. + fs, ok := selectInstallStrategy("node", "", map[string]bool{}).(FallbackStrategy) + require.True(t, ok) + + result, ok := fs.withVersion("20.0.0").(FallbackStrategy) + + require.True(t, ok) + assert.Contains(t, result.Commands, "nvm install 20.0.0") + assert.Contains(t, result.Commands, "nvm use 20.0.0") +}