From 69effa748e7db376238c9f4df78c68f0f22b95e0 Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:07:38 +0200 Subject: [PATCH 1/6] move bytes formatter to separate package --- cmd/toolset/main.go | 16 ++-------------- internal/humanize/bytes.go | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 internal/humanize/bytes.go diff --git a/cmd/toolset/main.go b/cmd/toolset/main.go index b6fd852..ac83a8a 100644 --- a/cmd/toolset/main.go +++ b/cmd/toolset/main.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/kazhuravlev/toolset/internal/humanize" "github.com/kazhuravlev/toolset/internal/timeh" "github.com/kazhuravlev/optional" @@ -679,7 +680,7 @@ func cmdInfo(_ *cli.Context, wd *workdir.Workdir) error { rows := []table.Row{ {"Version:", version}, - {"Cache Size:", humanizeBytes(info.Storage.TotalBytes)}, + {"Cache Size:", humanize.Bytes(info.Storage.TotalBytes)}, {"Cache dir:", info.Locations.CacheDir}, {"Toolset File:", info.Locations.ToolsetFile}, {"Toolset Lock File:", info.Locations.ToolsetLockFile}, @@ -747,16 +748,3 @@ func cmdEnsureModuleVersion(c *cli.Context, wd *workdir.Workdir) error { return nil } - -func humanizeBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} diff --git a/internal/humanize/bytes.go b/internal/humanize/bytes.go new file mode 100644 index 0000000..6f8d760 --- /dev/null +++ b/internal/humanize/bytes.go @@ -0,0 +1,16 @@ +package humanize + +import "fmt" + +func Bytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} From 2a96bc693132c0ab39b83ad2397d5a6ae7a47e3f Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:07:45 +0200 Subject: [PATCH 2/6] add simple test --- internal/humanize/bytes_test.go | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 internal/humanize/bytes_test.go diff --git a/internal/humanize/bytes_test.go b/internal/humanize/bytes_test.go new file mode 100644 index 0000000..edf012d --- /dev/null +++ b/internal/humanize/bytes_test.go @@ -0,0 +1,74 @@ +package humanize_test + +import ( + "testing" + + "github.com/kazhuravlev/toolset/internal/humanize" + "github.com/stretchr/testify/require" +) + +func TestHumanizeBytes(t *testing.T) { + tests := []struct { + name string + bytes int64 + want string + }{ + { + name: "zero bytes", + bytes: 0, + want: "0 B", + }, + { + name: "small bytes", + bytes: 512, + want: "512 B", + }, + { + name: "exactly 1 KB", + bytes: 1024, + want: "1.0 KB", + }, + { + name: "1.5 KB", + bytes: 1536, + want: "1.5 KB", + }, + { + name: "exactly 1 MB", + bytes: 1024 * 1024, + want: "1.0 MB", + }, + { + name: "2.5 MB", + bytes: 1024*1024*2 + 1024*512, + want: "2.5 MB", + }, + { + name: "exactly 1 GB", + bytes: 1024 * 1024 * 1024, + want: "1.0 GB", + }, + { + name: "3.7 GB", + bytes: 1024*1024*1024*3 + 1024*1024*700, + want: "3.7 GB", + }, + { + name: "exactly 1 TB", + bytes: 1024 * 1024 * 1024 * 1024, + want: "1.0 TB", + }, + { + name: "large cache size", + bytes: 5*1024*1024*1024 + 256*1024*1024, + want: "5.2 GB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := humanize.Bytes(tt.bytes) + require.Equal(t, tt.want, got) + }) + } +} From 0f7ad89e9ca49fa58fc1feacbcf4d2333565b409 Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:10:43 +0200 Subject: [PATCH 3/6] add file extension tests --- internal/fsh/helpers_test.go | 84 +++++++++++++++++++++++++++++++++ internal/humanize/bytes_test.go | 13 +---- 2 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 internal/fsh/helpers_test.go diff --git a/internal/fsh/helpers_test.go b/internal/fsh/helpers_test.go new file mode 100644 index 0000000..65a6f3e --- /dev/null +++ b/internal/fsh/helpers_test.go @@ -0,0 +1,84 @@ +package fsh_test + +import ( + "testing" + + "github.com/kazhuravlev/toolset/internal/fsh" + "github.com/stretchr/testify/require" +) + +func TestExt(t *testing.T) { + tests := []struct { + name string + filename string + exp string + }{ + { + name: "simple zip file", + filename: "archive.zip", + exp: ".zip", + }, + { + name: "tar.gz file", + filename: "archive.tar.gz", + exp: ".tar.gz", + }, + { + name: "tgz file", + filename: "archive.tgz", + exp: ".tgz", + }, + { + name: "tar.bz2 file", + filename: "archive.tar.bz2", + exp: ".tar.bz2", + }, + { + name: "tar.xz file", + filename: "archive.tar.xz", + exp: ".tar.xz", + }, + { + name: "with path", + filename: "/path/to/archive.tar.gz", + exp: ".tar.gz", + }, + { + name: "uppercase extension", + filename: "archive.TAR.GZ", + exp: ".tar.gz", + }, + { + name: "mixed case extension", + filename: "archive.Tar.Gz", + exp: ".tar.gz", + }, + { + name: "no extension", + filename: "noext", + exp: "", + }, + { + name: "hidden file with extension", + filename: ".hidden.tar.gz", + exp: ".tar.gz", + }, + { + name: "multiple dots", + filename: "my.archive.file.tar.gz", + exp: ".tar.gz", + }, + { + name: "exe file", + filename: "tool.exe", + exp: ".exe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fsh.Ext(tt.filename) + require.Equal(t, tt.exp, got) + }) + } +} diff --git a/internal/humanize/bytes_test.go b/internal/humanize/bytes_test.go index edf012d..36dbef8 100644 --- a/internal/humanize/bytes_test.go +++ b/internal/humanize/bytes_test.go @@ -9,64 +9,53 @@ import ( func TestHumanizeBytes(t *testing.T) { tests := []struct { - name string bytes int64 want string }{ { - name: "zero bytes", bytes: 0, want: "0 B", }, { - name: "small bytes", bytes: 512, want: "512 B", }, { - name: "exactly 1 KB", bytes: 1024, want: "1.0 KB", }, { - name: "1.5 KB", bytes: 1536, want: "1.5 KB", }, { - name: "exactly 1 MB", bytes: 1024 * 1024, want: "1.0 MB", }, { - name: "2.5 MB", bytes: 1024*1024*2 + 1024*512, want: "2.5 MB", }, { - name: "exactly 1 GB", bytes: 1024 * 1024 * 1024, want: "1.0 GB", }, { - name: "3.7 GB", bytes: 1024*1024*1024*3 + 1024*1024*700, want: "3.7 GB", }, { - name: "exactly 1 TB", bytes: 1024 * 1024 * 1024 * 1024, want: "1.0 TB", }, { - name: "large cache size", bytes: 5*1024*1024*1024 + 256*1024*1024, want: "5.2 GB", }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run("", func(t *testing.T) { got := humanize.Bytes(tt.bytes) require.Equal(t, tt.want, got) }) From b1fcfd568fc622d10f065639953263fe8226f569 Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:11:38 +0200 Subject: [PATCH 4/6] add parse tests --- .../runtime-github-release/private_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/workdir/runtimes/runtime-github-release/private_test.go diff --git a/internal/workdir/runtimes/runtime-github-release/private_test.go b/internal/workdir/runtimes/runtime-github-release/private_test.go new file mode 100644 index 0000000..eeb6ff4 --- /dev/null +++ b/internal/workdir/runtimes/runtime-github-release/private_test.go @@ -0,0 +1,100 @@ +package runtimegh + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Run("valid GitHub releases", func(t *testing.T) { + tests := []struct { + name string + input string + wantOwner string + wantRepo string + wantVersion string + }{ + { + name: "golangci-lint", + input: "golangci/golangci-lint@v1.61.0", + wantOwner: "golangci", + wantRepo: "golangci-lint", + wantVersion: "v1.61.0", + }, + { + name: "another tool", + input: "owner/repo@v2.5.0", + wantOwner: "owner", + wantRepo: "repo", + wantVersion: "v2.5.0", + }, + { + name: "tool with patch version", + input: "someowner/sometool@v1.2.3", + wantOwner: "someowner", + wantRepo: "sometool", + wantVersion: "v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parse(tt.input) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.wantRepo, result.Program) + require.Equal(t, tt.wantOwner+"/"+tt.wantRepo, result.Mod.Name()) + require.Equal(t, tt.wantVersion, result.Mod.Version()) + }) + } + }) + + t.Run("invalid inputs", func(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "empty string", + input: "", + wantErr: "program name not provided", + }, + { + name: "missing version", + input: "owner/repo", + wantErr: "invalid github repository: should be owner/proj@v1.2.3", + }, + { + name: "missing owner", + input: "repo@v1.0.0", + wantErr: "invalid github path: should be owner/proj", + }, + { + name: "too many slashes", + input: "owner/sub/repo@v1.0.0", + wantErr: "invalid github path: should be owner/proj", + }, + { + name: "invalid semver", + input: "owner/repo@1.0.0", + wantErr: "non-semver versions is not supported", + }, + { + name: "invalid semver format", + input: "owner/repo@latest", + wantErr: "non-semver versions is not supported", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parse(tt.input) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), tt.wantErr) + }) + } + }) +} From 0fe6a4f39c6dc342dfff078bca220142b24215af Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:28:15 +0200 Subject: [PATCH 5/6] add some more tests --- .../runtime-github-release/private_test.go | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/internal/workdir/runtimes/runtime-github-release/private_test.go b/internal/workdir/runtimes/runtime-github-release/private_test.go index eeb6ff4..a2d7531 100644 --- a/internal/workdir/runtimes/runtime-github-release/private_test.go +++ b/internal/workdir/runtimes/runtime-github-release/private_test.go @@ -1,11 +1,156 @@ package runtimegh import ( + "context" + "path/filepath" "testing" + "github.com/kazhuravlev/toolset/internal/fsh" "github.com/stretchr/testify/require" ) +func TestRuntimeVersion(t *testing.T) { + runtime := New(fsh.NewMemFS(nil), "/tmp/tools", nil) + require.Equal(t, "gh", runtime.Version()) +} + +func TestRuntimeParse(t *testing.T) { + runtime := New(fsh.NewMemFS(nil), "/tmp/tools", nil) + ctx := context.Background() + + t.Run("valid module strings", func(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "golangci-lint", + input: "golangci/golangci-lint@v1.61.0", + want: "golangci/golangci-lint@v1.61.0", + }, + { + name: "tool with different version", + input: "owner/tool@v2.3.4", + want: "owner/tool@v2.3.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := runtime.Parse(ctx, tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, result) + }) + } + }) + + t.Run("invalid module strings", func(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "empty string", + input: "", + }, + { + name: "missing version", + input: "owner/repo", + }, + { + name: "invalid semver", + input: "owner/repo@latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := runtime.Parse(ctx, tt.input) + require.Error(t, err) + require.Empty(t, result) + }) + } + }) +} + +func TestRuntimeGetModule(t *testing.T) { + t.Run("constructs correct paths for module", func(t *testing.T) { + const binToolDir = "/cache/tools" + memFS := fsh.NewMemFS(nil) + runtime := New(memFS, binToolDir, nil) + ctx := context.Background() + + tests := []struct { + name string + module string + wantName string + wantBinDir string + wantBinPath string + wantModuleS string + wantIsInstalled bool + }{ + { + name: "golangci-lint", + module: "golangci/golangci-lint@v1.61.0", + wantName: "golangci-lint", + wantBinDir: filepath.Join(binToolDir, "gh/golangci/golangci-lint@v1.61.0"), + wantBinPath: filepath.Join(binToolDir, "gh/golangci/golangci-lint@v1.61.0/golangci-lint"), + wantModuleS: "golangci/golangci-lint@v1.61.0", + wantIsInstalled: false, + }, + { + name: "different tool", + module: "owner/mytool@v2.0.0", + wantName: "mytool", + wantBinDir: filepath.Join(binToolDir, "gh/owner/mytool@v2.0.0"), + wantBinPath: filepath.Join(binToolDir, "gh/owner/mytool@v2.0.0/mytool"), + wantModuleS: "owner/mytool@v2.0.0", + wantIsInstalled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + moduleInfo, err := runtime.GetModule(ctx, tt.module) + require.NoError(t, err) + require.NotNil(t, moduleInfo) + require.Equal(t, tt.wantName, moduleInfo.Name) + require.Equal(t, tt.wantBinDir, moduleInfo.BinDir) + require.Equal(t, tt.wantBinPath, moduleInfo.BinPath) + require.Equal(t, tt.wantModuleS, moduleInfo.Mod.S()) + require.Equal(t, tt.wantIsInstalled, moduleInfo.IsInstalled) + require.False(t, moduleInfo.IsPrivate) + }) + } + }) + + t.Run("detects installed binary", func(t *testing.T) { + const binToolDir = "/cache/tools" + // Create a filesystem with the binary already present + binaryPath := filepath.Join(binToolDir, "gh/golangci/golangci-lint@v1.61.0/golangci-lint") + memFS := fsh.NewMemFS(map[string]string{ + binaryPath: "fake binary content", + }) + runtime := New(memFS, binToolDir, nil) + ctx := context.Background() + + moduleInfo, err := runtime.GetModule(ctx, "golangci/golangci-lint@v1.61.0") + require.NoError(t, err) + require.NotNil(t, moduleInfo) + require.True(t, moduleInfo.IsInstalled, "should detect existing binary") + }) + + t.Run("returns error for invalid module", func(t *testing.T) { + runtime := New(fsh.NewMemFS(nil), "/cache/tools", nil) + ctx := context.Background() + + moduleInfo, err := runtime.GetModule(ctx, "invalid-module") + require.Error(t, err) + require.Nil(t, moduleInfo) + }) +} + func TestParse(t *testing.T) { t.Run("valid GitHub releases", func(t *testing.T) { tests := []struct { From 927ac60dc0a2acfb6867c6feb9d2751529116a84 Mon Sep 17 00:00:00 2001 From: Kirill Zhuravlev Date: Mon, 20 Oct 2025 20:38:54 +0200 Subject: [PATCH 6/6] fix flaky test --- internal/fsh/real_fs_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/fsh/real_fs_test.go b/internal/fsh/real_fs_test.go index c5d5fb5..710c7e3 100644 --- a/internal/fsh/real_fs_test.go +++ b/internal/fsh/real_fs_test.go @@ -112,11 +112,20 @@ func TestLocks(t *testing.T) { fs := fsh.NewRealFS() - const filename = "/tmp/test.lock" + // Use a unique temporary file for this test + tmpDir := t.TempDir() + filename := tmpDir + "/test.lock" + + // Ensure clean state + _ = os.Remove(filename) // 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() {