feat(golang-rewrite): create RunCallback method for Plugin struct

* Create `plugins.New` function, updating existing code to use it
* Add another test for `hook.Run` function
* Enable `plugin_add_command.bats` tests for Go implementation of asdf
* Add `RunCallback` method to `Plugin` struct
* Update `plugins.Add` function to run `post-plugin-add` plugin callback script
* Handle Bash expression and scripts properly in `execute` package so `$@` is always set
This commit is contained in:
Trevor Brown 2024-07-03 20:27:13 -04:00
parent 2531de184a
commit f1e7c05ae3
10 changed files with 290 additions and 58 deletions

View File

@ -7,11 +7,13 @@ import (
"fmt"
"io"
"os/exec"
"strings"
)
// Command represents a Bash command that can be executed by asdf
type Command struct {
Command string
Expression string
Args []string
Stdin io.Reader
Stdout io.Writer
@ -19,16 +21,31 @@ type Command struct {
Env map[string]string
}
// New takes a string containing a Bash expression and a slice of string
// arguments and returns a Command struct
// New takes a string containing the path to a Bash script, and a slice of
// string arguments and returns a Command struct
func New(command string, args []string) Command {
return Command{Command: command, Args: args}
}
// NewExpression takes a string containing a Bash expression and a slice of
// string arguments and returns a Command struct
func NewExpression(expression string, args []string) Command {
return Command{Expression: expression, 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...)
var command string
if c.Expression != "" {
// Expressions need to be invoked inside a Bash function, so variables like
// $0 and $@ are available
command = fmt.Sprintf("fn() { %s; }; fn %s", c.Expression, formatArgString(c.Args))
} else {
// Scripts can be invoked directly, with args provided
command = fmt.Sprintf("%s %s", c.Command, formatArgString(c.Args))
}
cmd := exec.Command("bash", "-c", command)
cmd.Env = mapToSlice(c.Env)
cmd.Stdin = c.Stdin
@ -40,6 +57,14 @@ func (c Command) Run() error {
return cmd.Run()
}
func formatArgString(args []string) string {
var newArgs []string
for _, str := range args {
newArgs = append(newArgs, fmt.Sprintf("\"%s\"", str))
}
return strings.Join(newArgs, " ")
}
func mapToSlice(env map[string]string) (slice []string) {
for key, value := range env {
slice = append(slice, fmt.Sprintf("%s=%s", key, value))

View File

@ -12,12 +12,21 @@ 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)
assert.Equal(t, "", cmd.Expression)
})
}
func TestRun(t *testing.T) {
func TestNewExpression(t *testing.T) {
t.Run("Returns new command expression", func(t *testing.T) {
cmd := NewExpression("echo", []string{"test string"})
assert.Equal(t, "echo", cmd.Expression)
assert.Equal(t, "", cmd.Command)
})
}
func TestRun_Command(t *testing.T) {
t.Run("command is executed with bash", func(t *testing.T) {
cmd := New("echo $(type -a sh)", []string{})
cmd := New("echo $(type -a sh);", []string{})
var stdout strings.Builder
cmd.Stdout = &stdout
@ -27,8 +36,8 @@ func TestRun(t *testing.T) {
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"})
t.Run("positional arg is passed to command", func(t *testing.T) {
cmd := New("testdata/script", []string{"test string"})
var stdout strings.Builder
cmd.Stdout = &stdout
@ -38,8 +47,19 @@ func TestRun(t *testing.T) {
assert.Equal(t, "test string\n", stdout.String())
})
t.Run("positional args are passed to command", func(t *testing.T) {
cmd := New("testdata/script", []string{"test string", "another string"})
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "test string another string\n", stdout.String())
})
t.Run("environment variables are passed to command", func(t *testing.T) {
cmd := New("echo $MYVAR", []string{})
cmd := New("echo $MYVAR;", []string{})
cmd.Env = map[string]string{"MYVAR": "my var value"}
var stdout strings.Builder
@ -78,3 +98,78 @@ func TestRun(t *testing.T) {
assert.Equal(t, 12, err.(*exec.ExitError).ExitCode())
})
}
func TestRun_Expression(t *testing.T) {
t.Run("expression is executed with bash", func(t *testing.T) {
cmd := NewExpression("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 arg is passed to expression", func(t *testing.T) {
cmd := NewExpression("echo $1; true", []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("positional args are passed to expression", func(t *testing.T) {
cmd := NewExpression("echo $@; true", []string{"test string", "another string"})
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
assert.Nil(t, err)
assert.Equal(t, "test string another string\n", stdout.String())
})
t.Run("environment variables are passed to expression", func(t *testing.T) {
cmd := NewExpression("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 := NewExpression("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 := NewExpression("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())
})
}

View File

@ -20,7 +20,7 @@ func Run(config config.Config, hookName string, arguments []string) error {
return nil
}
cmd := execute.New(hookCmd, arguments)
cmd := execute.NewExpression(hookCmd, arguments)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@ -29,6 +29,14 @@ func TestRun(t *testing.T) {
assert.Equal(t, 123, err.(*exec.ExitError).ExitCode())
})
t.Run("passes arguments to command", func(t *testing.T) {
config, err := config.LoadConfig()
assert.Nil(t, err)
err = Run(config, "pre_asdf_plugin_add_test3", []string{"exit 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)

View File

@ -4,4 +4,5 @@
# 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
pre_asdf_plugin_add_test2 = exit $1
pre_asdf_plugin_add_test3 = eval $@

View File

@ -1,7 +1,9 @@
package main
import (
"fmt"
"os/exec"
"strings"
"testing"
)
@ -45,9 +47,9 @@ func TestBatsTests(t *testing.T) {
// runBatsFile(t, dir, "list_command.bats")
//})
//t.Run("plugin_add_command", func(t *testing.T) {
// runBatsFile(t, dir, "plugin_add_command.bats")
//})
t.Run("plugin_add_command", func(t *testing.T) {
runBatsFile(t, dir, "plugin_add_command.bats")
})
//t.Run("plugin_extension_command", func(t *testing.T) {
// runBatsFile(t, dir, "plugin_extension_command.bats")
@ -118,32 +120,32 @@ func TestBatsTests(t *testing.T) {
//})
}
// func runBatsFile(t *testing.T, dir, filename string) {
// t.Helper()
func runBatsFile(t *testing.T, dir, filename string) {
t.Helper()
// cmd := exec.Command("bats", "--verbose-run", fmt.Sprintf("test/%s", filename))
cmd := exec.Command("bats", "--verbose-run", fmt.Sprintf("test/%s", filename))
// // Capture stdout and stderr
// var stdout strings.Builder
// var stderr strings.Builder
// cmd.Stdout = &stdout
// cmd.Stderr = &stderr
// Capture stdout and stderr
var stdout strings.Builder
var stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// // Add dir to asdf test variables
// asdfTestHome := fmt.Sprintf("BASE_DIR=%s", dir)
// asdfBinPath := fmt.Sprintf("ASDF_BIN=%s", dir)
// cmd.Env = []string{asdfBinPath, asdfTestHome}
// Add dir to asdf test variables
asdfTestHome := fmt.Sprintf("BASE_DIR=%s", dir)
asdfBinPath := fmt.Sprintf("ASDF_BIN=%s", dir)
cmd.Env = []string{asdfBinPath, asdfTestHome}
// err := cmd.Run()
// if err != nil {
// // If command fails print both stderr and stdout
// fmt.Println("stdout:", stdout.String())
// fmt.Println("stderr:", stderr.String())
// t.Fatal("bats command failed to run test file successfully")
err := cmd.Run()
if err != nil {
// If command fails print both stderr and stdout
fmt.Println("stdout:", stdout.String())
fmt.Println("stderr:", stderr.String())
t.Fatal("bats command failed to run test file successfully")
// return
// }
//}
return
}
}
func buildAsdf(t *testing.T, dir string) {
cmd := exec.Command("go", "build", "-o", dir)

View File

@ -4,11 +4,13 @@ package plugins
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"asdf/config"
"asdf/execute"
"asdf/git"
"asdf/hook"
"asdf/pluginindex"
@ -30,10 +32,22 @@ func (e PluginAlreadyExists) Error() string {
return fmt.Sprintf(pluginAlreadyExistsMsg, e.plugin)
}
// NoCallbackError is an error returned by RunCallback when a callback with
// particular name does not exist
type NoCallbackError struct {
callback string
plugin string
}
func (e NoCallbackError) Error() string {
return fmt.Sprintf(hasNoCallbackMsg, e.plugin, e.callback)
}
const (
dataDirPlugins = "plugins"
invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'"
pluginAlreadyExistsMsg = "Plugin named %s already added"
hasNoCallbackMsg = "Plugin named %s does not have a callback named %s"
)
// Plugin struct represents an asdf plugin to all asdf code. The name and dir
@ -46,6 +60,32 @@ type Plugin struct {
URL string
}
// New takes config and a plugin name and returns a Plugin struct. It is
// intended for functions that need to quickly initialize a plugin.
func New(config config.Config, name string) Plugin {
pluginsDir := DataDirectory(config.DataDir)
dir := filepath.Join(pluginsDir, name)
return Plugin{Dir: dir, Name: name}
}
// 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)
_, err := os.Stat(callback)
if errors.Is(err, os.ErrNotExist) {
return NoCallbackError{callback: name, plugin: p.Name}
}
cmd := execute.New(fmt.Sprintf("'%s'", callback), arguments)
cmd.Env = environment
cmd.Stdout = stdOut
cmd.Stderr = errOut
return cmd.Run()
}
// List takes config and flags for what to return and builds a list of plugins
// representing the currently installed plugins on the system.
func List(config config.Config, urls, refs bool) (plugins []Plugin, err error) {
@ -117,11 +157,7 @@ func Add(config config.Config, pluginName, pluginURL string) error {
return NewPluginAlreadyExists(pluginName)
}
pluginDir := PluginDirectory(config.DataDir, pluginName)
if err != nil {
return fmt.Errorf("unable to create plugin directory: %w", err)
}
plugin := New(config, pluginName)
if pluginURL == "" {
// Ignore error here as the default value is fine
@ -147,18 +183,23 @@ func Add(config config.Config, pluginName, pluginURL string) error {
}
}
// Run pre hooks
hook.Run(config, "pre_asdf_plugin_add", []string{})
hook.Run(config, fmt.Sprintf("pre_asdf_plugin_add_%s", pluginName), []string{})
plugin.URL = pluginURL
err = git.NewRepo(pluginDir).Clone(pluginURL)
// Run pre hooks
hook.Run(config, "pre_asdf_plugin_add", []string{plugin.Name})
hook.Run(config, fmt.Sprintf("pre_asdf_plugin_add_%s", plugin.Name), []string{})
err = git.NewRepo(plugin.Dir).Clone(plugin.URL)
if err != nil {
return err
}
env := map[string]string{"ASDF_PLUGIN_SOURCE_URL": plugin.URL, "ASDF_PLUGIN_PATH": plugin.Dir}
plugin.RunCallback("post-plugin-add", []string{}, env, os.Stdout, os.Stderr)
// Run post hooks
hook.Run(config, "post_asdf_plugin_add", []string{})
hook.Run(config, fmt.Sprintf("post_asdf_plugin_add_%s", pluginName), []string{})
hook.Run(config, "post_asdf_plugin_add", []string{plugin.Name})
hook.Run(config, fmt.Sprintf("post_asdf_plugin_add_%s", plugin.Name), []string{})
return nil
}

View File

@ -73,6 +73,18 @@ func TestList(t *testing.T) {
})
}
func TestNew(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
t.Run("returns Plugin struct with Dir and Name fields set correctly", func(t *testing.T) {
plugin := New(conf, "test-plugin")
assert.Equal(t, "test-plugin", plugin.Name)
assert.Equal(t, filepath.Join(testDataDir, "plugins", "test-plugin"), plugin.Dir)
})
}
func TestAdd(t *testing.T) {
testDataDir := t.TempDir()
@ -300,7 +312,7 @@ func TestPluginDirectory(t *testing.T) {
func TestValidatePluginName(t *testing.T) {
t.Run("returns no error when plugin name is valid", func(t *testing.T) {
err := validatePluginName(testPluginName)
refuteError(t, err)
assert.Nil(t, err)
})
invalids := []string{"plugin^name", "plugin%name", "plugin name", "PLUGIN_NAME"}
@ -316,10 +328,55 @@ func TestValidatePluginName(t *testing.T) {
}
}
func refuteError(t *testing.T, err error) {
if err != nil {
t.Fatal("Returned unexpected error", err)
}
func TestRunCallback(t *testing.T) {
emptyEnv := map[string]string{}
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
testRepo, err := installMockPluginRepo(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) {
var stdout strings.Builder
var stderr strings.Builder
err = plugin.RunCallback("non-existant", []string{}, emptyEnv, &stdout, &stderr)
assert.Equal(t, err.(NoCallbackError).Error(), "Plugin named lua does not have a callback named non-existant")
})
t.Run("passes argument to command", func(t *testing.T) {
var stdout strings.Builder
var stderr strings.Builder
err = plugin.RunCallback("debug", []string{"123"}, emptyEnv, &stdout, &stderr)
assert.Nil(t, err)
assert.Equal(t, "123\n", stdout.String())
assert.Equal(t, "", stderr.String())
})
t.Run("passes arguments to command", func(t *testing.T) {
var stdout strings.Builder
var stderr strings.Builder
err = plugin.RunCallback("debug", []string{"123", "test string"}, emptyEnv, &stdout, &stderr)
assert.Nil(t, err)
assert.Equal(t, "123 test string\n", stdout.String())
assert.Equal(t, "", stderr.String())
})
t.Run("passes env to command", func(t *testing.T) {
var stdout strings.Builder
var stderr strings.Builder
err = plugin.RunCallback("post-plugin-update", []string{}, map[string]string{"ASDF_PLUGIN_PREV_REF": "TEST"}, &stdout, &stderr)
assert.Nil(t, err)
assert.Equal(t, "plugin updated path= old git-ref=TEST new git-ref=\n", stdout.String())
assert.Equal(t, "", stderr.String())
})
}
func touchFile(name string) error {

3
test/fixtures/dummy_plugin/bin/debug vendored Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
echo $@

View File

@ -16,7 +16,7 @@ setup_asdf_dir() {
mkdir "$BASE_DIR"
# HOME is now defined by the Golang test code in main_test.go
HOME="$BASE_DIR/home"
HOME="$BASE_DIR"
export HOME
ASDF_DIR="$HOME/.asdf"
mkdir -p "$ASDF_DIR/plugins"