diff --git a/Taskfile.yaml b/Taskfile.yaml index 6d08687..7f8d52e 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -39,6 +39,12 @@ tasks: cmds: - echo ">> Run tests..." - toolset run gotestsum + - task: "test:remove" + - task: "test:alias" + - task: "test:runtimes" + - task: "test:ensure" + - task: "test:ensure:gh" + - task: "test:other" upd:dl: - rm -rf dl @@ -53,7 +59,6 @@ tasks: dir: temp_tests cmds: - "rm .toolset.json .toolset.lock.json || true" - - "rm -rf ./bin" - "{{.TOOLSET}} init --copy-from git+https://gist.github.com/c92c40d0e4329c1c2fe9372216474cd7.git:/formatters.json" test:remove: @@ -70,23 +75,27 @@ tasks: dir: temp_tests cmds: - task: "init" - - "{{.TOOLSET}} add go github.com/vburenin/ifacemaker iface" + - "{{.TOOLSET}} add go github.com/vburenin/ifacemaker hello" - "{{.TOOLSET}} sync" - "{{.TOOLSET}} list" - - ls -lsa bin/tools | grep iface + - "{{.TOOLSET}} list | grep hello" test:runtimes: dir: temp_tests cmds: - "rm .toolset.json .toolset.lock.json || true" - - "rm -rf ./bin" - "{{.TOOLSET}} init" - "{{.TOOLSET}} runtime add go@1.22.10" + - "{{.TOOLSET}} runtime add go@1.24" + - "{{.TOOLSET}} runtime add go@1.25" + - "{{.TOOLSET}} runtime add go" + - "{{.TOOLSET}} runtime add gh" - "{{.TOOLSET}} runtime list" - - "{{.TOOLSET}} add go@1.22.10 golang.org/x/tools/cmd/goimports" + - "{{.TOOLSET}} ensure go@1.25 golang.org/x/tools/cmd/goimports" - "{{.TOOLSET}} sync" + - "{{.TOOLSET}} list" - "{{.TOOLSET}} remove goimports" - - "{{.TOOLSET}} add go golang.org/x/tools/cmd/goimports" + - "{{.TOOLSET}} ensure go@1.24 golang.org/x/tools/cmd/goimports" - "{{.TOOLSET}} sync" - "{{.TOOLSET}} remove goimports" - "{{.TOOLSET}} list" @@ -123,7 +132,7 @@ tasks: - "{{.TOOLSET}} list" - "{{.TOOLSET}} run golangci-lint version" - test: + test:other: dir: temp_tests cmds: - task: "init" diff --git a/internal/fsh/real_fs_test.go b/internal/fsh/real_fs_test.go index 710c7e3..c5d5fb5 100644 --- a/internal/fsh/real_fs_test.go +++ b/internal/fsh/real_fs_test.go @@ -112,20 +112,11 @@ func TestLocks(t *testing.T) { fs := fsh.NewRealFS() - // Use a unique temporary file for this test - tmpDir := t.TempDir() - filename := tmpDir + "/test.lock" - - // Ensure clean state - _ = os.Remove(filename) + const filename = "/tmp/test.lock" // Lock file unlock, err := fs.Lock(ctx, filename) require.NoError(t, err) - defer func() { - // Cleanup: ensure the lock is released and file is removed - _ = os.Remove(filename) - }() ch := make(chan struct{}) go func() { diff --git a/internal/version/version.go b/internal/version/version.go index 997e941..783a2c2 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -68,8 +68,10 @@ func runGo(root string) { handleSignals() - if err := cmd.Run(); err != nil { - // TODO: return the same exit status maybe. + err := cmd.Run() + if eerr, ok := err.(*exec.ExitError); ok { + os.Exit(eerr.ExitCode()) + } else if err != nil { os.Exit(1) } os.Exit(0) @@ -534,5 +536,5 @@ func dedupEnv(caseInsensitive bool, env []string) []string { func handleSignals() { // Ensure that signals intended for the child process are not handled by // this process' runtime (e.g. SIGQUIT). See issue #36976. - signal.Notify(make(chan os.Signal), signalsToIgnore...) //nolint:staticcheck + signal.Notify(make(chan os.Signal), signalsToIgnore...) } diff --git a/internal/version/versions.go b/internal/version/versions.go new file mode 100644 index 0000000..8e4571e --- /dev/null +++ b/internal/version/versions.go @@ -0,0 +1,135 @@ +package version + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "sort" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +var ( + fullRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)$`) + partialRe = regexp.MustCompile(`^(\d+)\.(\d+)$`) +) + +// ResolvePartialVersion resolves a partial version (e.g., "1.25") to the latest patch version. +// Returns the full version string without "go" prefix (e.g., "1.25.4"). +func ResolvePartialVersion(ctx context.Context, partialVer string) (string, error) { + major, minor, _, isPartial, err := parseVersion(partialVer) + if err != nil { + return "", err + } + + if !isPartial { + // Already a full version, return as-is + return strings.TrimPrefix(partialVer, "go"), nil + } + + versions, err := listAvailableVersions(ctx) + if err != nil { + return "", fmt.Errorf("list versions: %w", err) + } + + // Filter versions that match major.minor and are stable + var candidates []string + prefix := fmt.Sprintf("go%d.%d.", major, minor) + for _, v := range versions { + if !v.Stable { + continue + } + if strings.HasPrefix(v.Version, prefix) { + candidates = append(candidates, v.Version) + } + } + + if len(candidates) == 0 { + return "", fmt.Errorf("no stable versions found for go%d.%d", major, minor) + } + + // Sort versions using semver to find the latest + sort.Slice(candidates, func(i, j int) bool { + return semver.Compare(candidates[i], candidates[j]) > 0 + }) + + latest := candidates[0] + // Remove "go" prefix + return strings.TrimPrefix(latest, "go"), nil +} + +// goVersionRec represents a Go release version. +type goVersionRec struct { + Version string `json:"version"` // e.g., "go1.25.4" + Stable bool `json:"stable"` +} + +// listAvailableVersions fetches all available Go versions from golang.org. +func listAvailableVersions(ctx context.Context) ([]goVersionRec, error) { + const url = "https://go.dev/dl/?mode=json" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch versions: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var versions []goVersionRec + if err := json.Unmarshal(body, &versions); err != nil { + return nil, fmt.Errorf("parse versions: %w", err) + } + + return versions, nil +} + +// parseVersion parses a version string and returns whether it's a partial version (major.minor) +// or full version (major.minor.patch). +// Returns: major, minor, patch, isPartial +func parseVersion(ver string) (major, minor, patch int, isPartial bool, err error) { + // Remove "go" prefix if present + ver = strings.TrimPrefix(ver, "go") + + // Try to match major.minor.patch + if matches := fullRe.FindStringSubmatch(ver); matches != nil { + var majorPart, minorPart, patchPart int + fmt.Sscanf(matches[1], "%d", &majorPart) + fmt.Sscanf(matches[2], "%d", &minorPart) + fmt.Sscanf(matches[3], "%d", &patchPart) + + return majorPart, minorPart, patchPart, false, nil + } + + // Try to match major.minor + if matches := partialRe.FindStringSubmatch(ver); matches != nil { + var majorPart, minorPart int + fmt.Sscanf(matches[1], "%d", &majorPart) + fmt.Sscanf(matches[2], "%d", &minorPart) + + return majorPart, minorPart, 0, true, nil + } + + return 0, 0, 0, false, fmt.Errorf("invalid version format: %s", ver) +} diff --git a/internal/version/versions_test.go b/internal/version/versions_test.go new file mode 100644 index 0000000..f8c63b9 --- /dev/null +++ b/internal/version/versions_test.go @@ -0,0 +1,159 @@ +package version + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParseVersion(t *testing.T) { + tests := []struct { + name string + input string + wantMajor int + wantMinor int + wantPatch int + wantPart bool + wantErr bool + }{ + { + name: "full version", + input: "1.25.4", + wantMajor: 1, + wantMinor: 25, + wantPatch: 4, + wantPart: false, + wantErr: false, + }, + { + name: "full version with go prefix", + input: "go1.25.4", + wantMajor: 1, + wantMinor: 25, + wantPatch: 4, + wantPart: false, + wantErr: false, + }, + { + name: "partial version", + input: "1.25", + wantMajor: 1, + wantMinor: 25, + wantPatch: 0, + wantPart: true, + wantErr: false, + }, + { + name: "partial version with go prefix", + input: "go1.25", + wantMajor: 1, + wantMinor: 25, + wantPatch: 0, + wantPart: true, + wantErr: false, + }, + { + name: "invalid format", + input: "1.25.4.5", + wantErr: true, + }, + { + name: "invalid format - single number", + input: "1", + wantErr: true, + }, + { + name: "invalid format - non-numeric", + input: "1.x.4", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + major, minor, patch, isPartial, err := parseVersion(tt.input) + require.Equal(t, tt.wantErr, err != nil) + require.Equal(t, tt.wantMajor, major, "major version mismatch") + require.Equal(t, tt.wantMinor, minor, "minor version mismatch") + require.Equal(t, tt.wantPatch, patch, "patch version mismatch") + require.Equal(t, tt.wantPart, isPartial, "isPartial mismatch") + }) + } +} + +func TestResolvePartialVersion(t *testing.T) { + // Skip if running in CI without network access + if testing.Short() { + t.Skip("Skipping network test in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "partial version - 1.24", + input: "1.24", + wantErr: false, + }, + { + name: "partial version with go prefix", + input: "go1.24", + wantErr: false, + }, + { + name: "full version", + input: "1.24.9", + wantErr: false, + }, + { + name: "non-existent version", + input: "1.999", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolvePartialVersion(ctx, tt.input) + require.Equal(t, tt.wantErr, err != nil) + if tt.wantErr { + return + } + + // Verify result format + _, _, _, _, err = parseVersion(result) + require.NoError(t, err) + + t.Logf("Resolved %s to %s", tt.input, result) + }) + } +} + +func TestListAvailableVersions(t *testing.T) { + // Skip if running in CI without network access + if testing.Short() { + t.Skip("Skipping network test in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + versions, err := listAvailableVersions(ctx) + if err != nil { + t.Fatalf("listAvailableVersions() error = %v", err) + } + + require.NotEmpty(t, versions) + + // Check that versions have expected format + for _, v := range versions { + require.NotEmpty(t, v.Version) + } +} diff --git a/internal/workdir/runtimes/runtime-go/runtime_go.go b/internal/workdir/runtimes/runtime-go/runtime_go.go index 9039d8c..5c88fa7 100644 --- a/internal/workdir/runtimes/runtime-go/runtime_go.go +++ b/internal/workdir/runtimes/runtime-go/runtime_go.go @@ -231,12 +231,23 @@ func Discover(ctx context.Context, fSys fsh.FS, binToolDir string) ([]*Runtime, return res, nil } -func Install(ctx context.Context, fSys fsh.FS, binToolDir, ver string) error { +// Install install runtime and return installed version. Installed version can be different in case if requested version is partial like "1.25". +func Install(ctx context.Context, fSys fsh.FS, binToolDir, ver string) (string, error) { + // Resolve partial version to full version + { + resolvedVer, err := version.ResolvePartialVersion(ctx, ver) + if err != nil { + return "", fmt.Errorf("resolve version (%s): %w", ver, err) + } + + ver = resolvedVer + } + dstDir := filepath.Join(binToolDir, runtimePrefix+ver) if err := version.Install(ctx, dstDir, "go"+ver); err != nil { _ = fSys.RemoveAll(dstDir) - return fmt.Errorf("install go (%s): %w", ver, err) + return "", fmt.Errorf("install go (%s): %w", ver, err) } - return nil + return ver, nil } diff --git a/internal/workdir/runtimes/runtimes.go b/internal/workdir/runtimes/runtimes.go index 87c9497..e1ee102 100644 --- a/internal/workdir/runtimes/runtimes.go +++ b/internal/workdir/runtimes/runtimes.go @@ -15,7 +15,10 @@ import ( "github.com/kazhuravlev/toolset/internal/workdir/structs" ) -const runtimeGo = "go" +const ( + runtimeGo = "go" + runtimeGithub = "gh" +) var ErrNotFound = errors.New("not found") @@ -32,6 +35,7 @@ type IRuntime interface { Run(ctx context.Context, program string, args ...string) error GetLatest(ctx context.Context, module string) (string, bool, error) Remove(ctx context.Context, tool structs.Tool) error + Version() string } type Runtimes struct { @@ -51,7 +55,7 @@ func New(fs fsh.FS, binToolDir string) (*Runtimes, error) { func (r *Runtimes) Get(runtime string) (IRuntime, error) { rt, ok := r.impls[runtime] if !ok { - return nil, ErrNotFound + return nil, fmt.Errorf("get runtime (%s): %w", runtime, ErrNotFound) } return rt, nil @@ -59,33 +63,43 @@ func (r *Runtimes) Get(runtime string) (IRuntime, error) { // GetInstall will get installed runtime or try to install it in other case. func (r *Runtimes) GetInstall(ctx context.Context, runtime string) (IRuntime, error) { - if err := r.EnsureInstalled(ctx, runtime); err != nil { + runtime, err := r.EnsureInstalled(ctx, runtime) + if err != nil { return nil, err } return r.Get(runtime) } -func (r *Runtimes) EnsureInstalled(ctx context.Context, runtime string) error { - if _, err := r.Get(runtime); err == nil { +func (r *Runtimes) EnsureInstalled(ctx context.Context, runtime string) (string, error) { + // Check if already installed with the exact runtime string + if rt, err := r.Get(runtime); err == nil { // Already installed - return nil - } - - if !strings.HasPrefix(runtime, runtimeGo+"@") { - return fmt.Errorf("unsupported runtime: %s", runtime) + return rt.Version(), nil } - ver := strings.TrimPrefix(runtime, runtimeGo+"@") - if err := runtimego.Install(ctx, r.fs, r.binToolDir, ver); err != nil { - return fmt.Errorf("install tool runtime (%s): %w", runtime, err) + name, requestedVersion, hasPart := strings.Cut(runtime, "@") + switch name { + default: + return "", fmt.Errorf("unsupported runtime: %s", runtime) + case runtimeGo: + if !hasPart { + return "", fmt.Errorf("runtime (%s) should be specified with version", runtime) + } + + ver, err := runtimego.Install(ctx, r.fs, r.binToolDir, requestedVersion) + if err != nil { + return "", fmt.Errorf("install tool runtime (%s): %w", runtime, err) + } + + if err := r.Discover(ctx); err != nil { + return "", fmt.Errorf("discover tools: %w", err) + } + + return runtimeGo + "@" + ver, nil + case runtimeGithub: + return runtimeGithub, nil } - - if err := r.Discover(ctx); err != nil { - return fmt.Errorf("discover tools: %w", err) - } - - return nil } func (r *Runtimes) List() []string { diff --git a/internal/workdir/workdir.go b/internal/workdir/workdir.go index c910843..ea45e93 100644 --- a/internal/workdir/workdir.go +++ b/internal/workdir/workdir.go @@ -179,7 +179,7 @@ func (c *Workdir) AddInclude(ctx context.Context, source string, tags []string) } func (c *Workdir) Add(ctx context.Context, runtime, program string, alias optional.Val[string], tags []string) (bool, string, error) { - rt, err := c.runtimes.Get(runtime) + rt, err := c.runtimes.GetInstall(ctx, runtime) if err != nil { return false, "", fmt.Errorf("get runtime: %w", err) } @@ -190,7 +190,7 @@ func (c *Workdir) Add(ctx context.Context, runtime, program string, alias option } tool := structs.Tool{ - Runtime: runtime, + Runtime: rt.Version(), Module: program, Alias: alias, Tags: tags, @@ -216,7 +216,7 @@ func (c *Workdir) Ensure(ctx context.Context, runtime, program string, alias opt } tool := structs.Tool{ - Runtime: runtime, + Runtime: rt.Version(), Module: program, Alias: alias, Tags: tags, @@ -514,7 +514,7 @@ func (c *Workdir) GetTools(ctx context.Context) ([]structs.ToolState, error) { } func (c *Workdir) RuntimeAdd(ctx context.Context, runtime string) error { - if err := c.runtimes.EnsureInstalled(ctx, runtime); err != nil { + if _, err := c.runtimes.EnsureInstalled(ctx, runtime); err != nil { return fmt.Errorf("install runtime: %w", err) }