Skip to content

Commit

Permalink
Tool version reporting
Browse files Browse the repository at this point in the history
  • Loading branch information
ofalvai committed Jan 8, 2024
1 parent fb30b06 commit 9800c02
Show file tree
Hide file tree
Showing 5 changed files with 556 additions and 0 deletions.
122 changes: 122 additions & 0 deletions toolversions/toolversions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package toolversions

import (
"fmt"
"path/filepath"
"regexp"
"strings"

"github.com/bitrise-io/go-utils/v2/command"
"github.com/bitrise-io/go-utils/v2/env"
"github.com/bitrise-io/go-utils/v2/log"
)

type ToolVersionReporter interface {
// IsAvailable returns true if the tool version manager is available and actively manages the tool versions.
IsAvailable() bool

// CurrentToolVersions returns a snapshot of the currently active tools and versions.
// The returned map is keyed by tool name.
// Tool names and reported versions are implementation-specific. Tool names are normalized to lowercase.
CurrentToolVersions() (map[string]ToolVersion, error)
}

type ToolVersion struct {
Version string
IsInstalled bool
DeclaredByFile string
IsGlobal bool
}

type ASDFVersionReporter struct {
cmdLocator env.CommandLocator
cmdFactory command.Factory
logger log.Logger
userHomeDir string
}

func NewASDFVersionReporter(cmdLocator env.CommandLocator, cmdFactory command.Factory, logger log.Logger, userHomeDir string) ASDFVersionReporter {
return ASDFVersionReporter{
cmdLocator: cmdLocator,
cmdFactory: cmdFactory,
logger: logger,
userHomeDir: userHomeDir,
}
}

func (r *ASDFVersionReporter) IsAvailable() bool {
_, err := r.cmdLocator.LookPath("asdf")
if err != nil {
r.logger.Debugf("asdf not found in path")
return false
}

code, err := r.cmdFactory.Create("asdf", []string{"current"}, &command.Opts{}).RunAndReturnExitCode()
if err != nil {
r.logger.Debugf("run asdf current: %s", err)
return false
}
if code != 0 {
r.logger.Debugf("run asdf current: nonzero exit code: %d", code)
return false
}

return true
}

func (r *ASDFVersionReporter) CurrentToolVersions() (map[string]ToolVersion, error) {
cmd := r.cmdFactory.Create("asdf", []string{"current"}, &command.Opts{})
out, err := cmd.RunAndReturnTrimmedCombinedOutput()
if err != nil {
return nil, fmt.Errorf("run asdf current: %s", err)
}

asdfVersionRegexp, err := regexp.Compile(`([a-z]+)\s+(\S+)\s+(.+)`)
if err != nil {
return nil, fmt.Errorf("compile regex: %s", err)
}

toolVersions := map[string]ToolVersion{}
for _, line := range strings.Split(out, "\n") {
matches := asdfVersionRegexp.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
continue
}
captureGroups := matches[0]
if len(captureGroups) != 4 {
// Entire match + 3 capture groups
return nil, fmt.Errorf("unexpected number of matches (%d) in input: %s, matches: %s", len(matches), line, matches)
}
tool := captureGroups[1]
version := captureGroups[2]
declaredBy := captureGroups[3]

if tool == "alias" {
// Meta-tool, ignore
continue
}

if version == "______" {
// No version is set globally, ignore
continue
}

isInstalled := !strings.HasPrefix(declaredBy, "Not installed.")
var declaredByFile string
var isGlobal bool
file := filepath.Base(declaredBy)
if file != "." && isInstalled {
declaredByFile = file
isGlobal = filepath.Dir(declaredBy) == r.userHomeDir
}

toolVersions[strings.ToLower(tool)] = ToolVersion{
Version: version,
IsInstalled: isInstalled,
DeclaredByFile: declaredByFile,
IsGlobal: isGlobal,
}
}

return toolVersions, nil
}
217 changes: 217 additions & 0 deletions toolversions/toolversions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package toolversions

import (
"fmt"
"strings"
"testing"

"github.com/bitrise-io/go-utils/v2/command"
"github.com/bitrise-io/go-utils/v2/log"
"github.com/stretchr/testify/assert"
)

const validASDFOutput = `alias ______ No version is set. Run "asdf <global|shell|local> alias <version>"
flutter 3.16.1-stable /Users/bitrise/.tool-versions
golang 1.18 /Users/bitrise/Projects/steps/.tool-versions
java 17 Not installed. Run "asdf install java 17"
nodejs 19.7.0 Not installed. Run "asdf install nodejs 19.7.0"
ruby 3.1.3 /Users/bitrise/.tool-versions
python ______ No version is set. Run "asdf <global|shell|local> python <version>"`

func TestIsAvailable(t *testing.T) {
tests := []struct {
name string
systemPath string
cmdOutput string
cmdExitCode int
expected bool
}{
{
name: "asdf is available",
systemPath: "/bin:/usr/bin:/root/.asdf/bin/asdf",
cmdOutput: validASDFOutput,
cmdExitCode: 0,
expected: true,
},
{
name: "asdf is not available",
systemPath: "/bin:/usr/bin",
cmdOutput: "",
cmdExitCode: 1,
expected: false,
},
{
name: "asdf is not working",
systemPath: "/bin:/usr/bin:/root/.asdf/bin/asdf",
cmdOutput: "",
cmdExitCode: 1,
expected: false,
},

}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := log.NewLogger()
logger.EnableDebugLog(true)
r := NewASDFVersionReporter(
fakeCommandLocator{path: tt.systemPath},
fakeCommandFactory{stdout: tt.cmdOutput, exitCode: tt.cmdExitCode},
logger,
"/Users/bitrise",
)

result := r.IsAvailable()
assert.Equal(t, tt.expected, result)
})
}
}

func TestCurrentToolVersions(t *testing.T) {
tests := []struct {
name string
cmdOutput string
cmdError error
expected map[string]ToolVersion
expectErr bool
}{
{
name: "valid output",
cmdOutput: validASDFOutput,
expected: map[string]ToolVersion{
"flutter": {
Version: "3.16.1-stable",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: true,
},
"golang": {
Version: "1.18",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: false,
},
"java": {
Version: "17",
IsInstalled: false,
DeclaredByFile: "",
IsGlobal: false,
},
"nodejs": {
Version: "19.7.0",
IsInstalled: false,
DeclaredByFile: "",
IsGlobal: false,
},
"ruby": {
Version: "3.1.3",
IsInstalled: true,
DeclaredByFile: ".tool-versions",
IsGlobal: true,
},
},
expectErr: false,
},
{
name: "empty output",
cmdOutput: "",
expected: map[string]ToolVersion{},
expectErr: false,
},
{
name: "invalid output",
cmdOutput: "error",
expected: map[string]ToolVersion{},
expectErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := log.NewLogger()
logger.EnableDebugLog(true)
r := NewASDFVersionReporter(
fakeCommandLocator{path: "/root/.asdf/bin/asdf"},
fakeCommandFactory{stdout: tt.cmdOutput},
logger,
"/Users/bitrise",
)

result, err := r.CurrentToolVersions()
if tt.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}

type fakeCommandLocator struct {
path string
}

func (f fakeCommandLocator) LookPath(name string) (string, error) {
return f.path, nil
}

type fakeCommandFactory struct {
stdout string
exitCode int
}

func (f fakeCommandFactory) Create(name string, args []string, opts *command.Opts) command.Command {
return fakeCommand{
command: fmt.Sprintf("%s %s", name, strings.Join(args, " ")),
stdout: f.stdout,
exitCode: f.exitCode,
}
}

type fakeCommand struct {
command string
stdout string
stderr string
exitCode int
}

func (c fakeCommand) PrintableCommandArgs() string {
return c.command
}

func (c fakeCommand) Run() error {
if c.exitCode != 0 {
return fmt.Errorf("exit code %d", c.exitCode)
}
return nil
}

func (c fakeCommand) RunAndReturnExitCode() (int, error) {
if c.exitCode != 0 {
return c.exitCode, fmt.Errorf("exit code %d", c.exitCode)
}
return c.exitCode, nil
}

func (c fakeCommand) RunAndReturnTrimmedOutput() (string, error) {
if c.exitCode != 0 {
return "", fmt.Errorf("exit code %d", c.exitCode)
}
return c.stdout, nil
}

func (c fakeCommand) RunAndReturnTrimmedCombinedOutput() (string, error) {
if c.exitCode != 0 {
return "", fmt.Errorf("exit code %d", c.exitCode)
}
return fmt.Sprintf("%s%s", c.stdout, c.stderr), nil
}

func (c fakeCommand) Start() error {
return nil
}

func (c fakeCommand) Wait() error {
return nil
}
Loading

0 comments on commit 9800c02

Please sign in to comment.