diff --git a/hook/hook.go b/hook/hook.go index 51750dbc..6bc909d8 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -3,14 +3,22 @@ package hook import ( + "io" "os" "asdf/config" "asdf/execute" ) -// Run gets a hook command from config and runs it with the provided arguments -func Run(config config.Config, hookName string, arguments []string) error { +// Run gets a hook command from config and runs it with the provided arguments. +// Output is sent to STDOUT and STDERR +func Run(conf config.Config, hookName string, arguments []string) error { + return RunWithOutput(conf, hookName, arguments, os.Stdout, os.Stderr) +} + +// RunWithOutput gets a hook command from config and runs it with the provided +// arguments. Output is sent to the provided io.Writers. +func RunWithOutput(config config.Config, hookName string, arguments []string, stdOut io.Writer, stdErr io.Writer) error { hookCmd, err := config.GetHook(hookName) if err != nil { return err @@ -22,8 +30,8 @@ func Run(config config.Config, hookName string, arguments []string) error { cmd := execute.NewExpression(hookCmd, arguments) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = stdOut + cmd.Stderr = stdErr return cmd.Run() } diff --git a/internal/resolve/testdata/asdfrc b/internal/resolve/testdata/asdfrc new file mode 100644 index 00000000..e600bd0c --- /dev/null +++ b/internal/resolve/testdata/asdfrc @@ -0,0 +1 @@ +legacy_version_file = yes diff --git a/internal/versions/testdata/asdfrc b/internal/versions/testdata/asdfrc new file mode 100644 index 00000000..dfa93fb6 --- /dev/null +++ b/internal/versions/testdata/asdfrc @@ -0,0 +1,3 @@ +pre_asdf_download_lua = echo pre_asdf_download_lua $@ +pre_asdf_install_lua = echo pre_asdf_install_lua $@ +post_asdf_install_lua = echo post_asdf_install_lua $@ diff --git a/internal/versions/versions.go b/internal/versions/versions.go new file mode 100644 index 00000000..ed1dbb9f --- /dev/null +++ b/internal/versions/versions.go @@ -0,0 +1,126 @@ +// Package versions handles all operations pertaining to specific versions. +// Install, uninstall, etc... +package versions + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "asdf/config" + "asdf/hook" + "asdf/plugins" +) + +const ( + systemVersion = "system" + latestVersion = "latest" + uninstallableVersionMsg = "uninstallable version: system" + dataDirDownloads = "downloads" + dataDirInstalls = "installs" +) + +// UninstallableVersion is an error returned if someone tries to install the +// system version. +type UninstallableVersion struct{} + +func (e UninstallableVersion) Error() string { + return fmt.Sprint(uninstallableVersionMsg) +} + +// TODO: Implement these functions +//func InstallAll() { +//} + +//func InstallOne() { +//} + +// InstallOneVersion installs a specific version of a specific tool +func InstallOneVersion(conf config.Config, plugin plugins.Plugin, version string, _ bool, stdOut io.Writer, stdErr io.Writer) error { + err := plugin.Exists() + if err != nil { + return err + } + + if version == systemVersion { + return UninstallableVersion{} + } + + if version == latestVersion { + // TODO: Implement this + return errors.New("not implemented") + } + + downloadDir := downloadPath(conf, plugin, version) + installDir := installPath(conf, plugin, version) + versionType, version := ParseString(version) + + // Check if version already installed + if _, err = os.Stat(installDir); !os.IsNotExist(err) { + return fmt.Errorf("version %s of %s is already installed", version, plugin.Name) + } + + env := map[string]string{ + "ASDF_INSTALL_TYPE": versionType, + "ASDF_INSTALL_VERSION": version, + "ASDF_INSTALL_PATH": installDir, + "ASDF_DOWNLOAD_PATH": downloadDir, + } + + err = os.MkdirAll(downloadDir, 0o777) + if err != nil { + return fmt.Errorf("unable to create download dir: %w", err) + } + + err = hook.RunWithOutput(conf, fmt.Sprintf("pre_asdf_download_%s", plugin.Name), []string{version}, stdOut, stdErr) + if err != nil { + return fmt.Errorf("failed to run pre-download hook: %w", err) + } + + err = plugin.RunCallback("download", []string{}, env, stdOut, stdErr) + if _, ok := err.(plugins.NoCallbackError); err != nil && !ok { + return fmt.Errorf("failed to run download callback: %w", err) + } + + err = hook.RunWithOutput(conf, fmt.Sprintf("pre_asdf_install_%s", plugin.Name), []string{version}, stdOut, stdErr) + if err != nil { + return fmt.Errorf("failed to run pre-install hook: %w", err) + } + + err = os.MkdirAll(installDir, 0o777) + if err != nil { + return fmt.Errorf("unable to create install dir: %w", err) + } + + err = plugin.RunCallback("install", []string{}, env, stdOut, stdErr) + if err != nil { + return fmt.Errorf("failed to run install callback: %w", err) + } + + err = hook.RunWithOutput(conf, fmt.Sprintf("post_asdf_install_%s", plugin.Name), []string{version}, stdOut, stdErr) + if err != nil { + return fmt.Errorf("failed to run post-install hook: %w", err) + } + return nil +} + +func downloadPath(conf config.Config, plugin plugins.Plugin, version string) string { + return filepath.Join(conf.DataDir, dataDirDownloads, plugin.Name, version) +} + +func installPath(conf config.Config, plugin plugins.Plugin, version string) string { + return filepath.Join(conf.DataDir, dataDirInstalls, plugin.Name, version) +} + +// ParseString parses a version string into versionType and version components +func ParseString(version string) (string, string) { + segments := strings.Split(version, ":") + if len(segments) >= 1 && segments[0] == "ref" { + return "ref", strings.Join(segments[1:], ":") + } + + return "version", version +} diff --git a/internal/versions/versions_test.go b/internal/versions/versions_test.go new file mode 100644 index 00000000..5a9d4e99 --- /dev/null +++ b/internal/versions/versions_test.go @@ -0,0 +1,194 @@ +package versions + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "asdf/config" + "asdf/plugins" + "asdf/repotest" + + "github.com/stretchr/testify/assert" +) + +const testPluginName = "lua" + +func TestInstallOneVersion(t *testing.T) { + t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc") + + t.Run("returns error when plugin doesn't exist", func(t *testing.T) { + conf, _ := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugins.New(conf, "non-existent"), "1.2.3", false, &stdout, &stderr) + assert.IsType(t, plugins.PluginMissing{}, err) + }) + + t.Run("returns error when plugin version is 'system'", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "system", false, &stdout, &stderr) + assert.IsType(t, UninstallableVersion{}, err) + }) + + //t.Run("installs latest version of tool when version is 'latest'", func(t *testing.T) { + // t.Fatal("not implemented") + //}) + + t.Run("returns error when version doesn't exist", func(t *testing.T) { + version := "other-dummy" + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, version, false, &stdout, &stderr) + assert.Errorf(t, err, "failed to run install callback: exit status 1") + + want := "pre_asdf_download_lua other-dummy\npre_asdf_install_lua other-dummy\nDummy couldn't install version: other-dummy (on purpose)\n" + assert.Equal(t, want, stdout.String()) + + assertNotInstalled(t, conf.DataDir, plugin.Name, version) + }) + + t.Run("returns error when version already installed", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + assertInstalled(t, conf.DataDir, plugin.Name, "1.0.0") + + // Install a second time + err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.NotNil(t, err) + }) + + t.Run("creates download directory", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + + downloadPath := filepath.Join(conf.DataDir, "downloads", plugin.Name, "1.0.0") + pathInfo, err := os.Stat(downloadPath) + assert.Nil(t, err) + assert.True(t, pathInfo.IsDir()) + }) + + t.Run("creates install directory", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + + installPath := filepath.Join(conf.DataDir, "installs", plugin.Name, "1.0.0") + pathInfo, err := os.Stat(installPath) + assert.Nil(t, err) + assert.True(t, pathInfo.IsDir()) + }) + + t.Run("runs pre-download, pre-install and post-install hooks when installation successful", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + assert.Equal(t, "", stderr.String()) + want := "pre_asdf_download_lua 1.0.0\npre_asdf_install_lua 1.0.0\npost_asdf_install_lua 1.0.0\n" + assert.Equal(t, want, stdout.String()) + }) + + t.Run("installs successfully when plugin exists but version does not", func(t *testing.T) { + conf, plugin := generateConfig(t) + stdout, stderr := buildOutputs() + err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + + // Check download directory + downloadPath := filepath.Join(conf.DataDir, "downloads", plugin.Name, "1.0.0") + entries, err := os.ReadDir(downloadPath) + assert.Nil(t, err) + // mock plugin doesn't write anything + assert.Empty(t, entries) + + // Check install directory + assertInstalled(t, conf.DataDir, plugin.Name, "1.0.0") + }) + + t.Run("install successfully when plugin lacks download callback", func(t *testing.T) { + conf, _ := generateConfig(t) + stdout, stderr := buildOutputs() + testPluginName := "no-download" + _, err := repotest.InstallPlugin("dummy_plugin_no_download", conf.DataDir, testPluginName) + assert.Nil(t, err) + plugin := plugins.New(conf, testPluginName) + + err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr) + assert.Nil(t, err) + + // no-download install script prints 'install' + assert.Equal(t, "install", stdout.String()) + }) +} + +func TestParseString(t *testing.T) { + t.Run("returns 'version', and unmodified version when passed semantic version", func(t *testing.T) { + versionType, version := ParseString("1.2.3") + assert.Equal(t, versionType, "version") + assert.Equal(t, version, "1.2.3") + }) + + t.Run("returns 'ref' and reference version when passed a ref version", func(t *testing.T) { + versionType, version := ParseString("ref:abc123") + assert.Equal(t, versionType, "ref") + assert.Equal(t, version, "abc123") + }) + + t.Run("returns 'ref' and empty string when passed 'ref:'", func(t *testing.T) { + versionType, version := ParseString("ref:") + assert.Equal(t, versionType, "ref") + assert.Equal(t, version, "") + }) +} + +// Helper functions +func buildOutputs() (strings.Builder, strings.Builder) { + var stdout strings.Builder + var stderr strings.Builder + + return stdout, stderr +} + +func assertInstalled(t *testing.T, dataDir, pluginName, version string) { + t.Helper() + + installPath := filepath.Join(dataDir, "installs", pluginName, version) + entries, err := os.ReadDir(installPath) + assert.Nil(t, err) + + var fileNames []string + for _, e := range entries { + fileNames = append(fileNames, e.Name()) + } + + assert.Equal(t, fileNames, []string{"bin", "env", "version"}) +} + +func assertNotInstalled(t *testing.T, dataDir, pluginName, version string) { + t.Helper() + + installPath := filepath.Join(dataDir, "installs", pluginName, version) + entries, err := os.ReadDir(installPath) + assert.Empty(t, entries) + assert.Nil(t, err) +} + +func generateConfig(t *testing.T) (config.Config, plugins.Plugin) { + t.Helper() + testDataDir := t.TempDir() + conf, err := config.LoadConfig() + assert.Nil(t, err) + conf.DataDir = testDataDir + + _, err = repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName) + assert.Nil(t, err) + + return conf, plugins.New(conf, testPluginName) +} diff --git a/plugins/plugins.go b/plugins/plugins.go index e84bfcf3..c13e46dd 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -33,6 +33,16 @@ func (e PluginAlreadyExists) Error() string { return fmt.Sprintf(pluginAlreadyExistsMsg, e.plugin) } +// PluginMissing is the error returned when Plugin.Exists is call and the plugin +// doesn't exist on disk. +type PluginMissing struct { + plugin string +} + +func (e PluginMissing) Error() string { + return fmt.Sprintf(pluginMissingMsg, e.plugin) +} + // NoCallbackError is an error returned by RunCallback when a callback with // particular name does not exist type NoCallbackError struct { @@ -48,6 +58,7 @@ const ( dataDirPlugins = "plugins" invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'" pluginAlreadyExistsMsg = "Plugin named %s already added" + pluginMissingMsg = "Plugin named %s not installed" hasNoCallbackMsg = "Plugin named %s does not have a callback named %s" ) @@ -126,6 +137,20 @@ func (p Plugin) ParseLegacyVersionFile(path string) (versions []string, err erro return versions, err } +// Exists returns a boolean indicating whether or not the plugin exists on disk. +func (p Plugin) Exists() error { + exists, err := directoryExists(p.Dir) + if err != nil { + return err + } + + if !exists { + return PluginMissing{plugin: p.Name} + } + + return nil +} + // RunCallback invokes a callback with the given name if it exists for the plugin func (p Plugin) RunCallback(name string, arguments []string, environment map[string]string, stdOut io.Writer, errOut io.Writer) error { callback := filepath.Join(p.Dir, "bin", name) diff --git a/plugins/plugins_test.go b/plugins/plugins_test.go index a3f9e2c6..29fd0763 100644 --- a/plugins/plugins_test.go +++ b/plugins/plugins_test.go @@ -285,6 +285,26 @@ func TestUpdate(t *testing.T) { } } +func TestExists(t *testing.T) { + testDataDir := t.TempDir() + conf := config.Config{DataDir: testDataDir} + _, err := repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName) + assert.Nil(t, err) + + existingPlugin := New(conf, testPluginName) + + t.Run("returns nil if plugin exists", func(t *testing.T) { + err := existingPlugin.Exists() + assert.Nil(t, err) + }) + + t.Run("returns PluginMissing error when plugin missing", func(t *testing.T) { + missingPlugin := New(conf, "non-existent") + err := missingPlugin.Exists() + assert.Equal(t, err, PluginMissing{plugin: "non-existent"}) + }) +} + func TestPluginExists(t *testing.T) { testDataDir := t.TempDir() pluginDir := PluginDirectory(testDataDir, testPluginName) @@ -368,10 +388,9 @@ func TestRunCallback(t *testing.T) { testDataDir := t.TempDir() conf := config.Config{DataDir: testDataDir} - testRepo, err := repotest.GeneratePlugin("dummy_plugin", testDataDir, testPluginName) + _, err := repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName) assert.Nil(t, err) - err = Add(conf, testPluginName, testRepo) plugin := New(conf, testPluginName) t.Run("returns NoCallback error when callback with name not found", func(t *testing.T) {