From 90ca983200e7ef625edb8e3e24d066d272b3aabb Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 09:03:38 +0000 Subject: [PATCH] fix(detect): wire up ports package for OpenBSD detection The ports package was fully implemented but detect_openbsd.go still returned nil candidates with a stale TODO. Wire up ports.New() so OpenBSD systems can auto-detect their package manager. Also adds: - Unit tests for GetCapabilities with mock managers (base + full) - Unit tests for Locker mutual exclusion - Edge case tests for ports parser (empty input, whitespace, fallback) - Minor dep updates (charmbracelet/ultraviolet, charmbracelet/x) --- capabilities_test.go | 93 ++++++++++++++++++++++++++++++++++++++++ detect/detect_openbsd.go | 12 ++++-- go.mod | 4 +- go.sum | 8 ++-- mutex_test.go | 45 +++++++++++++++++++ ports/ports_test.go | 75 ++++++++++++++++++++++++++++++++ 6 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 capabilities_test.go create mode 100644 mutex_test.go diff --git a/capabilities_test.go b/capabilities_test.go new file mode 100644 index 0000000..1efb8a0 --- /dev/null +++ b/capabilities_test.go @@ -0,0 +1,93 @@ +package snack_test + +import ( + "context" + "testing" + + "github.com/gogrlx/snack" + "github.com/stretchr/testify/assert" +) + +// mockManager is a minimal Manager implementation for testing. +type mockManager struct{} + +func (m *mockManager) Install(context.Context, []snack.Target, ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, nil +} +func (m *mockManager) Remove(context.Context, []snack.Target, ...snack.Option) (snack.RemoveResult, error) { + return snack.RemoveResult{}, nil +} +func (m *mockManager) Purge(context.Context, []snack.Target, ...snack.Option) error { return nil } +func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil } +func (m *mockManager) Update(context.Context) error { return nil } +func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil } +func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil } +func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil } +func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil } +func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil } +func (m *mockManager) Available() bool { return true } +func (m *mockManager) Name() string { return "mock" } + +// fullMockManager implements Manager plus all optional interfaces. +type fullMockManager struct { + mockManager +} + +func (m *fullMockManager) LatestVersion(context.Context, string) (string, error) { return "", nil } +func (m *fullMockManager) ListUpgrades(context.Context) ([]snack.Package, error) { return nil, nil } +func (m *fullMockManager) UpgradeAvailable(context.Context, string) (bool, error) { + return false, nil +} +func (m *fullMockManager) VersionCmp(context.Context, string, string) (int, error) { return 0, nil } +func (m *fullMockManager) Hold(context.Context, []string) error { return nil } +func (m *fullMockManager) Unhold(context.Context, []string) error { return nil } +func (m *fullMockManager) ListHeld(context.Context) ([]snack.Package, error) { return nil, nil } +func (m *fullMockManager) IsHeld(context.Context, string) (bool, error) { return false, nil } +func (m *fullMockManager) Autoremove(context.Context, ...snack.Option) error { return nil } +func (m *fullMockManager) Clean(context.Context) error { return nil } +func (m *fullMockManager) FileList(context.Context, string) ([]string, error) { return nil, nil } +func (m *fullMockManager) Owner(context.Context, string) (string, error) { return "", nil } +func (m *fullMockManager) ListRepos(context.Context) ([]snack.Repository, error) { return nil, nil } +func (m *fullMockManager) AddRepo(context.Context, snack.Repository) error { return nil } +func (m *fullMockManager) RemoveRepo(context.Context, string) error { return nil } +func (m *fullMockManager) AddKey(context.Context, string) error { return nil } +func (m *fullMockManager) RemoveKey(context.Context, string) error { return nil } +func (m *fullMockManager) ListKeys(context.Context) ([]string, error) { return nil, nil } +func (m *fullMockManager) GroupList(context.Context) ([]string, error) { return nil, nil } +func (m *fullMockManager) GroupInfo(context.Context, string) ([]snack.Package, error) { + return nil, nil +} +func (m *fullMockManager) GroupInstall(context.Context, string, ...snack.Option) error { return nil } +func (m *fullMockManager) GroupIsInstalled(context.Context, string) (bool, error) { return false, nil } +func (m *fullMockManager) NormalizeName(name string) string { return name } +func (m *fullMockManager) ParseArch(name string) (string, string) { return name, "" } +func (m *fullMockManager) SupportsDryRun() bool { return true } +func (m *fullMockManager) UpgradePackages(context.Context, []snack.Target, ...snack.Option) (snack.InstallResult, error) { + return snack.InstallResult{}, nil +} + +func TestGetCapabilities_BaseManager(t *testing.T) { + caps := snack.GetCapabilities(&mockManager{}) + assert.False(t, caps.VersionQuery) + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.FileOwnership) + assert.False(t, caps.RepoManagement) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + assert.False(t, caps.NameNormalize) + assert.False(t, caps.DryRun) +} + +func TestGetCapabilities_FullManager(t *testing.T) { + caps := snack.GetCapabilities(&fullMockManager{}) + assert.True(t, caps.VersionQuery) + assert.True(t, caps.Hold) + assert.True(t, caps.Clean) + assert.True(t, caps.FileOwnership) + assert.True(t, caps.RepoManagement) + assert.True(t, caps.KeyManagement) + assert.True(t, caps.Groups) + assert.True(t, caps.NameNormalize) + assert.True(t, caps.DryRun) +} diff --git a/detect/detect_openbsd.go b/detect/detect_openbsd.go index 982303a..47ad86f 100644 --- a/detect/detect_openbsd.go +++ b/detect/detect_openbsd.go @@ -2,12 +2,18 @@ package detect +import ( + "github.com/gogrlx/snack" + "github.com/gogrlx/snack/ports" +) + // candidates returns manager factories in probe order for OpenBSD. -// TODO: wire up ports.New() once the ports package is implemented. func candidates() []managerFactory { - return nil + return []managerFactory{ + func() snack.Manager { return ports.New() }, + } } func allManagers() []managerFactory { - return nil + return candidates() } diff --git a/go.mod b/go.mod index 192b2d9..54679a0 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,9 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20260225200202-61df8bc4b903 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index 53a047f..d4cb8cd 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,12 @@ github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= -github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM= -github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw= +github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260225200202-61df8bc4b903 h1:QKMgigHt5QxrmLPYiy2rqeupaUk3LwKwJCBVr52KPTI= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260225200202-61df8bc4b903/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= diff --git a/mutex_test.go b/mutex_test.go new file mode 100644 index 0000000..8cd087f --- /dev/null +++ b/mutex_test.go @@ -0,0 +1,45 @@ +package snack_test + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/gogrlx/snack" + "github.com/stretchr/testify/assert" +) + +func TestLocker_MutualExclusion(t *testing.T) { + var l snack.Locker + var counter int64 + var wg sync.WaitGroup + const goroutines = 50 + + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + l.Lock() + defer l.Unlock() + // If the lock works, only one goroutine touches + // the counter at a time. + cur := atomic.LoadInt64(&counter) + atomic.StoreInt64(&counter, cur+1) + }() + } + wg.Wait() + assert.Equal(t, int64(goroutines), atomic.LoadInt64(&counter)) +} + +func TestLocker_LockUnlock(t *testing.T) { + var l snack.Locker + var n int + // Should not deadlock; verifies lock/unlock cycle works twice. + l.Lock() + n++ + l.Unlock() + l.Lock() + n++ + l.Unlock() + assert.Equal(t, 2, n) +} diff --git a/ports/ports_test.go b/ports/ports_test.go index d06c309..6f2a91e 100644 --- a/ports/ports_test.go +++ b/ports/ports_test.go @@ -101,6 +101,81 @@ func TestSplitNameVersion(t *testing.T) { } } +func TestParseListEmpty(t *testing.T) { + pkgs := parseList("") + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseListWhitespaceOnly(t *testing.T) { + pkgs := parseList(" \n \n\n") + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseListNoDescription(t *testing.T) { + input := "vim-9.0.2100\n" + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.2100" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } + if pkgs[0].Description != "" { + t.Errorf("expected empty description, got %q", pkgs[0].Description) + } +} + +func TestParseSearchResultsEmpty(t *testing.T) { + pkgs := parseSearchResults("") + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseInfoOutputEmpty(t *testing.T) { + pkg := parseInfoOutput("", "") + if pkg != nil { + t.Error("expected nil for empty input and empty pkg name") + } +} + +func TestParseInfoOutputFallbackToPkgArg(t *testing.T) { + // No "Information for" header — should fall back to parsing the pkg argument. + input := "Some random output\nwithout the expected header" + pkg := parseInfoOutput(input, "curl-8.5.0") + if pkg == nil { + t.Fatal("expected non-nil package from fallback") + } + if pkg.Name != "curl" || pkg.Version != "8.5.0" { + t.Errorf("unexpected fallback parse: %+v", pkg) + } +} + +func TestSplitNameVersionNoHyphen(t *testing.T) { + name, ver := splitNameVersion("singleword") + if name != "singleword" || ver != "" { + t.Errorf("splitNameVersion(\"singleword\") = (%q, %q), want (\"singleword\", \"\")", name, ver) + } +} + +func TestSplitNameVersionLeadingHyphen(t *testing.T) { + // A hyphen at position 0 should return the whole string as name. + name, ver := splitNameVersion("-1.0") + if name != "" || ver != "1.0" { + // LastIndex("-1.0", "-") is 0, and idx <= 0 returns (s, "") + // Actually idx=0 means the condition idx <= 0 is true + } + // Re-check: idx=0, condition is idx <= 0, so returns (s, "") + name, ver = splitNameVersion("-1.0") + if name != "-1.0" || ver != "" { + t.Errorf("splitNameVersion(\"-1.0\") = (%q, %q), want (\"-1.0\", \"\")", name, ver) + } +} + func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Ports)(nil) }