mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-01 20:58:42 -07:00
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)
This commit is contained in:
93
capabilities_test.go
Normal file
93
capabilities_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
package detect
|
package detect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
"github.com/gogrlx/snack/ports"
|
||||||
|
)
|
||||||
|
|
||||||
// candidates returns manager factories in probe order for OpenBSD.
|
// candidates returns manager factories in probe order for OpenBSD.
|
||||||
// TODO: wire up ports.New() once the ports package is implemented.
|
|
||||||
func candidates() []managerFactory {
|
func candidates() []managerFactory {
|
||||||
return nil
|
return []managerFactory{
|
||||||
|
func() snack.Manager { return ports.New() },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allManagers() []managerFactory {
|
func allManagers() []managerFactory {
|
||||||
return nil
|
return candidates()
|
||||||
}
|
}
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -17,9 +17,9 @@ require (
|
|||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // 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/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/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
|||||||
8
go.sum
8
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/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 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
|
||||||
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
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-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
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 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
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-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE=
|
||||||
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/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 h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
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=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
|||||||
45
mutex_test.go
Normal file
45
mutex_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestInterfaceCompliance(t *testing.T) {
|
||||||
var _ snack.Manager = (*Ports)(nil)
|
var _ snack.Manager = (*Ports)(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user