feat(golang-rewrite): create hooks

* Update tests for config package to use assert package
* Create Config.GetHook method
* Create execute package for running Bash commands
* Create hook package for running asdf hooks
* Run plugin add hooks
This commit is contained in:
Trevor Brown 2024-06-23 16:35:02 -04:00
parent 44e8257dcf
commit b1a47fe2bf
9 changed files with 294 additions and 38 deletions

View File

@ -32,6 +32,7 @@ var pluginRepoCheckDurationDefault = PluginRepoCheckDuration{Every: 60}
// Settings is a struct that stores config values from the asdfrc file // Settings is a struct that stores config values from the asdfrc file
type Settings struct { type Settings struct {
Loaded bool Loaded bool
Raw *ini.Section
LegacyVersionFile bool LegacyVersionFile bool
// I don't think this setting should be supported in the Golang implementation // I don't think this setting should be supported in the Golang implementation
// UseReleaseCandidates bool // UseReleaseCandidates bool
@ -56,6 +57,7 @@ type Config struct {
func defaultSettings() *Settings { func defaultSettings() *Settings {
return &Settings{ return &Settings{
Loaded: false, Loaded: false,
Raw: nil,
LegacyVersionFile: false, LegacyVersionFile: false,
AlwaysKeepDownload: false, AlwaysKeepDownload: false,
PluginRepositoryLastCheckDuration: pluginRepoCheckDurationDefault, PluginRepositoryLastCheckDuration: pluginRepoCheckDurationDefault,
@ -142,6 +144,16 @@ func (c *Config) DisablePluginShortNameRepository() (bool, error) {
return c.Settings.DisablePluginShortNameRepository, nil return c.Settings.DisablePluginShortNameRepository, nil
} }
// GetHook returns a hook command from config if it is there
func (c *Config) GetHook(hook string) (string, error) {
err := c.loadSettings()
if err != nil {
return "", err
}
return c.Settings.Raw.Key(hook).String(), nil
}
func (c *Config) loadSettings() error { func (c *Config) loadSettings() error {
if c.Settings.Loaded { if c.Settings.Loaded {
return nil return nil
@ -191,6 +203,7 @@ func loadSettings(asdfrcPath string) (Settings, error) {
mainConf := config.Section("") mainConf := config.Section("")
settings := defaultSettings() settings := defaultSettings()
settings.Raw = mainConf
settings.Loaded = true settings.Loaded = true
settings.PluginRepositoryLastCheckDuration = newPluginRepoCheckDuration(mainConf.Key("plugin_repository_last_check_duration").String()) settings.PluginRepositoryLastCheckDuration = newPluginRepoCheckDuration(mainConf.Key("plugin_repository_last_check_duration").String())

View File

@ -2,22 +2,24 @@ package config
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestLoadConfig(t *testing.T) { func TestLoadConfig(t *testing.T) {
config, err := LoadConfig() config, err := LoadConfig()
assert(t, err == nil, "Returned error when building config") assert.Nil(t, err, "Returned error when building config")
assert(t, config.Home != "", "Expected Home to be set") assert.NotZero(t, config.Home, "Expected Home to be set")
} }
func TestLoadConfigEnv(t *testing.T) { func TestLoadConfigEnv(t *testing.T) {
config, err := loadConfigEnv() config, err := loadConfigEnv()
assert(t, err == nil, "Returned error when loading env for config") assert.Nil(t, err, "Returned error when loading env for config")
assert(t, config.Home == "", "Shouldn't set Home property when loading config") assert.Zero(t, config.Home, "Shouldn't set Home property when loading config")
} }
func TestLoadSettings(t *testing.T) { func TestLoadSettings(t *testing.T) {
@ -36,26 +38,26 @@ func TestLoadSettings(t *testing.T) {
t.Run("When given path to populated asdfrc returns populated settings struct", func(t *testing.T) { t.Run("When given path to populated asdfrc returns populated settings struct", func(t *testing.T) {
settings, err := loadSettings("testdata/asdfrc") settings, err := loadSettings("testdata/asdfrc")
refuteError(t, err) assert.Nil(t, err)
assert(t, settings.Loaded, "Expected Loaded field to be set to true") assert.True(t, settings.Loaded, "Expected Loaded field to be set to true")
assert(t, settings.LegacyVersionFile == true, "LegacyVersionFile field has wrong value") assert.True(t, settings.LegacyVersionFile, "LegacyVersionFile field has wrong value")
assert(t, settings.AlwaysKeepDownload == true, "AlwaysKeepDownload field has wrong value") assert.True(t, settings.AlwaysKeepDownload, "AlwaysKeepDownload field has wrong value")
assert(t, settings.PluginRepositoryLastCheckDuration.Never, "PluginRepositoryLastCheckDuration field has wrong value") assert.True(t, settings.PluginRepositoryLastCheckDuration.Never, "PluginRepositoryLastCheckDuration field has wrong value")
assert(t, settings.PluginRepositoryLastCheckDuration.Every == 0, "PluginRepositoryLastCheckDuration field has wrong value") assert.Zero(t, settings.PluginRepositoryLastCheckDuration.Every, "PluginRepositoryLastCheckDuration field has wrong value")
assert(t, settings.DisablePluginShortNameRepository == true, "DisablePluginShortNameRepository field has wrong value") assert.True(t, settings.DisablePluginShortNameRepository, "DisablePluginShortNameRepository field has wrong value")
}) })
t.Run("When given path to empty file returns settings struct with defaults", func(t *testing.T) { t.Run("When given path to empty file returns settings struct with defaults", func(t *testing.T) {
settings, err := loadSettings("testdata/empty-asdfrc") settings, err := loadSettings("testdata/empty-asdfrc")
refuteError(t, err) assert.Nil(t, err)
assert(t, settings.LegacyVersionFile == false, "LegacyVersionFile field has wrong value") assert.False(t, settings.LegacyVersionFile, "LegacyVersionFile field has wrong value")
assert(t, settings.AlwaysKeepDownload == false, "AlwaysKeepDownload field has wrong value") assert.False(t, settings.AlwaysKeepDownload, "AlwaysKeepDownload field has wrong value")
assert(t, settings.PluginRepositoryLastCheckDuration.Never == false, "PluginRepositoryLastCheckDuration field has wrong value") assert.False(t, settings.PluginRepositoryLastCheckDuration.Never, "PluginRepositoryLastCheckDuration field has wrong value")
assert(t, settings.PluginRepositoryLastCheckDuration.Every == 60, "PluginRepositoryLastCheckDuration field has wrong value") assert.Equal(t, settings.PluginRepositoryLastCheckDuration.Every, 60, "PluginRepositoryLastCheckDuration field has wrong value")
assert(t, settings.DisablePluginShortNameRepository == false, "DisablePluginShortNameRepository field has wrong value") assert.False(t, settings.DisablePluginShortNameRepository, "DisablePluginShortNameRepository field has wrong value")
}) })
} }
@ -64,44 +66,62 @@ func TestConfigMethods(t *testing.T) {
t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc") t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc")
config, err := LoadConfig() config, err := LoadConfig()
assert(t, err == nil, "Returned error when building config") assert.Nil(t, err, "Returned error when building config")
t.Run("Returns LegacyVersionFile from asdfrc file", func(t *testing.T) { t.Run("Returns LegacyVersionFile from asdfrc file", func(t *testing.T) {
legacyFile, err := config.LegacyVersionFile() legacyFile, err := config.LegacyVersionFile()
assert(t, err == nil, "Returned error when loading settings") assert.Nil(t, err, "Returned error when loading settings")
assert(t, legacyFile == true, "Expected LegacyVersionFile to be set") assert.True(t, legacyFile, "Expected LegacyVersionFile to be set")
}) })
t.Run("Returns AlwaysKeepDownload from asdfrc file", func(t *testing.T) { t.Run("Returns AlwaysKeepDownload from asdfrc file", func(t *testing.T) {
alwaysKeepDownload, err := config.AlwaysKeepDownload() alwaysKeepDownload, err := config.AlwaysKeepDownload()
assert(t, err == nil, "Returned error when loading settings") assert.Nil(t, err, "Returned error when loading settings")
assert(t, alwaysKeepDownload == true, "Expected AlwaysKeepDownload to be set") assert.True(t, alwaysKeepDownload, "Expected AlwaysKeepDownload to be set")
}) })
t.Run("Returns PluginRepositoryLastCheckDuration from asdfrc file", func(t *testing.T) { t.Run("Returns PluginRepositoryLastCheckDuration from asdfrc file", func(t *testing.T) {
checkDuration, err := config.PluginRepositoryLastCheckDuration() checkDuration, err := config.PluginRepositoryLastCheckDuration()
assert(t, err == nil, "Returned error when loading settings") assert.Nil(t, err, "Returned error when loading settings")
assert(t, checkDuration.Never == true, "Expected PluginRepositoryLastCheckDuration to be set") assert.True(t, checkDuration.Never, "Expected PluginRepositoryLastCheckDuration to be set")
assert(t, checkDuration.Every == 0, "Expected PluginRepositoryLastCheckDuration to be set") assert.Zero(t, checkDuration.Every, "Expected PluginRepositoryLastCheckDuration to be set")
}) })
t.Run("Returns DisablePluginShortNameRepository from asdfrc file", func(t *testing.T) { t.Run("Returns DisablePluginShortNameRepository from asdfrc file", func(t *testing.T) {
DisablePluginShortNameRepository, err := config.DisablePluginShortNameRepository() DisablePluginShortNameRepository, err := config.DisablePluginShortNameRepository()
assert(t, err == nil, "Returned error when loading settings") assert.Nil(t, err, "Returned error when loading settings")
assert(t, DisablePluginShortNameRepository == true, "Expected DisablePluginShortNameRepository to be set") assert.True(t, DisablePluginShortNameRepository, "Expected DisablePluginShortNameRepository to be set")
}) })
} }
func assert(t *testing.T, expr bool, message string) { func TestConfigGetHook(t *testing.T) {
t.Helper() // Set the asdf config file location to the test file
t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc")
if !expr { config, err := LoadConfig()
t.Error(message) assert.Nil(t, err, "Returned error when building config")
}
}
func refuteError(t *testing.T, err error) { t.Run("Returns empty string when hook not present in asdfrc file", func(t *testing.T) {
if err != nil { hookCmd, err := config.GetHook("post_asdf_plugin_add")
t.Fatal("Returned unexpected error", err) assert.Nil(t, err)
} assert.Zero(t, hookCmd)
})
t.Run("Returns string containing Bash expression when present in asdfrc file", func(t *testing.T) {
hookCmd, err := config.GetHook("pre_asdf_plugin_add")
assert.Nil(t, err)
assert.Equal(t, hookCmd, "echo Executing with args: $@")
})
t.Run("Ignores trailing and leading spaces", func(t *testing.T) {
hookCmd, err := config.GetHook("pre_asdf_plugin_add_test")
assert.Nil(t, err)
assert.Equal(t, hookCmd, "echo Executing with args: $@")
})
t.Run("Preserves quoting", func(t *testing.T) {
hookCmd, err := config.GetHook("pre_asdf_plugin_add_test2")
assert.Nil(t, err)
assert.Equal(t, hookCmd, "echo 'Executing' \"with args: $@\"")
})
} }

View File

@ -5,3 +5,8 @@ use_release_candidates = yes
always_keep_download = yes always_keep_download = yes
plugin_repository_last_check_duration = never plugin_repository_last_check_duration = never
disable_plugin_short_name_repository = yes disable_plugin_short_name_repository = yes
# Hooks
pre_asdf_plugin_add = echo Executing with args: $@
pre_asdf_plugin_add_test = echo Executing with args: $@
pre_asdf_plugin_add_test2 = echo 'Executing' "with args: $@"

49
execute/execute.go Normal file
View File

@ -0,0 +1,49 @@
// Package execute is a simple package that wraps the os/exec Command features
// for convenient use in asdf. It was inspired by
// https://github.com/chen-keinan/go-command-eval
package execute
import (
"fmt"
"io"
"os/exec"
)
// Command represents a Bash command that can be executed by asdf
type Command struct {
Command string
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env map[string]string
}
// New takes a string containing a Bash expression and a slice of string
// arguments and returns a Command struct
func New(command string, args []string) Command {
return Command{Command: command, Args: args}
}
// Run executes a Command with Bash and returns the error if there is one
func (c Command) Run() error {
args := append([]string{"-c", c.Command}, c.Args...)
cmd := exec.Command("bash", args...)
cmd.Env = mapToSlice(c.Env)
cmd.Stdin = c.Stdin
// Capture stdout and stderr
cmd.Stdout = c.Stdout
cmd.Stderr = c.Stderr
return cmd.Run()
}
func mapToSlice(env map[string]string) (slice []string) {
for key, value := range env {
slice = append(slice, fmt.Sprintf("%s=%s", key, value))
}
return slice
}

80
execute/execute_test.go Normal file
View File

@ -0,0 +1,80 @@
package execute
import (
"os/exec"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNew(t *testing.T) {
t.Run("Returns new command", func(t *testing.T) {
cmd := New("echo", []string{"test string"})
assert.Equal(t, "echo", cmd.Command)
})
}
func TestRun(t *testing.T) {
t.Run("command is executed with bash", func(t *testing.T) {
cmd := New("echo $(type -a sh)", []string{})
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "sh is /bin/sh\n", stdout.String())
})
t.Run("positional args are passed to command", func(t *testing.T) {
cmd := New("echo $0", []string{"test string"})
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "test string\n", stdout.String())
})
t.Run("environment variables are passed to command", func(t *testing.T) {
cmd := New("echo $MYVAR", []string{})
cmd.Env = map[string]string{"MYVAR": "my var value"}
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "my var value\n", stdout.String())
})
t.Run("captures stdout and stdin", func(t *testing.T) {
cmd := New("echo 'a test' | tee /dev/stderr", []string{})
cmd.Env = map[string]string{"MYVAR": "my var value"}
var stdout strings.Builder
cmd.Stdout = &stdout
var stderr strings.Builder
cmd.Stderr = &stderr
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "a test\n", stdout.String())
assert.Equal(t, "a test\n", stderr.String())
})
t.Run("returns error when non-zero exit code", func(t *testing.T) {
cmd := New("exit 12", []string{})
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.NotNil(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, 12, err.(*exec.ExitError).ExitCode())
})
}

29
hook/hook.go Normal file
View File

@ -0,0 +1,29 @@
// Package hook provides a simple interface for running hook commands that may
// be defined in the asdfrc file
package hook
import (
"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 {
hookCmd, err := config.GetHook(hookName)
if err != nil {
return err
}
if hookCmd == "" {
return nil
}
cmd := execute.New(hookCmd, arguments)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

39
hook/hook_test.go Normal file
View File

@ -0,0 +1,39 @@
package hook
import (
"os/exec"
"testing"
"asdf/config"
"github.com/stretchr/testify/assert"
)
func TestRun(t *testing.T) {
// Set the asdf config file location to the test file
t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc")
t.Run("accepts config, hook name, and a slice of string arguments", func(t *testing.T) {
config, err := config.LoadConfig()
assert.Nil(t, err)
err = Run(config, "pre_asdf_plugin_add_test", []string{})
assert.Nil(t, err)
})
t.Run("passes argument to command", func(t *testing.T) {
config, err := config.LoadConfig()
assert.Nil(t, err)
err = Run(config, "pre_asdf_plugin_add_test2", []string{"123"})
assert.Equal(t, 123, err.(*exec.ExitError).ExitCode())
})
t.Run("does not return error when no such hook is defined in asdfrc", func(t *testing.T) {
config, err := config.LoadConfig()
assert.Nil(t, err)
err = Run(config, "nonexistant-hook", []string{})
assert.Nil(t, err)
})
}

7
hook/testdata/asdfrc vendored Normal file
View File

@ -0,0 +1,7 @@
# This is a test asdfrc file containing all possible values. Each field to set
# to a value that is different than the default.
# Hooks
pre_asdf_plugin_add = echo Executing with args: $@
pre_asdf_plugin_add_test = echo Executing with args: $@
pre_asdf_plugin_add_test2 = exit $0

View File

@ -10,6 +10,7 @@ import (
"asdf/config" "asdf/config"
"asdf/git" "asdf/git"
"asdf/hook"
"asdf/pluginindex" "asdf/pluginindex"
) )
@ -146,7 +147,20 @@ func Add(config config.Config, pluginName, pluginURL string) error {
} }
} }
return git.NewRepo(pluginDir).Clone(pluginURL) // Run pre hooks
hook.Run(config, "pre_asdf_plugin_add", []string{})
hook.Run(config, fmt.Sprintf("pre_asdf_plugin_add_%s", pluginName), []string{})
err = git.NewRepo(pluginDir).Clone(pluginURL)
if err != nil {
return err
}
// Run post hooks
hook.Run(config, "post_asdf_plugin_add", []string{})
hook.Run(config, fmt.Sprintf("post_asdf_plugin_add_%s", pluginName), []string{})
return nil
} }
// Remove uninstalls a plugin by removing it from the file system if installed // Remove uninstalls a plugin by removing it from the file system if installed