diff --git a/internal/config/config.go b/internal/config/config.go index 2395211e2..17f382813 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,7 +108,7 @@ func LoadConfig() (Config, error) { return config, err } - homeDir, err := homedir.Dir() + homeDir, err := os.UserHomeDir() if err != nil { return config, err } diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 7f2a2256a..0e8a1ef20 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -5,6 +5,7 @@ package resolve import ( "fmt" + "iter" "os" "path" "strings" @@ -16,69 +17,132 @@ import ( // ToolVersions represents a tool along with versions specified for it type ToolVersions struct { + Name string Versions []string Directory string Source string } +// AllVersions takes a set of plugins and a directory and resolves all tools to one or more +// versions. This includes tools without a corresponding plugin. +func AllVersions(conf config.Config, plugins []plugins.Plugin, directory string) (versions []ToolVersions, err error) { + resolvedToolVersions := map[string]bool{} + var finalVersions []ToolVersions + + // First: Resolve using environment values + for _, plugin := range plugins { + version, envVariableName, found := findVersionsInEnv(plugin.Name) + if found { + resolvedToolVersions[plugin.Name] = true + finalVersions = append(finalVersions, ToolVersions{Name: plugin.Name, Versions: version, Source: envVariableName}) + } + } + + // Iterate from the current towards the root directory, ending with the user's home. + for iterDir := range iterDirectories(conf, directory) { + // Second: Resolve using the tool versions file + filepath := path.Join(iterDir, conf.DefaultToolVersionsFilename) + if _, err = os.Stat(filepath); err == nil { + allVersions, err := toolversions.GetAllToolsAndVersions(filepath) + if err != nil { + return versions, err + } + for _, version := range allVersions { + if _, isPluginResolved := resolvedToolVersions[version.Name]; !isPluginResolved { + resolvedToolVersions[version.Name] = true + finalVersions = append(finalVersions, ToolVersions{Name: version.Name, Versions: version.Versions, Source: conf.DefaultToolVersionsFilename, Directory: iterDir}) + } + } + } + + // Third: Resolve using legacy settings + for _, plugin := range plugins { + if _, isPluginResolved := resolvedToolVersions[plugin.Name]; !isPluginResolved { + version, found, err := findLegacyVersionsInDir(conf, plugin, iterDir) + if err != nil { + return versions, err + } + if found { + resolvedToolVersions[plugin.Name] = true + finalVersions = append(finalVersions, version) + } + } + } + } + return finalVersions, nil +} + // Version takes a plugin and a directory and resolves the tool to one or more -// versions. +// versions. Only returns results for the provided plugin. func Version(conf config.Config, plugin plugins.Plugin, directory string) (versions ToolVersions, found bool, err error) { version, envVariableName, found := findVersionsInEnv(plugin.Name) if found { - return ToolVersions{Versions: version, Source: envVariableName}, true, nil + return ToolVersions{Name: plugin.Name, Versions: version, Source: envVariableName}, true, nil } - for !found { - versions, found, err = findVersionsInDir(conf, plugin, directory) - if err != nil { - return versions, false, err + for iterDir := range iterDirectories(conf, directory) { + versions, found, err = findVersionsInDir(conf, plugin, iterDir) + if found || err != nil { + return versions, found, err } + } + return versions, found, err +} - nextDir := path.Dir(directory) - // If current dir and next dir are the same it means we've reached `/` and - // have no more parent directories to search. - if nextDir == directory { - // If no version found, try current users home directory. I'd like to - // eventually remove this feature. - homeDir, osErr := os.UserHomeDir() - if osErr != nil { +func iterDirectories(conf config.Config, directory string) iter.Seq[string] { + return func(yield func(string) bool) { + if !yield(directory) { + return + } + iterDir := directory + for { + nextDir := path.Dir(iterDir) + // If current dir and next dir are the same it means we've reached `/` and + // have no more parent directories to search. + if nextDir == iterDir { break } - - versions, found, err = findVersionsInDir(conf, plugin, homeDir) - break + if !yield(iterDir) { + return + } + iterDir = nextDir + } + // If no version found, try current users home directory. I'd like to + // eventually remove this feature. + homeDir := conf.Home + if homeDir != "" { + if !yield(homeDir) { + return + } } - directory = nextDir } - - return versions, found, err } func findVersionsInDir(conf config.Config, plugin plugins.Plugin, directory string) (versions ToolVersions, found bool, err error) { filepath := path.Join(directory, conf.DefaultToolVersionsFilename) - if _, err = os.Stat(filepath); err == nil { - versions, found, err := toolversions.FindToolVersions(filepath, plugin.Name) - if found || err != nil { - return ToolVersions{Versions: versions, Source: conf.DefaultToolVersionsFilename, Directory: directory}, found, err + foundVersions, found, err := toolversions.FindToolVersions(filepath, plugin.Name) + if err != nil { + return versions, found, err + } + if found { + return ToolVersions{Name: plugin.Name, Versions: foundVersions, Source: conf.DefaultToolVersionsFilename, Directory: directory}, found, err } } + return findLegacyVersionsInDir(conf, plugin, directory) +} + +func findLegacyVersionsInDir(conf config.Config, plugin plugins.Plugin, directory string) (versions ToolVersions, found bool, err error) { legacyFiles, err := conf.LegacyVersionFile() if err != nil { return versions, found, err } if legacyFiles { - versions, found, err := findVersionsInLegacyFile(plugin, directory) - - if found || err != nil { - return versions, found, err - } + return findVersionsInLegacyFile(plugin, directory) } - - return versions, found, nil + return versions, false, nil } // findVersionsInEnv returns the version from the environment if present @@ -111,7 +175,7 @@ func findVersionsInLegacyFile(plugin plugins.Plugin, directory string) (versions if len(versionsSlice) == 0 || (len(versionsSlice) == 1 && versionsSlice[0] == "") { return versions, false, nil } - return ToolVersions{Versions: versionsSlice, Source: filename, Directory: directory}, err == nil, err + return ToolVersions{Name: plugin.Name, Versions: versionsSlice, Source: filename, Directory: directory}, err == nil, err } } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index d4a51f897..03b572715 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -18,7 +18,8 @@ const testPluginName = "test-plugin" func TestVersion(t *testing.T) { testDataDir := t.TempDir() currentDir := t.TempDir() - conf := config.Config{DataDir: testDataDir, DefaultToolVersionsFilename: ".tool-versions", ConfigFile: "testdata/asdfrc"} + homeDir := t.TempDir() + conf := config.Config{Home: homeDir, DataDir: testDataDir, DefaultToolVersionsFilename: ".tool-versions", ConfigFile: "testdata/asdfrc"} _, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, testPluginName) assert.Nil(t, err) plugin := plugins.New(conf, testPluginName) @@ -72,6 +73,105 @@ func TestVersion(t *testing.T) { }) } +func TestAllVersions(t *testing.T) { + testDataDir := t.TempDir() + currentDir := t.TempDir() + homeDir := t.TempDir() + conf := config.Config{Home: homeDir, DataDir: testDataDir, DefaultToolVersionsFilename: ".tool-versions", ConfigFile: "testdata/asdfrc"} + _, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, testPluginName) + assert.Nil(t, err) + allPlugins := []plugins.Plugin{plugins.New(conf, testPluginName)} + + t.Run("returns empty slice when non-existent version passed", func(t *testing.T) { + toolVersions, err := AllVersions(conf, allPlugins, t.TempDir()) + assert.Nil(t, err) + assert.Empty(t, toolVersions) + }) + + t.Run("returns single version from .tool-versions file", func(t *testing.T) { + // write a version file + data := []byte(fmt.Sprintf("%s 1.2.3", testPluginName)) + err = os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666) + + toolVersions, err := AllVersions(conf, allPlugins, currentDir) + assert.Nil(t, err) + assert.Equal(t, toolVersions[0].Versions, []string{"1.2.3"}) + }) + + t.Run("returns version from env when env variable set", func(t *testing.T) { + // Set env + t.Setenv(fmt.Sprintf("ASDF_%s_VERSION", strings.ToUpper(testPluginName)), "2.3.4") + + // write a version file + data := []byte(fmt.Sprintf("%s 1.2.3", testPluginName)) + err = os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666) + + // assert env variable takes precedence + toolVersions, err := AllVersions(conf, allPlugins, currentDir) + assert.Nil(t, err) + assert.Equal(t, toolVersions[0].Versions, []string{"2.3.4"}) + }) + + t.Run("returns single version from .tool-versions file in parent directory", func(t *testing.T) { + // write a version file + data := []byte(fmt.Sprintf("%s 1.2.3", testPluginName)) + err = os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666) + + subDir := filepath.Join(currentDir, "subdir") + err = os.MkdirAll(subDir, 0o777) + assert.Nil(t, err) + + toolVersion, err := AllVersions(conf, allPlugins, subDir) + assert.Nil(t, err) + assert.Equal(t, toolVersion[0].Versions, []string{"1.2.3"}) + }) + + t.Run("returns single version from .tool-versions file in home directory", func(t *testing.T) { + // write a version file + data := []byte(fmt.Sprintf("%s 1.2.3", testPluginName)) + err = os.WriteFile(filepath.Join(homeDir, ".tool-versions"), data, 0o666) + + toolVersion, err := AllVersions(conf, allPlugins, currentDir) + assert.Nil(t, err) + assert.Equal(t, toolVersion[0].Versions, []string{"1.2.3"}) + }) + + t.Run("returns unknown plugin version from .tool-versions file in parent directory", func(t *testing.T) { + // write a version file + unknownPluginName := "dummy_unknown_plugin" + data := []byte(fmt.Sprintf("%s 1.2.3", unknownPluginName)) + err = os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666) + + subDir := filepath.Join(currentDir, "subdir") + err = os.MkdirAll(subDir, 0o777) + assert.Nil(t, err) + + toolVersion, err := AllVersions(conf, allPlugins, subDir) + assert.Nil(t, err) + assert.Equal(t, toolVersion[0].Name, unknownPluginName) + assert.Equal(t, toolVersion[0].Versions, []string{"1.2.3"}) + }) + + t.Run("returns results in order from .tool-versions file", func(t *testing.T) { + testPluginTwoName := "dummy_plugin_two" + _, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, testPluginTwoName) + assert.Nil(t, err) + allPlugins := []plugins.Plugin{plugins.New(conf, testPluginName), plugins.New(conf, testPluginTwoName)} + + // write a version file + unknownPluginName := "dummy_unknown_plugin" + data := []byte(fmt.Sprintf("%s 1.2.3\n%s 1.2.3\n%s 1.2.3", testPluginTwoName, testPluginName, unknownPluginName)) + err = os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666) + assert.Nil(t, err) + + toolVersion, err := AllVersions(conf, allPlugins, currentDir) + assert.Nil(t, err) + assert.Equal(t, toolVersion[0].Name, testPluginTwoName) + assert.Equal(t, toolVersion[1].Name, testPluginName) + assert.Equal(t, toolVersion[2].Name, unknownPluginName) + }) +} + func TestFindVersionsInDir(t *testing.T) { testDataDir := t.TempDir() conf := config.Config{DataDir: testDataDir, DefaultToolVersionsFilename: ".tool-versions", ConfigFile: "testdata/asdfrc"} diff --git a/internal/shims/shims_test.go b/internal/shims/shims_test.go index 598b1bed3..c8c98ee6c 100644 --- a/internal/shims/shims_test.go +++ b/internal/shims/shims_test.go @@ -442,6 +442,7 @@ func generateConfig(t *testing.T) (config.Config, plugins.Plugin) { conf, err := config.LoadConfig() assert.Nil(t, err) conf.DataDir = testDataDir + conf.Home = t.TempDir() return conf, installPlugin(t, conf, "dummy_plugin", testPluginName) } diff --git a/internal/versions/versions.go b/internal/versions/versions.go index b46205ac0..208fba751 100644 --- a/internal/versions/versions.go +++ b/internal/versions/versions.go @@ -26,6 +26,7 @@ const ( latestFilterRegex = "(?i)(^Available versions:|-src|-dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|(a|b|c)[0-9]+|snapshot|master|main)" numericStartFilterRegex = "^\\s*[0-9]" noLatestVersionErrMsg = "no latest version found" + missingPluginErrMsg = "missing plugin for %s" ) // UninstallableVersionError is an error returned if someone tries to install the @@ -44,6 +45,16 @@ type NoVersionSetError struct { toolName string } +// MissingPluginError is returned whenever an operation expects a plugin, +// but it is not installed. +type MissingPluginError struct { + toolName string +} + +func (e MissingPluginError) Error() string { + return fmt.Sprintf(missingPluginErrMsg, e.toolName) +} + func (e NoVersionSetError) Error() string { // Eventually switch this to a more friendly error message, BATS tests fail // with this improvement @@ -67,21 +78,40 @@ func (e VersionAlreadyInstalledError) Error() string { // installed, but it may be multiple versions if multiple versions for the tool // are specified in the .tool-versions file. func InstallAll(conf config.Config, dir string, stdOut io.Writer, stdErr io.Writer) (failures []error) { - plugins, err := plugins.List(conf, false, false) + installedPlugins, err := plugins.List(conf, false, false) if err != nil { return []error{fmt.Errorf("unable to list plugins: %w", err)} } + pluginsMap := map[string]plugins.Plugin{} + for _, plugin := range installedPlugins { + pluginsMap[plugin.Name] = plugin + } - // Ideally we should install these in the order they are specified in the - // closest .tool-versions file, but for now that is too complicated to - // implement. - for _, plugin := range plugins { - err := Install(conf, plugin, dir, stdOut, stdErr) - if err != nil { + toolVersions, err := resolve.AllVersions(conf, installedPlugins, dir) + if err != nil { + return []error{fmt.Errorf("unable to resolve versions: %w", err)} + } + + for _, toolVersion := range toolVersions { + if plugin, isPluginResolved := pluginsMap[toolVersion.Name]; isPluginResolved { + delete(pluginsMap, plugin.Name) + for _, version := range toolVersion.Versions { + err := InstallOneVersion(conf, plugin, version, false, stdOut, stdErr) + if err != nil { + failures = append(failures, err) + } + } + } else { + err = MissingPluginError{toolName: toolVersion.Name} failures = append(failures, err) } } + for _, plugin := range pluginsMap { + err := NoVersionSetError{toolName: plugin.Name} + failures = append(failures, err) + } + return failures } diff --git a/internal/versions/versions_test.go b/internal/versions/versions_test.go index 2fb0a71aa..bc3373810 100644 --- a/internal/versions/versions_test.go +++ b/internal/versions/versions_test.go @@ -47,6 +47,7 @@ func TestInstallAll(t *testing.T) { writeVersionFile(t, currentDir, content) err := InstallAll(conf, currentDir, &stdout, &stderr) + assert.Len(t, err, 1, "expected 1 install error") assert.ErrorContains(t, err[0], "no version set") assertVersionInstalled(t, conf.DataDir, plugin.Name, version) @@ -70,6 +71,23 @@ func TestInstallAll(t *testing.T) { assertNotInstalled(t, conf.DataDir, secondPlugin.Name, version) assertVersionInstalled(t, conf.DataDir, plugin.Name, version) }) + + t.Run("reports skipped tools due to missing plugin", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + currentDir := t.TempDir() + version := "1.0.0" + + // write a version file + content := fmt.Sprintf("%s %s\n%s %s", plugin.Name, version, "non-existant-tool", version) + writeVersionFile(t, currentDir, content) + + err := InstallAll(conf, currentDir, &stdout, &stderr) + assert.Len(t, err, 1, "expected 1 install error") + assert.ErrorContains(t, err[0], "missing plugin for") + + assertVersionInstalled(t, conf.DataDir, plugin.Name, version) + }) } func TestInstall(t *testing.T) { @@ -521,9 +539,11 @@ func assertNotInstalled(t *testing.T, dataDir, pluginName, version string) { func generateConfig(t *testing.T) (config.Config, plugins.Plugin) { t.Helper() testDataDir := t.TempDir() + homeDir := t.TempDir() conf, err := config.LoadConfig() assert.Nil(t, err) conf.DataDir = testDataDir + conf.Home = homeDir _, err = repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName) assert.Nil(t, err)