feat(golang-rewrite): create versions.InstallOneVersion function

* Create Plugin.Exists method
* Create internal/versions package
* Add missing asdfrc for internal/resolve package tests
* Create versions.ParseString function
* Create versions.InstallOneVersion function
* Update hook package to allow hook output to be received
* Write tests
This commit is contained in:
Trevor Brown 2024-08-09 13:35:28 -04:00
parent ef91474538
commit 65688915a5
7 changed files with 382 additions and 6 deletions

View File

@ -3,14 +3,22 @@
package hook package hook
import ( import (
"io"
"os" "os"
"asdf/config" "asdf/config"
"asdf/execute" "asdf/execute"
) )
// Run gets a hook command from config and runs it with the provided arguments // Run gets a hook command from config and runs it with the provided arguments.
func Run(config config.Config, hookName string, arguments []string) error { // 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) hookCmd, err := config.GetHook(hookName)
if err != nil { if err != nil {
return err return err
@ -22,8 +30,8 @@ func Run(config config.Config, hookName string, arguments []string) error {
cmd := execute.NewExpression(hookCmd, arguments) cmd := execute.NewExpression(hookCmd, arguments)
cmd.Stdout = os.Stdout cmd.Stdout = stdOut
cmd.Stderr = os.Stderr cmd.Stderr = stdErr
return cmd.Run() return cmd.Run()
} }

1
internal/resolve/testdata/asdfrc vendored Normal file
View File

@ -0,0 +1 @@
legacy_version_file = yes

3
internal/versions/testdata/asdfrc vendored Normal file
View File

@ -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 $@

View File

@ -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
}

View File

@ -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)
}

View File

@ -33,6 +33,16 @@ func (e PluginAlreadyExists) Error() string {
return fmt.Sprintf(pluginAlreadyExistsMsg, e.plugin) 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 // NoCallbackError is an error returned by RunCallback when a callback with
// particular name does not exist // particular name does not exist
type NoCallbackError struct { type NoCallbackError struct {
@ -48,6 +58,7 @@ const (
dataDirPlugins = "plugins" dataDirPlugins = "plugins"
invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'" invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'"
pluginAlreadyExistsMsg = "Plugin named %s already added" pluginAlreadyExistsMsg = "Plugin named %s already added"
pluginMissingMsg = "Plugin named %s not installed"
hasNoCallbackMsg = "Plugin named %s does not have a callback named %s" 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 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 // 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 { 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) callback := filepath.Join(p.Dir, "bin", name)

View File

@ -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) { func TestPluginExists(t *testing.T) {
testDataDir := t.TempDir() testDataDir := t.TempDir()
pluginDir := PluginDirectory(testDataDir, testPluginName) pluginDir := PluginDirectory(testDataDir, testPluginName)
@ -368,10 +388,9 @@ func TestRunCallback(t *testing.T) {
testDataDir := t.TempDir() testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir} conf := config.Config{DataDir: testDataDir}
testRepo, err := repotest.GeneratePlugin("dummy_plugin", testDataDir, testPluginName) _, err := repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
assert.Nil(t, err) assert.Nil(t, err)
err = Add(conf, testPluginName, testRepo)
plugin := New(conf, testPluginName) plugin := New(conf, testPluginName)
t.Run("returns NoCallback error when callback with name not found", func(t *testing.T) { t.Run("returns NoCallback error when callback with name not found", func(t *testing.T) {