Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Report missing plugins and install plugins in defined order #1970

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ repository
.vagrant
keyrings
/tmp
.idea

dist/

Expand Down
3 changes: 2 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
import (
"context"
"io/fs"
"os"
"strconv"
"strings"

Expand Down Expand Up @@ -100,7 +101,7 @@ func LoadConfig() (Config, error) {
return config, err
}

homeDir, err := homedir.Dir()
homeDir, err := os.UserHomeDir()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@andrecloutier andrecloutier Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it here since it's a fairly safe change. Nothing else appears to be reading from config.Home prior to this change and the current implementation of install was already relying on os.UserHomeDir(). I think removing the other usages merit its own PR. Seems trivial but I'm not confident enough w/ this code base yet to say there aren't edge cases to consider.

if err != nil {
return config, err
}
Expand Down
1 change: 0 additions & 1 deletion internal/execenv/execenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ func TestMergeEnv(t *testing.T) {
}

func TestGenerate(t *testing.T) {

t.Run("returns map of environment variables", func(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
Expand Down
115 changes: 90 additions & 25 deletions internal/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package resolve

import (
"fmt"
"iter"
"os"
"path"
"strings"
Expand All @@ -16,55 +17,120 @@ 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
return ToolVersions{Name: plugin.Name, Versions: versions, 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
Expand All @@ -77,8 +143,7 @@ func findVersionsInDir(conf config.Config, plugin plugins.Plugin, directory stri
return versions, found, err
}
}

return versions, found, nil
return versions, false, nil
}

// findVersionsInEnv returns the version from the environment if present
Expand Down Expand Up @@ -111,7 +176,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
}
}

Expand Down
92 changes: 91 additions & 1 deletion internal/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -72,6 +73,95 @@ 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 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"}
Expand Down
44 changes: 37 additions & 7 deletions internal/versions/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
uninstallableVersionMsg = "uninstallable version: %s"
latestFilterRegex = "(?i)(^Available versions:|-src|-dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|(a|b|c)[0-9]+|snapshot|master)"
noLatestVersionErrMsg = "no latest version found"
missingPluginErrMsg = "missing plugin for %s"
)

// UninstallableVersionError is an error returned if someone tries to install the
Expand All @@ -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
Expand All @@ -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
}

Expand Down
Loading