30 Commits

Author SHA1 Message Date
e8b0454851 Merge pull request #41 from gogrlx/cd/fix-ci-failures
Cd/fix ci failures
2026-03-05 23:23:25 -05:00
2422655c1d Merge pull request #42 from gogrlx/cd/fix-readme-capabilities
docs: fix README capability matrix
2026-03-05 23:23:10 -05:00
4a9a2b1980 docs: fix capability matrix for dnf, flatpak, pkg
- dnf: add Hold and KeyMgmt (both implemented)
- flatpak: remove DryRun (not implemented)
- pkg: remove Hold, RepoMgmt, DryRun (not implemented)
2026-03-06 04:22:35 +00:00
1da329dfb5 docs: fix capability matrix — snap supports Clean 2026-03-06 04:22:35 +00:00
f53534ce6f docs: fix capability matrix for dnf, flatpak, pkg
- dnf: add Hold and KeyMgmt (both implemented)
- flatpak: remove DryRun (not implemented)
- pkg: remove Hold, RepoMgmt, DryRun (not implemented)
2026-03-06 04:21:51 +00:00
d30c9fec0e docs: fix capability matrix — snap supports Clean 2026-03-06 04:20:10 +00:00
98bfc56960 Merge pull request #40 from gogrlx/cd/fix-ci-failures
fix(ci): update integration tests for NameNormalizer, fix lint and Windows CI
2026-03-05 23:15:28 -05:00
c913d96de3 fix(ci): update integration tests for NameNormalizer, fix lint and Windows CI
- Update apk/pacman/snap/flatpak integration tests to assert
  NameNormalize=true (all managers now implement NameNormalizer)
- Update flatpak integration test to assert VersionQuery=true
- Remove unused aur functions: rpcInfoMulti, getAURBuildDir
- Wire AUR.Clean to call clean() for consistency
- Fix Windows CI: add shell:bash to avoid PowerShell arg splitting
  on -coverprofile=coverage-windows.out
2026-03-06 04:13:39 +00:00
ffbe0e12ba Merge branch 'cd/add-winget': add winget support for Windows 2026-03-06 03:40:39 +00:00
6237a5f23a Merge branch 'cd/fix-flatpak-build': fix ports/detect build issues 2026-03-06 03:40:37 +00:00
ac15ab5a49 feat(winget): add integration tests, CI, and README updates
- Add winget_integration_test.go for Windows integration testing
- Add Windows CI job (windows-latest) for unit + detect tests
- Add cross-compile CI matrix (windows, darwin, freebsd, openbsd)
- Update README with winget in supported managers and capability matrix
- Include Windows coverage in codecov upload
2026-03-06 03:40:13 +00:00
9e9fb1a822 ci: add Windows runner and cross-compile jobs for winget
- New 'windows' job runs unit tests on windows-latest with coverage
- New 'cross-compile' matrix job builds for windows/darwin/freebsd/openbsd
- Integration test file for winget (gated behind integration build tag)
- Windows coverage included in codecov upload
2026-03-06 03:29:47 +00:00
aed2ee8b86 feat(winget): add Windows Package Manager support
Implements the full snack.Manager interface for winget:
- Install/Remove/Purge/Upgrade via winget CLI
- Search/List/Info/IsInstalled/Version queries
- Source (repository) management via RepoManager
- Version querying via VersionQuerier
- Targeted package upgrades via PackageUpgrader
- Name normalization via NameNormalizer

All commands use --disable-interactivity, --accept-source-agreements,
and --accept-package-agreements for non-interactive operation.

Parser handles winget's fixed-width tabular output by detecting column
positions from the header/separator lines. Includes VT100 escape
sequence stripping and progress line filtering.

Windows-only via build tags; other platforms return
ErrUnsupportedPlatform. Registered in detect_windows.go as the
default Windows package manager.
2026-03-06 03:26:47 +00:00
84f9cbc9cf fix(ports,detect): remove duplicate funcs and fix windows build
ports/capabilities_openbsd.go duplicated autoremove, clean, fileList,
and owner already defined in ports_openbsd.go, breaking OpenBSD builds.

detect/detect_other.go build tag didn't exclude windows, conflicting
with detect_windows.go declarations.
2026-03-06 03:19:49 +00:00
151c657398 fix(flatpak): restore VersionQuerier functions lost in refactor
The capabilities_linux.go deletion moved VersionQuerier methods
(latestVersion, listUpgrades, upgradeAvailable, versionCmp) to
capabilities.go as receiver methods, but their platform implementations
were not added to flatpak_linux.go, causing build failure on Linux.

Also removes accidentally committed .DS_Store, .crush/, and AGENTS.md.
2026-03-06 03:15:43 +00:00
934c6610c5 feat: add Homebrew provider, implement NameNormalizer across all managers
- Add brew package for Homebrew support on macOS and Linux
- Implement NameNormalizer interface (NormalizeName, ParseArch) for all providers
- Add darwin platform detection with Homebrew as default
- Consolidate capabilities by removing separate *_linux.go/*_other.go files
- Update tests for new capability expectations
- Add comprehensive tests for AUR and brew providers
- Update README with capability matrix and modern Target API usage

💘 Generated with Crush

Assisted-by: AWS Claude Opus 4.5 via Crush <crush@charm.land>
2026-03-05 20:40:32 -05:00
724ecc866e fix(ci): update snap test caps, install Flatseal in flatpak CI
- snap integration test asserted Clean=false, but snap now implements
  Cleaner — updated to assert True
- flatpak CI job now installs Flatseal before running integration tests
  so Version/List tests have a known installed package
2026-03-06 01:14:20 +00:00
77bdcfef3a feat(ci): add goreleaser config and release workflow 2026-03-06 01:07:39 +00:00
80ea10dcff fix(detect): add stub for unsupported platforms (darwin, windows, etc.)
detect package failed to compile on non-Linux/FreeBSD/OpenBSD because
candidates() and allManagers() had no fallback. Added detect_other.go
with empty implementations.
2026-03-06 01:07:35 +00:00
85e06ffc44 docs: rewrite README with current API, capabilities table, CLI docs
- Fix usage examples to use snack.Targets() and snack.Target
- Replace construction emoji status with actual extras per provider
- Document all interfaces including PackageUpgrader and DryRunner
- Add Options section documenting all functional options
- Add full CLI command reference with examples
- Remove implementation priority (everything is implemented)
2026-03-06 01:07:35 +00:00
1fa7de6d66 feat: add PackageUpgrade to Capabilities, exhaustive detect + CLI tests
- Add PackageUpgrade field to Capabilities struct and GetCapabilities
- Add PackageUpgrade to all 11 provider capability tests
- Add pkg-upgrade to CLI detect command output
- Expand detect tests: ByName for all managers, concurrent Reset,
  HasBinary, candidates/allManagers coverage
- Add cmd/snack unit tests: targets, opts, getManager, version
- 838 tests passing, 0 failures
2026-03-06 01:07:35 +00:00
60b68060e7 test(dpkg,apk): exhaustive tests for dpkg extras, fix apk parseInfoNameVersion panic
- dpkg: add normalize_test.go with NormalizeName/ParseArch table tests
- dpkg: add capabilities, DryRunner, interface compliance, parse edge cases
- apk: fix parseInfoNameVersion panic on empty input
- apk: add empty/whitespace test cases for parseInfoNameVersion

807 tests passing, 0 failures.
2026-03-06 01:07:35 +00:00
c34b7a467c test: exhaustive unit tests for all provider-specific interfaces
Add 740 total tests (up from ~200) covering:
- Compile-time interface compliance for all providers
- GetCapabilities assertions for every provider
- Parse function edge cases: empty, malformed, single-entry, multi-entry
- apt: extract inline parse logic into testable functions (parsePolicyCandidate,
  parseUpgradeSimulation, parseHoldList, parseOwner, parseSourcesLine)
- dnf/rpm: edge cases for both dnf4 and dnf5 parsers, normalize/parseArch
- pacman/aur: parseUpgrades, parseGroupPkgSet, capabilities
- apk: parseUpgradeSimulation, parseListLine, SupportsDryRun
- flatpak/snap: semverCmp, stripNonNumeric edge cases
- pkg/ports: all parse functions with thorough edge cases

Every provider now has:
- Interface compliance checks (what it implements AND what it doesn't)
- Capabilities test via snack.GetCapabilities()
- Parse function unit tests with table-driven edge cases
2026-03-06 01:07:35 +00:00
e38a787beb feat(ports): add VersionQuerier, Cleaner, FileOwner, PackageUpgrader 2026-03-06 01:07:35 +00:00
6ba3d75258 feat(snap): add Cleaner 2026-03-06 01:07:35 +00:00
4a711f0187 feat(flatpak): add VersionQuerier 2026-03-06 01:07:35 +00:00
0b4c596fad Merge pull request #39 from gogrlx/dependabot/go_modules/github.com/cloudflare/circl-1.6.3
chore(deps): bump github.com/cloudflare/circl from 1.6.1 to 1.6.3
2026-03-05 20:04:12 -05:00
dependabot[bot]
18fabed79a chore(deps): bump github.com/cloudflare/circl from 1.6.1 to 1.6.3
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.1 to 1.6.3.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-05 23:19:53 +00:00
e1d89fa485 Merge pull request #38 from gogrlx/cd/aur-implementation
feat(aur): implement native AUR client
2026-03-05 18:09:56 -05:00
42c2e8ac05 ci: add goreleaser config and release workflow 2026-03-05 22:43:40 +00:00
89 changed files with 8475 additions and 928 deletions

View File

@@ -179,6 +179,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y flatpak
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
sudo flatpak install -y flathub com.github.tchx84.Flatseal
- name: Integration tests
run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-flatpak.out ./flatpak/
- uses: actions/upload-artifact@v4
@@ -187,10 +188,41 @@ jobs:
name: coverage-flatpak
path: coverage-flatpak.out
cross-compile:
name: Cross Compile
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, darwin, freebsd, openbsd]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build for ${{ matrix.goos }}
run: GOOS=${{ matrix.goos }} go build ./...
windows:
name: Windows (winget)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Unit tests
shell: bash
run: go test -race -coverprofile=coverage-windows.out ./winget/ ./detect/
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-windows
path: coverage-windows.out
codecov:
name: Upload Coverage
runs-on: ubuntu-latest
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak]
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak, windows, cross-compile]
if: always()
steps:
- uses: actions/checkout@v4
@@ -202,7 +234,7 @@ jobs:
run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found"
- uses: codecov/codecov-action@v5
with:
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out,coverage-windows.out
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

30
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
coverage.out
coverage.html
.DS_Store
.crush/
AGENTS.md

97
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,97 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
snapshot:
name_template: "{{ incpatch .Version }}-next"
builds:
- main: ./cmd/snack/
id: snack
binary: snack
goos:
- linux
- darwin
- freebsd
- openbsd
goarch:
- amd64
- arm64
- arm
ldflags:
- -X main.version={{.Version}}
universal_binaries:
- id: snack-universal
ids:
- snack
name_template: "snack-{{.Version}}-darwin-universal"
replace: true
archives:
- formats: tar.gz
allow_different_binary_count: true
ids:
- snack
- snack-universal
name_template: "snack-{{.Version}}-{{.Os}}-{{.Arch}}"
nfpms:
- id: snack
package_name: snack
builds: [snack]
formats: [apk, deb, rpm]
bindir: /usr/bin
description: "A unified CLI for system package managers"
maintainer: Tai Groot <tai@taigrr.com>
license: 0BSD
homepage: https://github.com/gogrlx/snack
vendor: Adatomic, Inc.
release:
github:
owner: gogrlx
name: snack
ids:
- snack
- snack-universal
draft: true
prerelease: auto
changelog:
use: github
sort: asc
abbrev: -1
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^style:"
groups:
- title: "Breaking Changes"
regexp: "^.*!:+.*$"
order: 0
- title: "Features"
regexp: "^.*feat[(\\w)]*:+.*$"
order: 1
- title: "Bug Fixes"
regexp: "^.*fix[(\\w)]*:+.*$"
order: 2
- title: "Performance"
regexp: "^.*perf[(\\w)]*:+.*$"
order: 3
- title: "Refactor"
regexp: "^.*refactor[(\\w)]*:+.*$"
order: 4
- title: "Build"
regexp: "^.*build[(\\w)]*:+.*$"
order: 5
- title: Others
order: 999

View File

@@ -13,18 +13,36 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
| Package | Manager | Platform | Status |
|---------|---------|----------|--------|
| `pacman` | pacman | Arch Linux | 🚧 |
| `aur` | AUR (makepkg) | Arch Linux | 🚧 |
| `apk` | apk-tools | Alpine Linux | 🚧 |
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | 🚧 |
| `dpkg` | dpkg | Debian/Ubuntu | 🚧 |
| `dnf` | DNF | Fedora/RHEL | 🚧 |
| `rpm` | RPM | Fedora/RHEL | 🚧 |
| `flatpak` | Flatpak | Cross-distro | 🚧 |
| `snap` | snapd | Cross-distro | 🚧 |
| `pkg` | pkg(8) | FreeBSD | 🚧 |
| `ports` | ports/packages | OpenBSD | 🚧 |
| `detect` | Auto-detection | All | 🚧 |
| `pacman` | pacman | Arch Linux | |
| `aur` | AUR (makepkg) | Arch Linux | |
| `apk` | apk-tools | Alpine Linux | |
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | |
| `dpkg` | dpkg | Debian/Ubuntu | |
| `dnf` | DNF | Fedora/RHEL | |
| `rpm` | RPM | Fedora/RHEL | |
| `flatpak` | Flatpak | Linux | |
| `snap` | snapd | Linux | |
| `brew` | Homebrew | macOS/Linux | |
| `pkg` | pkg(8) | FreeBSD | |
| `ports` | ports/packages | OpenBSD | |
| `winget` | Windows Package Manager | Windows | ✅ |
| `detect` | Auto-detection | All | ✅ |
### Capability Matrix
| Provider | VersionQuery | Hold | Clean | FileOwner | RepoMgmt | KeyMgmt | Groups | NameNorm | DryRun | PkgUpgrade |
|----------|:------------:|:----:|:-----:|:---------:|:--------:|:-------:|:------:|:--------:|:------:|:----------:|
| apt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ |
| pacman | ✅ | - | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | ✅ |
| aur | ✅ | - | ✅ | - | - | - | - | ✅ | - | ✅ |
| apk | ✅ | - | ✅ | ✅ | - | - | - | ✅ | ✅ | ✅ |
| dnf | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| flatpak | ✅ | - | ✅ | - | ✅ | - | - | ✅ | - | ✅ |
| snap | ✅ | - | ✅ | - | - | - | - | ✅ | - | ✅ |
| brew | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
| pkg | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
| ports | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
| winget | ✅ | - | - | - | ✅ | - | - | ✅ | - | ✅ |
## Install
@@ -51,7 +69,7 @@ func main() {
mgr := apt.New()
// Install a package
err := mgr.Install(ctx, []string{"nginx"}, snack.WithSudo(), snack.WithAssumeYes())
_, err := mgr.Install(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes())
if err != nil {
log.Fatal(err)
}
@@ -123,6 +141,7 @@ if caps.Hold {
4. dnf + rpm (Fedora/RHEL)
5. flatpak + snap (cross-distro)
6. pkg + ports (BSD)
7. winget (Windows)
## CLI

View File

@@ -17,9 +17,12 @@ func New() *Apk {
return &Apk{}
}
// compile-time check
var _ snack.Manager = (*Apk)(nil)
var _ snack.PackageUpgrader = (*Apk)(nil)
// Compile-time interface checks.
var (
_ snack.Manager = (*Apk)(nil)
_ snack.PackageUpgrader = (*Apk)(nil)
_ snack.NameNormalizer = (*Apk)(nil)
)
// Name returns "apk".
func (a *Apk) Name() string { return "apk" }
@@ -93,3 +96,13 @@ func (a *Apk) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...
defer a.Unlock()
return upgradePackages(ctx, pkgs, opts...)
}
// NormalizeName returns the canonical form of a package name.
func (a *Apk) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (a *Apk) ParseArch(name string) (string, string) {
return parseArchNormalize(name)
}

View File

@@ -29,7 +29,7 @@ func TestIntegration_Apk(t *testing.T) {
assert.False(t, caps.RepoManagement, "apk should not support RepoManagement")
assert.False(t, caps.KeyManagement, "apk should not support KeyManagement")
assert.False(t, caps.Groups, "apk should not support Groups")
assert.False(t, caps.NameNormalize, "apk should not support NameNormalize")
assert.True(t, caps.NameNormalize, "apk should support NameNormalize")
t.Run("Update", func(t *testing.T) {
require.NoError(t, mgr.Update(ctx))

View File

@@ -17,13 +17,23 @@ func TestSplitNameVersion(t *testing.T) {
{"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"},
{"go-1.21.5-r0", "go", "1.21.5-r0"},
{"noversion", "noversion", ""},
// Edge cases
{"", "", ""},
{"a-1", "a", "1"},
{"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"},
{"a-b-c-3.0", "a-b-c", "3.0"},
{"single", "single", ""},
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
{"pkg-0", "pkg", "0"},
}
for _, tt := range tests {
name, ver := splitNameVersion(tt.input)
if name != tt.name || ver != tt.version {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.name, tt.version)
}
t.Run(tt.input, func(t *testing.T) {
name, ver := splitNameVersion(tt.input)
if name != tt.name || ver != tt.version {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.name, tt.version)
}
})
}
}
@@ -46,6 +56,111 @@ musl-1.2.4-r2 x86_64 {musl} (MIT) [installed]
}
}
func TestParseListInstalledEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseListInstalled("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("whitespace only", func(t *testing.T) {
pkgs := parseListInstalled(" \n \n ")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single package", func(t *testing.T) {
pkgs := parseListInstalled("busybox-1.36.1-r5 x86_64 {busybox} (GPL-2.0-only) [installed]\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "busybox" || pkgs[0].Version != "1.36.1-r5" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
})
t.Run("not installed", func(t *testing.T) {
pkgs := parseListInstalled("curl-8.5.0-r0 x86_64 {curl} (MIT)\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Installed {
t.Error("expected Installed=false")
}
})
}
func TestParseListLine(t *testing.T) {
tests := []struct {
name string
line string
wantName string
wantVer string
wantArch string
installed bool
}{
{
name: "full line",
line: "curl-8.5.0-r0 x86_64 {curl} (MIT) [installed]",
wantName: "curl",
wantVer: "8.5.0-r0",
wantArch: "x86_64",
installed: true,
},
{
name: "no installed marker",
line: "vim-9.0-r0 x86_64 {vim} (Vim)",
wantName: "vim",
wantVer: "9.0-r0",
wantArch: "x86_64",
installed: false,
},
{
name: "name only",
line: "curl-8.5.0-r0",
wantName: "curl",
wantVer: "8.5.0-r0",
wantArch: "",
installed: false,
},
{
name: "empty line",
line: "",
wantName: "",
wantVer: "",
wantArch: "",
installed: false,
},
{
name: "aarch64 arch",
line: "openssl-3.1.4-r0 aarch64 {openssl} (Apache-2.0) [installed]",
wantName: "openssl",
wantVer: "3.1.4-r0",
wantArch: "aarch64",
installed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkg := parseListLine(tt.line)
if pkg.Name != tt.wantName {
t.Errorf("Name = %q, want %q", pkg.Name, tt.wantName)
}
if pkg.Version != tt.wantVer {
t.Errorf("Version = %q, want %q", pkg.Version, tt.wantVer)
}
if pkg.Arch != tt.wantArch {
t.Errorf("Arch = %q, want %q", pkg.Arch, tt.wantArch)
}
if pkg.Installed != tt.installed {
t.Errorf("Installed = %v, want %v", pkg.Installed, tt.installed)
}
})
}
}
func TestParseSearch(t *testing.T) {
// verbose output
output := `curl-8.5.0-r0 - URL retrieval utility and library
@@ -76,6 +191,51 @@ curl-doc-8.5.0-r0
}
}
func TestParseSearchEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single result verbose", func(t *testing.T) {
pkgs := parseSearch("nginx-1.24.0-r0 - HTTP and reverse proxy server\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("expected nginx, got %q", pkgs[0].Name)
}
if pkgs[0].Description != "HTTP and reverse proxy server" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
t.Run("single result plain", func(t *testing.T) {
pkgs := parseSearch("nginx-1.24.0-r0\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0-r0" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("description with hyphens", func(t *testing.T) {
pkgs := parseSearch("git-2.43.0-r0 - Distributed version control system - fast\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "Distributed version control system - fast" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
}
func TestParseInfo(t *testing.T) {
output := `curl-8.5.0-r0 installed size:
description: URL retrieval utility and library
@@ -94,11 +254,84 @@ webpage: https://curl.se/
}
}
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkg := parseInfo("")
if pkg == nil {
t.Fatal("expected non-nil (parseInfo always returns a pkg)")
}
})
t.Run("no description", func(t *testing.T) {
pkg := parseInfo("arch: aarch64\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Arch != "aarch64" {
t.Errorf("expected aarch64, got %q", pkg.Arch)
}
if pkg.Description != "" {
t.Errorf("expected empty description, got %q", pkg.Description)
}
})
t.Run("multiple colons in value", func(t *testing.T) {
pkg := parseInfo("description: A tool: does things: really well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
// Note: strings.Cut splits on first colon only
if pkg.Description != "A tool: does things: really well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseInfoNameVersion(t *testing.T) {
output := "curl-8.5.0-r0 description:\nsome stuff"
name, ver := parseInfoNameVersion(output)
if name != "curl" || ver != "8.5.0-r0" {
t.Errorf("got (%q, %q), want (curl, 8.5.0-r0)", name, ver)
tests := []struct {
name string
input string
wantN string
wantV string
}{
{
name: "standard",
input: "curl-8.5.0-r0 description:\nsome stuff",
wantN: "curl",
wantV: "8.5.0-r0",
},
{
name: "single line no version",
input: "noversion",
wantN: "noversion",
wantV: "",
},
{
name: "multi-hyphen name",
input: "lib-ssl-dev-3.0.0-r0 some text",
wantN: "lib-ssl-dev",
wantV: "3.0.0-r0",
},
{
name: "empty",
input: "",
wantN: "",
wantV: "",
},
{
name: "whitespace_only",
input: " ",
wantN: "",
wantV: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ver := parseInfoNameVersion(tt.input)
if name != tt.wantN || ver != tt.wantV {
t.Errorf("got (%q, %q), want (%q, %q)", name, ver, tt.wantN, tt.wantV)
}
})
}
}
@@ -112,3 +345,151 @@ func TestName(t *testing.T) {
t.Errorf("expected apk, got %q", a.Name())
}
}
// Compile-time interface compliance checks
var (
_ snack.VersionQuerier = (*Apk)(nil)
_ snack.Cleaner = (*Apk)(nil)
_ snack.FileOwner = (*Apk)(nil)
_ snack.DryRunner = (*Apk)(nil)
_ snack.PackageUpgrader = (*Apk)(nil)
)
func TestInterfaceCompliance(t *testing.T) {
// Verify at test time as well
var m snack.Manager = New()
if _, ok := m.(snack.VersionQuerier); !ok {
t.Error("Apk should implement VersionQuerier")
}
if _, ok := m.(snack.Cleaner); !ok {
t.Error("Apk should implement Cleaner")
}
if _, ok := m.(snack.FileOwner); !ok {
t.Error("Apk should implement FileOwner")
}
if _, ok := m.(snack.DryRunner); !ok {
t.Error("Apk should implement DryRunner")
}
if _, ok := m.(snack.PackageUpgrader); !ok {
t.Error("Apk should implement PackageUpgrader")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
if !caps.DryRun {
t.Error("expected DryRun=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestSupportsDryRun(t *testing.T) {
a := New()
if !a.SupportsDryRun() {
t.Error("SupportsDryRun() should return true")
}
}
func TestParseUpgradeSimulation(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty",
input: "",
wantLen: 0,
},
{
name: "OK only",
input: "OK: 123 MiB in 45 packages\n",
wantLen: 0,
},
{
name: "single upgrade",
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "curl", Version: "8.6.0-r0", Installed: true},
},
},
{
name: "multiple upgrades",
input: `(1/3) Upgrading musl (1.2.4-r2 -> 1.2.5-r0)
(2/3) Upgrading openssl (3.1.4-r0 -> 3.2.0-r0)
(3/3) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)
OK: 123 MiB in 45 packages
`,
wantLen: 3,
wantPkgs: []snack.Package{
{Name: "musl", Version: "1.2.5-r0", Installed: true},
{Name: "openssl", Version: "3.2.0-r0", Installed: true},
{Name: "curl", Version: "8.6.0-r0", Installed: true},
},
},
{
name: "non-upgrade lines only",
input: "Purging old package\nInstalling new-pkg\n",
wantLen: 0,
},
{
name: "upgrade without version parens",
input: "(1/1) Upgrading busybox\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "busybox", Version: "", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgradeSimulation(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
if pkgs[i].Name != want.Name {
t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, want.Name)
}
if pkgs[i].Version != want.Version {
t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, want.Version)
}
if pkgs[i].Installed != want.Installed {
t.Errorf("pkg[%d].Installed = %v, want %v", i, pkgs[i].Installed, want.Installed)
}
}
})
}
}

View File

@@ -11,6 +11,7 @@ var (
_ snack.VersionQuerier = (*Apk)(nil)
_ snack.Cleaner = (*Apk)(nil)
_ snack.FileOwner = (*Apk)(nil)
_ snack.NameNormalizer = (*Apk)(nil)
_ snack.DryRunner = (*Apk)(nil)
)

View File

@@ -44,45 +44,6 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
return parseUpgradeSimulation(string(out)), nil
}
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
func parseUpgradeSimulation(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "Upgrading") {
continue
}
// "(1/3) Upgrading pkg (oldver -> newver)"
idx := strings.Index(line, "Upgrading ")
if idx < 0 {
continue
}
rest := line[idx+len("Upgrading "):]
// "pkg (oldver -> newver)"
parts := strings.SplitN(rest, " (", 2)
if len(parts) < 1 {
continue
}
name := strings.TrimSpace(parts[0])
var ver string
if len(parts) == 2 {
// "oldver -> newver)"
verPart := strings.TrimSuffix(parts[1], ")")
arrow := strings.Split(verPart, " -> ")
if len(arrow) == 2 {
ver = strings.TrimSpace(arrow[1])
}
}
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
Installed: true,
})
}
return pkgs
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {

38
apk/normalize.go Normal file
View File

@@ -0,0 +1,38 @@
package apk
import "strings"
// normalizeName returns the canonical form of a package name.
// Alpine package names sometimes include version suffixes in queries.
// This strips common version patterns.
func normalizeName(name string) string {
n, _ := parseArchNormalize(name)
return n
}
// parseArchNormalize extracts the architecture from a package name if present.
// Alpine package names typically don't embed architecture in the package name,
// but some query outputs may include it. Common patterns:
// - package-x86_64
// - package-aarch64
func parseArchNormalize(name string) (string, string) {
knownArchs := map[string]bool{
"x86_64": true,
"x86": true,
"aarch64": true,
"armhf": true,
"armv7": true,
"ppc64le": true,
"s390x": true,
"riscv64": true,
"loongarch64": true,
}
if idx := strings.LastIndex(name, "-"); idx >= 0 {
suffix := name[idx+1:]
if knownArchs[suffix] {
return name[:idx], suffix
}
}
return name, ""
}

View File

@@ -127,10 +127,51 @@ func parseInfo(output string) *snack.Package {
// The first line is typically "pkgname-version description".
func parseInfoNameVersion(output string) (string, string) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
if len(lines) == 0 || lines[0] == "" {
return "", ""
}
// first line: name-version
first := strings.Fields(lines[0])[0]
return splitNameVersion(first)
fields := strings.Fields(lines[0])
if len(fields) == 0 {
return "", ""
}
return splitNameVersion(fields[0])
}
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
func parseUpgradeSimulation(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "Upgrading") {
continue
}
// "(1/3) Upgrading pkg (oldver -> newver)"
idx := strings.Index(line, "Upgrading ")
if idx < 0 {
continue
}
rest := line[idx+len("Upgrading "):]
// "pkg (oldver -> newver)"
parts := strings.SplitN(rest, " (", 2)
if len(parts) < 1 {
continue
}
name := strings.TrimSpace(parts[0])
var ver string
if len(parts) == 2 {
// "oldver -> newver)"
verPart := strings.TrimSuffix(parts[1], ")")
arrow := strings.Split(verPart, " -> ")
if len(arrow) == 2 {
ver = strings.TrimSpace(arrow[1])
}
}
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
Installed: true,
})
}
return pkgs
}

View File

@@ -67,5 +67,50 @@ func TestNew(t *testing.T) {
}
}
// Verify Apt implements snack.Manager at compile time.
var _ snack.Manager = (*Apt)(nil)
func TestSupportsDryRun(t *testing.T) {
a := New()
if !a.SupportsDryRun() {
t.Error("expected SupportsDryRun() = true")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
checks := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Hold", caps.Hold, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, true},
{"RepoManagement", caps.RepoManagement, true},
{"KeyManagement", caps.KeyManagement, true},
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, true},
{"DryRun", caps.DryRun, true},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
if c.got != c.want {
t.Errorf("Capabilities.%s = %v, want %v", c.name, c.got, c.want)
}
})
}
}
// Compile-time interface checks.
var (
_ snack.Manager = (*Apt)(nil)
_ snack.VersionQuerier = (*Apt)(nil)
_ snack.Holder = (*Apt)(nil)
_ snack.Cleaner = (*Apt)(nil)
_ snack.FileOwner = (*Apt)(nil)
_ snack.RepoManager = (*Apt)(nil)
_ snack.KeyManager = (*Apt)(nil)
_ snack.NameNormalizer = (*Apt)(nil)
_ snack.DryRunner = (*Apt)(nil)
_ snack.PackageUpgrader = (*Apt)(nil)
)

View File

@@ -23,17 +23,11 @@ func latestVersion(ctx context.Context, pkg string) (string, error) {
if err != nil {
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Candidate:") {
candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if candidate == "(none)" {
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
}
return candidate, nil
}
candidate := parsePolicyCandidate(string(out))
if candidate == "" {
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
return candidate, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
@@ -45,38 +39,7 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
if err != nil {
return nil, fmt.Errorf("apt-get --just-print upgrade: %w", err)
}
var pkgs []snack.Package
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
// Lines starting with "Inst " indicate upgradable packages.
// Format: "Inst pkg [old-ver] (new-ver repo [arch])"
if !strings.HasPrefix(line, "Inst ") {
continue
}
line = strings.TrimPrefix(line, "Inst ")
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := fields[0]
// Find the new version in parentheses
parenStart := strings.Index(line, "(")
parenEnd := strings.Index(line, ")")
if parenStart < 0 || parenEnd < 0 {
continue
}
verFields := strings.Fields(line[parenStart+1 : parenEnd])
if len(verFields) < 1 {
continue
}
p := snack.Package{
Name: name,
Version: verFields[0],
Installed: true,
}
pkgs = append(pkgs, p)
}
return pkgs, nil
return parseUpgradeSimulation(string(out)), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
@@ -85,19 +48,12 @@ func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
if err != nil {
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
}
var installed, candidate string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Installed:") {
installed = strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
} else if strings.HasPrefix(line, "Candidate:") {
candidate = strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
}
}
if installed == "(none)" || installed == "" {
installed := parsePolicyInstalled(string(out))
candidate := parsePolicyCandidate(string(out))
if installed == "" {
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled)
}
if candidate == "(none)" || candidate == "" || candidate == installed {
if candidate == "" || candidate == installed {
return false, nil
}
return true, nil
@@ -148,15 +104,7 @@ func listHeld(ctx context.Context) ([]snack.Package, error) {
if err != nil {
return nil, fmt.Errorf("apt-mark showhold: %w", err)
}
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, snack.Package{Name: line, Installed: true})
}
return pkgs, nil
return parseHoldList(string(out)), nil
}
func isHeld(ctx context.Context, pkg string) (bool, error) {
@@ -198,14 +146,7 @@ func fileList(ctx context.Context, pkg string) ([]string, error) {
}
return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files, nil
return parseFileList(string(out)), nil
}
func owner(ctx context.Context, path string) (string, error) {
@@ -216,18 +157,11 @@ func owner(ctx context.Context, path string) (string, error) {
if err != nil {
return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound)
}
// Output format: "package: /path/to/file" or "package1, package2: /path"
line := strings.TrimSpace(strings.Split(string(out), "\n")[0])
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
pkg := parseOwner(string(out))
if pkg == "" {
return "", fmt.Errorf("dpkg -S %s: unexpected output", path)
}
// Return first package if multiple
pkgPart := line[:colonIdx]
if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 {
pkgPart = strings.TrimSpace(pkgPart[:commaIdx])
}
return strings.TrimSpace(pkgPart), nil
return pkg, nil
}
// --- RepoManager ---
@@ -249,51 +183,14 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
}
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
enabled := true
// deb822 format (.sources files) not fully parsed; treat as single entry
if strings.HasPrefix(line, "deb ") || strings.HasPrefix(line, "deb-src ") {
repos = append(repos, snack.Repository{
ID: line,
URL: extractURL(line),
Enabled: enabled,
Type: strings.Fields(line)[0],
})
if r := parseSourcesLine(scanner.Text()); r != nil {
repos = append(repos, *r)
}
}
}
return repos, nil
}
// extractURL pulls the URL from a deb/deb-src line.
func extractURL(line string) string {
fields := strings.Fields(line)
inOptions := false
for i, f := range fields {
if i == 0 {
continue // skip deb/deb-src
}
if inOptions {
if strings.HasSuffix(f, "]") {
inOptions = false
}
continue
}
if strings.HasPrefix(f, "[") {
if strings.HasSuffix(f, "]") {
// Single-token options like [arch=amd64]
continue
}
inOptions = true
continue
}
return f
}
return ""
}
func addRepo(ctx context.Context, repo snack.Repository) error {
repoLine := repo.URL

View File

@@ -150,50 +150,3 @@ func TestBuildArgs(t *testing.T) {
})
}
}
func TestExtractURL(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "with_options",
input: "deb [arch=amd64] https://apt.example.com/repo stable main",
want: "https://apt.example.com/repo",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main",
want: "https://repo.example.com/deb",
},
{
name: "just_type",
input: "deb",
want: "",
},
{
name: "empty_options_bracket",
input: "deb [] http://example.com/repo stable",
want: "http://example.com/repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractURL(tt.input)
if got != tt.want {
t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View File

@@ -51,6 +51,158 @@ func parseSearch(output string) []snack.Package {
return pkgs
}
// parsePolicyCandidate extracts the Candidate version from apt-cache policy output.
// Returns empty string if no candidate is found or candidate is "(none)".
func parsePolicyCandidate(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Candidate:") {
candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if candidate == "(none)" {
return ""
}
return candidate
}
}
return ""
}
// parsePolicyInstalled extracts the Installed version from apt-cache policy output.
// Returns empty string if not installed or "(none)".
func parsePolicyInstalled(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Installed:") {
installed := strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
if installed == "(none)" {
return ""
}
return installed
}
}
return ""
}
// parseUpgradeSimulation parses apt-get --just-print upgrade output.
// Lines starting with "Inst " indicate upgradable packages.
// Format: "Inst pkg [old-ver] (new-ver repo [arch])"
func parseUpgradeSimulation(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Inst ") {
continue
}
line = strings.TrimPrefix(line, "Inst ")
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := fields[0]
parenStart := strings.Index(line, "(")
parenEnd := strings.Index(line, ")")
if parenStart < 0 || parenEnd < 0 {
continue
}
verFields := strings.Fields(line[parenStart+1 : parenEnd])
if len(verFields) < 1 {
continue
}
pkgs = append(pkgs, snack.Package{
Name: name,
Version: verFields[0],
Installed: true,
})
}
return pkgs
}
// parseHoldList parses apt-mark showhold output (one package name per line).
func parseHoldList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, snack.Package{Name: line, Installed: true})
}
return pkgs
}
// parseFileList parses dpkg-query -L output (one file path per line).
func parseFileList(output string) []string {
var files []string
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files
}
// parseOwner parses dpkg -S output to extract the owning package name.
// Output format: "package: /path/to/file" or "pkg1, pkg2: /path".
// Returns the first package name.
func parseOwner(output string) string {
line := strings.TrimSpace(strings.Split(output, "\n")[0])
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
return ""
}
pkgPart := line[:colonIdx]
if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 {
pkgPart = strings.TrimSpace(pkgPart[:commaIdx])
}
return strings.TrimSpace(pkgPart)
}
// parseSourcesLine parses a single deb/deb-src line from sources.list.
// Returns a Repository if the line is valid, or nil if it's a comment/blank.
func parseSourcesLine(line string) *snack.Repository {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return nil
}
if !strings.HasPrefix(line, "deb ") && !strings.HasPrefix(line, "deb-src ") {
return nil
}
return &snack.Repository{
ID: line,
URL: extractURL(line),
Enabled: true,
Type: strings.Fields(line)[0],
}
}
// extractURL pulls the URL from a deb/deb-src line.
func extractURL(line string) string {
fields := strings.Fields(line)
inOptions := false
for i, f := range fields {
if i == 0 {
continue // skip deb/deb-src
}
if inOptions {
if strings.HasSuffix(f, "]") {
inOptions = false
}
continue
}
if strings.HasPrefix(f, "[") {
if strings.HasSuffix(f, "]") {
// Single-token options like [arch=amd64]
continue
}
inOptions = true
continue
}
return f
}
return ""
}
// parseInfo parses apt-cache show output into a Package.
func parseInfo(output string) (*snack.Package, error) {
p := &snack.Package{}

View File

@@ -194,3 +194,445 @@ func TestParseInfo_EdgeCases(t *testing.T) {
}
})
}
// --- New parse function tests ---
func TestParsePolicyCandidate(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "normal_policy_output",
input: `bash:
Installed: 5.2-1
Candidate: 5.2-2
Version table:
5.2-2 500
500 http://archive.ubuntu.com/ubuntu jammy/main amd64 Packages
*** 5.2-1 100
100 /var/lib/dpkg/status`,
want: "5.2-2",
},
{
name: "candidate_none",
input: `virtual-pkg:
Installed: (none)
Candidate: (none)
Version table:`,
want: "",
},
{
name: "empty_input",
input: "",
want: "",
},
{
name: "installed_equals_candidate",
input: `curl:
Installed: 7.88.1-10+deb12u4
Candidate: 7.88.1-10+deb12u4
Version table:
*** 7.88.1-10+deb12u4 500`,
want: "7.88.1-10+deb12u4",
},
{
name: "epoch_version",
input: `systemd:
Installed: 1:252-2
Candidate: 1:252-3
Version table:`,
want: "1:252-3",
},
{
name: "no_candidate_line",
input: "bash:\n Installed: 5.2-1\n",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePolicyCandidate(tt.input)
if got != tt.want {
t.Errorf("parsePolicyCandidate() = %q, want %q", got, tt.want)
}
})
}
}
func TestParsePolicyInstalled(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "normal",
input: `bash:
Installed: 5.2-1
Candidate: 5.2-2`,
want: "5.2-1",
},
{
name: "not_installed",
input: `foo:
Installed: (none)
Candidate: 1.0`,
want: "",
},
{
name: "empty",
input: "",
want: "",
},
{
name: "epoch_version",
input: `systemd:
Installed: 1:252-2
Candidate: 1:252-3`,
want: "1:252-2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parsePolicyInstalled(tt.input)
if got != tt.want {
t.Errorf("parsePolicyInstalled() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseUpgradeSimulation(t *testing.T) {
tests := []struct {
name string
input string
want []snack.Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single_upgrade",
input: `Reading package lists...
Building dependency tree...
Reading state information...
The following packages will be upgraded:
bash
1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])`,
want: []snack.Package{
{Name: "bash", Version: "5.2-2", Installed: true},
},
},
{
name: "multiple_upgrades",
input: `Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
Inst curl [7.88.0] (7.88.1 Ubuntu:22.04/jammy [amd64])
Inst systemd [1:252-1] (1:252-2 Ubuntu:22.04/jammy [amd64])
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])
Conf curl (7.88.1 Ubuntu:22.04/jammy [amd64])
Conf systemd (1:252-2 Ubuntu:22.04/jammy [amd64])`,
want: []snack.Package{
{Name: "bash", Version: "5.2-2", Installed: true},
{Name: "curl", Version: "7.88.1", Installed: true},
{Name: "systemd", Version: "1:252-2", Installed: true},
},
},
{
name: "no_inst_lines",
input: "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed.\n",
want: nil,
},
{
name: "inst_without_parens",
input: "Inst bash no-parens-here\n",
want: nil,
},
{
name: "inst_with_empty_parens",
input: "Inst bash [5.2-1] ()\n",
want: nil,
},
{
name: "conf_lines_ignored",
input: "Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])\n",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseUpgradeSimulation(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("parseUpgradeSimulation() returned %d packages, want %d", len(got), len(tt.want))
}
for i, g := range got {
w := tt.want[i]
if g.Name != w.Name || g.Version != w.Version || g.Installed != w.Installed {
t.Errorf("package[%d] = %+v, want %+v", i, g, w)
}
}
})
}
}
func TestParseHoldList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"empty", "", nil},
{"whitespace_only", " \n \n", nil},
{"single_package", "bash\n", []string{"bash"}},
{"multiple_packages", "bash\ncurl\nnginx\n", []string{"bash", "curl", "nginx"}},
{"blank_lines_mixed", "\nbash\n\ncurl\n\n", []string{"bash", "curl"}},
{"trailing_whitespace", " bash \n curl \n", []string{"bash", "curl"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseHoldList(tt.input)
var names []string
for _, p := range pkgs {
names = append(names, p.Name)
if !p.Installed {
t.Errorf("expected Installed=true for %q", p.Name)
}
}
if len(names) != len(tt.want) {
t.Fatalf("got %d packages, want %d", len(names), len(tt.want))
}
for i, n := range names {
if n != tt.want[i] {
t.Errorf("package[%d] = %q, want %q", i, n, tt.want[i])
}
}
})
}
}
func TestParseFileList(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"empty", "", nil},
{"whitespace_only", " \n\n ", nil},
{
name: "single_file",
input: "/usr/bin/bash\n",
want: []string{"/usr/bin/bash"},
},
{
name: "multiple_files",
input: "/.\n/usr\n/usr/bin\n/usr/bin/bash\n/usr/share/man/man1/bash.1.gz\n",
want: []string{"/.", "/usr", "/usr/bin", "/usr/bin/bash", "/usr/share/man/man1/bash.1.gz"},
},
{
name: "blank_lines_mixed",
input: "\n/usr/bin/curl\n\n/usr/share/doc/curl\n\n",
want: []string{"/usr/bin/curl", "/usr/share/doc/curl"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFileList(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("parseFileList() returned %d files, want %d", len(got), len(tt.want))
}
for i, f := range got {
if f != tt.want[i] {
t.Errorf("file[%d] = %q, want %q", i, f, tt.want[i])
}
}
})
}
}
func TestParseOwner(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "single_package",
input: "bash: /usr/bin/bash\n",
want: "bash",
},
{
name: "multiple_packages",
input: "bash, dash: /usr/bin/sh\n",
want: "bash",
},
{
name: "package_with_arch",
input: "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n",
want: "libc6", // parseOwner splits on first colon, arch suffix is stripped
},
{
name: "multiple_lines",
input: "coreutils: /usr/bin/ls\ncoreutils: /usr/bin/cat\n",
want: "coreutils",
},
{
name: "no_colon",
input: "unexpected output without colon",
want: "",
},
{
name: "empty",
input: "",
want: "",
},
{
name: "whitespace_around_package",
input: " nginx : /usr/sbin/nginx\n",
want: "nginx",
},
{
name: "three_packages_comma_separated",
input: "pkg1, pkg2, pkg3: /some/path\n",
want: "pkg1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwner(tt.input)
if got != tt.want {
t.Errorf("parseOwner() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseSourcesLine(t *testing.T) {
tests := []struct {
name string
input string
wantNil bool
wantURL string
wantTyp string
}{
{
name: "empty",
input: "",
wantNil: true,
},
{
name: "comment",
input: "# This is a comment",
wantNil: true,
},
{
name: "non_deb_line",
input: "some random text",
wantNil: true,
},
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
wantURL: "http://archive.ubuntu.com/ubuntu/",
wantTyp: "deb",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
wantURL: "http://archive.ubuntu.com/ubuntu/",
wantTyp: "deb-src",
},
{
name: "with_options",
input: "deb [arch=amd64] https://repo.example.com/deb stable main",
wantURL: "https://repo.example.com/deb",
wantTyp: "deb",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com stable main",
wantURL: "https://repo.example.com",
wantTyp: "deb",
},
{
name: "leading_whitespace",
input: " deb http://example.com/repo stable main",
wantURL: "http://example.com/repo",
wantTyp: "deb",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := parseSourcesLine(tt.input)
if tt.wantNil {
if r != nil {
t.Errorf("parseSourcesLine() = %+v, want nil", r)
}
return
}
if r == nil {
t.Fatal("parseSourcesLine() = nil, want non-nil")
}
if r.URL != tt.wantURL {
t.Errorf("URL = %q, want %q", r.URL, tt.wantURL)
}
if r.Type != tt.wantTyp {
t.Errorf("Type = %q, want %q", r.Type, tt.wantTyp)
}
if !r.Enabled {
t.Error("expected Enabled=true")
}
})
}
}
func TestExtractURL(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "basic_deb",
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "deb_src",
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
want: "http://archive.ubuntu.com/ubuntu/",
},
{
name: "with_options",
input: "deb [arch=amd64] https://apt.example.com/repo stable main",
want: "https://apt.example.com/repo",
},
{
name: "with_signed_by",
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main",
want: "https://repo.example.com/deb",
},
{
name: "just_type",
input: "deb",
want: "",
},
{
name: "empty_options_bracket",
input: "deb [] http://example.com/repo stable",
want: "http://example.com/repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractURL(tt.input)
if got != tt.want {
t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View File

@@ -1,10 +1,5 @@
// Package aur provides a native Go client for the Arch User Repository.
//
// Unlike other snack backends that wrap CLI tools, aur uses the AUR RPC API
// directly for queries and git+makepkg for building. Packages are built in
// a temporary directory and installed via pacman -U.
//
// Requirements: git, makepkg, pacman (all present on any Arch Linux system).
// Package aur provides Go bindings for AUR (Arch User Repository) package building.
// AUR packages are built from source using makepkg.
package aur
import (
@@ -13,105 +8,91 @@ import (
"github.com/gogrlx/snack"
)
// AUR wraps the Arch User Repository using its RPC API and makepkg.
// AUR wraps makepkg and AUR helper tools for building packages from the AUR.
type AUR struct {
snack.Locker
// BuildDir is the base directory for cloning and building packages.
// If empty, a temporary directory is created per build.
BuildDir string
// MakepkgFlags are extra flags passed to makepkg (e.g. "--skippgpcheck").
MakepkgFlags []string
}
// New returns a new AUR manager with default settings.
// New returns a new AUR manager.
func New() *AUR {
return &AUR{}
}
// Option configures an AUR manager.
type AUROption func(*AUR)
// WithBuildDir sets a persistent build directory.
func WithBuildDir(dir string) AUROption {
return func(a *AUR) { a.BuildDir = dir }
}
// WithMakepkgFlags sets extra flags for makepkg.
func WithMakepkgFlags(flags ...string) AUROption {
return func(a *AUR) { a.MakepkgFlags = flags }
}
// NewWithOptions returns a new AUR manager with the given options.
func NewWithOptions(opts ...AUROption) *AUR {
a := New()
for _, opt := range opts {
opt(a)
}
return a
}
// Compile-time interface checks.
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
_ snack.NameNormalizer = (*AUR)(nil)
)
// Name returns "aur".
func (a *AUR) Name() string { return "aur" }
// Available reports whether the AUR toolchain (git, makepkg, pacman) is present.
// Available reports whether makepkg is present on the system.
func (a *AUR) Available() bool { return available() }
// Install clones, builds, and installs AUR packages.
// Install one or more packages from the AUR.
func (a *AUR) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
a.Lock()
defer a.Unlock()
return a.install(ctx, pkgs, opts...)
return install(ctx, pkgs, opts...)
}
// Remove removes packages via pacman (AUR packages are regular pacman packages once installed).
// Remove is not directly supported by AUR (use pacman).
func (a *AUR) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
a.Lock()
defer a.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including config files via pacman.
// Purge is not directly supported by AUR (use pacman).
func (a *AUR) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade rebuilds and reinstalls all foreign (AUR) packages.
// Upgrade all AUR packages (requires re-building from source).
func (a *AUR) Upgrade(ctx context.Context, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return a.upgradeAll(ctx, opts...)
return upgrade(ctx, opts...)
}
// Update is a no-op for AUR (there is no local package index to refresh).
func (a *AUR) Update(_ context.Context) error {
return nil
// Update is a no-op for AUR (packages are fetched on demand).
func (a *AUR) Update(ctx context.Context) error {
return update(ctx)
}
// List returns all installed foreign (non-repo) packages, which are typically AUR packages.
// List returns installed packages that came from the AUR.
func (a *AUR) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the AUR RPC API for packages matching the query.
// Search queries the AUR for packages matching the query.
func (a *AUR) Search(ctx context.Context, query string) ([]snack.Package, error) {
return rpcSearch(ctx, query)
return search(ctx, query)
}
// Info returns details about a specific AUR package from the RPC API.
// Info returns details about a specific AUR package.
func (a *AUR) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return rpcInfo(ctx, pkg)
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
// IsInstalled reports whether a package from the AUR is currently installed.
func (a *AUR) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
// Version returns the installed version of an AUR package.
func (a *AUR) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of an AUR package name.
func (a *AUR) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (a *AUR) ParseArch(name string) (string, string) {
return parseArch(name)
}

View File

@@ -5,59 +5,67 @@ package aur
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/gogrlx/snack"
)
const aurGitBase = "https://aur.archlinux.org"
const aurRPC = "https://aur.archlinux.org/rpc/v5"
func available() bool {
for _, tool := range []string{"makepkg", "pacman"} {
if _, err := exec.LookPath(tool); err != nil {
return false
}
}
return true
_, err := exec.LookPath("makepkg")
return err == nil
}
// runPacman executes a pacman command and returns stdout.
func runPacman(ctx context.Context, args []string, sudo bool) (string, error) {
cmd := "pacman"
if sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
c := exec.CommandContext(ctx, cmd, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
// aurSearchResponse is the JSON response from the AUR RPC API.
type aurSearchResponse struct {
ResultCount int `json:"resultcount"`
Results []struct {
Name string `json:"Name"`
Version string `json:"Version"`
Description string `json:"Description"`
URL string `json:"URL"`
OutOfDate *int64 `json:"OutOfDate"`
Maintainer string `json:"Maintainer"`
Popularity float64 `json:"Popularity"`
} `json:"results"`
}
func aurQuery(ctx context.Context, queryType, arg string) (*aurSearchResponse, error) {
url := fmt.Sprintf("%s/%s/%s", aurRPC, queryType, arg)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") || strings.Contains(se, "requires root") {
return "", fmt.Errorf("aur: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("aur: %s: %w", strings.TrimSpace(se), err)
return nil, err
}
return stdout.String(), nil
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
var result aurSearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("aur: %w", err)
}
return &result, nil
}
// install clones PKGBUILDs from the AUR, builds with makepkg, and installs with pacman.
func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var installed []snack.Package
var unchanged []string
for _, t := range pkgs {
// Check if already installed
if !o.Reinstall && !o.DryRun {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
@@ -69,19 +77,8 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
}
}
pkgFile, err := a.buildPackage(ctx, t)
if err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
}
if o.DryRun {
installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"})
continue
}
args := []string{"-U", "--noconfirm", pkgFile}
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
if err := installPkg(ctx, t.Name, o); err != nil {
return snack.InstallResult{}, err
}
v, _ := version(ctx, t.Name)
@@ -92,315 +89,92 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
Installed: true,
})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
// buildPackage clones the AUR git repo for a package and runs makepkg.
// Returns the path to the built .pkg.tar.zst file.
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error) {
// Determine build directory
buildDir := a.BuildDir
if buildDir == "" {
tmp, err := os.MkdirTemp("", "snack-aur-*")
if err != nil {
return "", fmt.Errorf("creating temp dir: %w", err)
}
buildDir = tmp
}
pkgDir := filepath.Join(buildDir, t.Name)
// Clone or update the PKGBUILD repo
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
return "", err
}
// Run makepkg
args := []string{"-s", "-f", "--noconfirm"}
args = append(args, a.MakepkgFlags...)
c := exec.CommandContext(ctx, "makepkg", args...)
c.Dir = pkgDir
var stderr bytes.Buffer
c.Stderr = &stderr
c.Stdout = &stderr // makepkg output goes to stderr anyway
if err := c.Run(); err != nil {
return "", fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err)
}
// Find the built package file
matches, err := filepath.Glob(filepath.Join(pkgDir, "*.pkg.tar*"))
if err != nil || len(matches) == 0 {
return "", fmt.Errorf("makepkg %s: no package file produced", t.Name)
}
return matches[len(matches)-1], nil
}
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.
func cloneOrPull(ctx context.Context, pkg, dir string) error {
repoURL := aurGitBase + "/" + pkg + ".git"
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
// Repo exists, pull latest
r, err := git.PlainOpen(dir)
if err != nil {
return fmt.Errorf("aur open %s: %w", pkg, err)
}
w, err := r.Worktree()
if err != nil {
return fmt.Errorf("aur worktree %s: %w", pkg, err)
}
if err := w.Pull(&git.PullOptions{}); err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("aur pull %s: %w", pkg, err)
}
return nil
}
// Clone fresh (depth 1)
_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: repoURL,
Depth: 1,
})
func installPkg(ctx context.Context, pkg string, opts snack.Options) error {
tmpDir, err := os.MkdirTemp("", "aur-"+pkg)
if err != nil {
if err == transport.ErrRepositoryNotFound {
return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound)
return fmt.Errorf("aur: create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
gitURL := fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg)
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", gitURL, tmpDir)
var stderr bytes.Buffer
cloneCmd.Stderr = &stderr
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("aur: git clone %s: %s: %w", pkg, stderr.String(), err)
}
makepkgArgs := []string{"-si", "--noconfirm"}
if opts.AssumeYes {
makepkgArgs = append(makepkgArgs, "--noconfirm")
}
makeCmd := exec.CommandContext(ctx, "makepkg", makepkgArgs...)
makeCmd.Dir = tmpDir
makeCmd.Stderr = &stderr
makeCmd.Stdout = os.Stdout
if err := makeCmd.Run(); err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") {
return fmt.Errorf("aur: %w", snack.ErrPermissionDenied)
}
return fmt.Errorf("aur clone %s: %w", pkg, err)
return fmt.Errorf("aur: makepkg %s: %s: %w", pkg, se, err)
}
return nil
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
o := snack.ApplyOptions(opts...)
var toRemove []snack.Target
var unchanged []string
for _, t := range pkgs {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.RemoveResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toRemove = append(toRemove, t)
}
}
if len(toRemove) > 0 {
args := append([]string{"-R", "--noconfirm"}, snack.TargetNames(toRemove)...)
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
return snack.RemoveResult{}, err
}
}
var removed []snack.Package
for _, t := range toRemove {
removed = append(removed, snack.Package{Name: t.Name})
}
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
return snack.RemoveResult{}, fmt.Errorf("aur: remove not supported, use pacman instead")
}
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...)
_, err := runPacman(ctx, args, o.Sudo)
return err
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return fmt.Errorf("aur: purge not supported, use pacman instead")
}
// upgradeAll rebuilds all installed foreign packages that have newer versions in the AUR.
func (a *AUR) upgradeAll(ctx context.Context, opts ...snack.Option) error {
upgrades, err := listUpgrades(ctx)
func upgrade(ctx context.Context, opts ...snack.Option) error {
aurPkgs, err := list(ctx)
if err != nil {
return err
}
if len(upgrades) == 0 {
return nil
}
targets := make([]snack.Target, len(upgrades))
for i, p := range upgrades {
targets[i] = snack.Target{Name: p.Name}
}
// Force reinstall since we're upgrading
allOpts := append([]snack.Option{snack.WithReinstall()}, opts...)
_, err = a.install(ctx, targets, allOpts...)
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
// pacman -Qm lists foreign (non-repo) packages, which are typically AUR
out, err := runPacman(ctx, []string{"-Qm"}, false)
if err != nil {
// exit status 1 means no foreign packages
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("aur list: %w", err)
}
return parsePackageList(out), nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("aur isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := runPacman(ctx, []string{"-Q", pkg}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("aur version: %w", err)
}
parts := strings.Fields(strings.TrimSpace(out))
if len(parts) < 2 {
return "", fmt.Errorf("aur version %s: unexpected output %q", pkg, out)
}
return parts[1], nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
p, err := rpcInfo(ctx, pkg)
if err != nil {
return "", err
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
// Get all installed foreign packages
installed, err := list(ctx)
if err != nil {
return nil, err
}
if len(installed) == 0 {
return nil, nil
}
// Batch-query the AUR for all of them
names := make([]string, len(installed))
for i, p := range installed {
names[i] = p.Name
}
aurInfo, err := rpcInfoMulti(ctx, names)
if err != nil {
return nil, err
}
// Compare versions
var upgrades []snack.Package
for _, inst := range installed {
aurPkg, ok := aurInfo[inst.Name]
if !ok {
continue // not in AUR (maybe from a custom repo)
}
cmp, err := versionCmp(ctx, inst.Version, aurPkg.Version)
for _, p := range aurPkgs {
result, err := aurQuery(ctx, "info", p.Name)
if err != nil {
continue // skip packages where vercmp fails
continue
}
if cmp < 0 {
upgrades = append(upgrades, snack.Package{
Name: inst.Name,
Version: aurPkg.Version,
Repository: "aur",
Installed: true,
})
if result.ResultCount == 0 {
continue
}
}
return upgrades, nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
inst, err := version(ctx, pkg)
if err != nil {
return false, err
}
latest, err := latestVersion(ctx, pkg)
if err != nil {
return false, err
}
cmp, err := versionCmp(ctx, inst, latest)
if err != nil {
return false, err
}
return cmp < 0, nil
}
func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
c := exec.CommandContext(ctx, "vercmp", ver1, ver2)
out, err := c.Output()
if err != nil {
return 0, fmt.Errorf("vercmp: %w", err)
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, fmt.Errorf("vercmp: unexpected output %q: %w", string(out), err)
}
switch {
case n < 0:
return -1, nil
case n > 0:
return 1, nil
default:
return 0, nil
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// Get orphans
orphans, err := runPacman(ctx, []string{"-Qdtq"}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil // no orphans
}
return fmt.Errorf("aur autoremove: %w", err)
}
orphans = strings.TrimSpace(orphans)
if orphans == "" {
return nil
}
pkgs := strings.Fields(orphans)
args := append([]string{"-Rns", "--noconfirm"}, pkgs...)
_, err = runPacman(ctx, args, o.Sudo)
return err
}
// cleanBuildDir removes all subdirectories in the build directory.
func (a *AUR) cleanBuildDir() error {
if a.BuildDir == "" {
return nil // temp dirs are cleaned automatically
}
entries, err := os.ReadDir(a.BuildDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("aur clean: %w", err)
}
for _, e := range entries {
if e.IsDir() {
if err := os.RemoveAll(filepath.Join(a.BuildDir, e.Name())); err != nil {
return fmt.Errorf("aur clean %s: %w", e.Name(), err)
if result.Results[0].Version != p.Version {
if _, err := install(ctx, []snack.Target{{Name: p.Name}}, opts...); err != nil {
return err
}
}
}
return nil
}
// parsePackageList parses "name version" lines from pacman -Q output.
func update(_ context.Context) error {
return nil
}
func list(ctx context.Context) ([]snack.Package, error) {
c := exec.CommandContext(ctx, "pacman", "-Qm")
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
// exit status 1 means no foreign packages
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil, nil
}
return nil, fmt.Errorf("aur list: %w", err)
}
return parsePackageList(stdout.String()), nil
}
func parsePackageList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
@@ -421,3 +195,188 @@ func parsePackageList(output string) []snack.Package {
}
return pkgs
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
result, err := aurQuery(ctx, "search", query)
if err != nil {
return nil, err
}
var pkgs []snack.Package
for _, r := range result.Results {
pkgs = append(pkgs, snack.Package{
Name: r.Name,
Version: r.Version,
Description: r.Description,
})
}
return pkgs, nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
result, err := aurQuery(ctx, "info", pkg)
if err != nil {
return nil, err
}
if result.ResultCount == 0 {
return nil, fmt.Errorf("aur info %s: %w", pkg, snack.ErrNotFound)
}
r := result.Results[0]
return &snack.Package{
Name: r.Name,
Version: r.Version,
Description: r.Description,
}, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("aur isInstalled: %w", err)
}
aurPkgs, err := list(ctx)
if err != nil {
return false, err
}
for _, p := range aurPkgs {
if p.Name == pkg {
return true, nil
}
}
return false, nil
}
func version(ctx context.Context, pkg string) (string, error) {
c := exec.CommandContext(ctx, "pacman", "-Q", pkg)
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("aur version: %w", err)
}
parts := strings.Fields(strings.TrimSpace(stdout.String()))
if len(parts) < 2 {
return "", fmt.Errorf("aur version %s: unexpected output", pkg)
}
return parts[1], nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
result, err := aurQuery(ctx, "info", pkg)
if err != nil {
return "", err
}
if result.ResultCount == 0 {
return "", fmt.Errorf("aur latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return result.Results[0].Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
aurPkgs, err := list(ctx)
if err != nil {
return nil, err
}
var upgrades []snack.Package
for _, p := range aurPkgs {
result, err := aurQuery(ctx, "info", p.Name)
if err != nil || result.ResultCount == 0 {
continue
}
if result.Results[0].Version != p.Version {
upgrades = append(upgrades, snack.Package{
Name: p.Name,
Version: result.Results[0].Version,
Repository: "aur",
Installed: true,
})
}
}
return upgrades, nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
installed, err := version(ctx, pkg)
if err != nil {
return false, err
}
latest, err := latestVersion(ctx, pkg)
if err != nil {
return false, err
}
cmp, err := versionCmp(ctx, installed, latest)
if err != nil {
return false, err
}
return cmp < 0, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
c := exec.Command("vercmp", ver1, ver2)
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
return 0, fmt.Errorf("aur versionCmp: %w", err)
}
result := strings.TrimSpace(stdout.String())
switch result {
case "-1":
return -1, nil
case "0":
return 0, nil
case "1":
return 1, nil
default:
return 0, fmt.Errorf("aur versionCmp: unexpected output %q", result)
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// Get orphans via pacman
c := exec.CommandContext(ctx, "pacman", "-Qdtq")
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
// exit status 1 means no orphans
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return nil
}
return fmt.Errorf("aur autoremove: %w", err)
}
orphans := strings.TrimSpace(stdout.String())
if orphans == "" {
return nil
}
args := []string{"-Rns", "--noconfirm"}
args = append(args, strings.Fields(orphans)...)
cmd := "pacman"
if o.Sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
removeCmd := exec.CommandContext(ctx, cmd, args...)
return removeCmd.Run()
}
func clean(_ context.Context) error {
// AUR builds in temp dirs, nothing persistent to clean
return nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
// For AUR, upgrading is just reinstalling from source
allOpts := append([]snack.Option{snack.WithReinstall()}, opts...)
return install(ctx, pkgs, allOpts...)
}

View File

@@ -10,7 +10,7 @@ import (
func available() bool { return false }
func (a *AUR) install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}
@@ -22,7 +22,11 @@ func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func (a *AUR) upgradeAll(_ context.Context, _ ...snack.Option) error {
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
@@ -30,6 +34,14 @@ func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
@@ -58,6 +70,10 @@ func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func (a *AUR) cleanBuildDir() error {
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -3,63 +3,123 @@ package aur
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/gogrlx/snack"
)
func TestParsePackageList(t *testing.T) {
// Compile-time interface checks
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
_ snack.NameNormalizer = (*AUR)(nil)
)
func TestNew(t *testing.T) {
a := New()
if a == nil {
t.Fatal("New() returned nil")
}
}
func TestName(t *testing.T) {
a := New()
if a.Name() != "aur" {
t.Errorf("Name() = %q, want %q", a.Name(), "aur")
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
name string
input string
expect int
input string
want string
}{
{
name: "empty",
input: "",
expect: 0,
},
{
name: "single package",
input: "yay 12.5.7-1\n",
expect: 1,
},
{
name: "multiple packages",
input: "yay 12.5.7-1\nparu 2.0.4-1\naur-helper 1.0-1\n",
expect: 3,
},
{
name: "trailing whitespace",
input: "yay 12.5.7-1 \n paru 2.0.4-1\n\n",
expect: 2,
},
{"yay", "yay"},
{"paru", "paru"},
{"google-chrome", "google-chrome"},
{"visual-studio-code-bin", "visual-studio-code-bin"},
{"", ""},
}
a := New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parsePackageList(tt.input)
assert.Len(t, pkgs, tt.expect)
for _, p := range pkgs {
assert.NotEmpty(t, p.Name)
assert.NotEmpty(t, p.Version)
assert.Equal(t, "aur", p.Repository)
assert.True(t, p.Installed)
t.Run(tt.input, func(t *testing.T) {
got := a.NormalizeName(tt.input)
if got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNew(t *testing.T) {
func TestParseArch(t *testing.T) {
tests := []struct {
input string
wantName string
wantArch string
}{
{"yay", "yay", ""},
{"paru", "paru", ""},
{"google-chrome", "google-chrome", ""},
}
a := New()
assert.Equal(t, "aur", a.Name())
assert.Empty(t, a.BuildDir)
assert.Nil(t, a.MakepkgFlags)
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
gotName, gotArch := a.ParseArch(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("ParseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}
func TestNewWithOptions(t *testing.T) {
a := NewWithOptions(
WithBuildDir("/tmp/aur-builds"),
WithMakepkgFlags("--skippgpcheck", "--nocheck"),
)
assert.Equal(t, "/tmp/aur-builds", a.BuildDir)
assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags)
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"PackageUpgrade", caps.PackageUpgrade, true},
{"NameNormalize", caps.NameNormalize, true},
// AUR does not support these
{"Hold", caps.Hold, false},
{"FileOwnership", caps.FileOwnership, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"DryRun", caps.DryRun, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
}
})
}
}
func TestInterfaceNonCompliance(t *testing.T) {
var m snack.Manager = New()
if _, ok := m.(snack.Holder); ok {
t.Error("AUR should not implement Holder")
}
if _, ok := m.(snack.FileOwner); ok {
t.Error("AUR should not implement FileOwner")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("AUR should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("AUR should not implement KeyManager")
}
if _, ok := m.(snack.Grouper); ok {
t.Error("AUR should not implement Grouper")
}
}

View File

@@ -6,30 +6,22 @@ import (
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
)
// LatestVersion returns the latest version available in the AUR.
// LatestVersion returns the latest version of an AUR package.
func (a *AUR) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns installed foreign packages that have newer versions in the AUR.
// ListUpgrades returns AUR packages that have newer versions available.
func (a *AUR) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available in the AUR.
// UpgradeAvailable reports whether a newer version is available.
func (a *AUR) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings using pacman's vercmp.
// VersionCmp compares two version strings using vercmp.
func (a *AUR) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
@@ -41,14 +33,14 @@ func (a *AUR) Autoremove(ctx context.Context, opts ...snack.Option) error {
return autoremove(ctx, opts...)
}
// Clean removes cached build artifacts from the build directory.
func (a *AUR) Clean(_ context.Context) error {
return a.cleanBuildDir()
// Clean is a no-op for AUR (builds use temp directories).
func (a *AUR) Clean(ctx context.Context) error {
return clean(ctx)
}
// UpgradePackages rebuilds and reinstalls specific AUR packages.
func (a *AUR) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
a.Lock()
defer a.Unlock()
return a.install(ctx, pkgs, opts...)
return upgradePackages(ctx, pkgs, opts...)
}

15
aur/normalize.go Normal file
View File

@@ -0,0 +1,15 @@
package aur
// normalizeName returns the canonical form of an AUR package name.
// AUR package names are simple identifiers without architecture or version
// suffixes, so this is essentially a pass-through.
func normalizeName(name string) string {
return name
}
// parseArch extracts the architecture from a package name if present.
// AUR package names do not include architecture suffixes,
// so this returns the name unchanged with an empty string.
func parseArch(name string) (string, string) {
return name, ""
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/gogrlx/snack"
)
@@ -117,23 +116,3 @@ func rpcInfo(ctx context.Context, pkg string) (*snack.Package, error) {
return &p, nil
}
// rpcInfoMulti returns info about multiple AUR packages in a single request.
func rpcInfoMulti(ctx context.Context, pkgs []string) (map[string]rpcResult, error) {
if len(pkgs) == 0 {
return nil, nil
}
params := make([]string, len(pkgs))
for i, p := range pkgs {
params[i] = "arg[]=" + url.QueryEscape(p)
}
endpoint := rpcBaseURL + "/info?" + strings.Join(params, "&")
resp, err := rpcGet(ctx, endpoint)
if err != nil {
return nil, err
}
result := make(map[string]rpcResult, len(resp.Results))
for _, r := range resp.Results {
result[r.Name] = r
}
return result, nil
}

156
brew/brew.go Normal file
View File

@@ -0,0 +1,156 @@
// Package brew provides Go bindings for Homebrew package manager.
package brew
import (
"context"
"github.com/gogrlx/snack"
)
// Brew wraps the brew CLI.
type Brew struct {
snack.Locker
}
// New returns a new Brew manager.
func New() *Brew {
return &Brew{}
}
// Compile-time interface checks.
var (
_ snack.Manager = (*Brew)(nil)
_ snack.VersionQuerier = (*Brew)(nil)
_ snack.Cleaner = (*Brew)(nil)
_ snack.FileOwner = (*Brew)(nil)
_ snack.NameNormalizer = (*Brew)(nil)
_ snack.PackageUpgrader = (*Brew)(nil)
)
// Name returns "brew".
func (b *Brew) Name() string { return "brew" }
// Available reports whether brew is present on the system.
func (b *Brew) Available() bool { return available() }
// Install one or more packages.
func (b *Brew) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
b.Lock()
defer b.Unlock()
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (b *Brew) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
b.Lock()
defer b.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including all dependencies.
func (b *Brew) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
b.Lock()
defer b.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages.
func (b *Brew) Upgrade(ctx context.Context, opts ...snack.Option) error {
b.Lock()
defer b.Unlock()
return upgrade(ctx, opts...)
}
// Update refreshes the package index.
func (b *Brew) Update(ctx context.Context) error {
b.Lock()
defer b.Unlock()
return update(ctx)
}
// List returns all installed packages.
func (b *Brew) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries Homebrew for packages.
func (b *Brew) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (b *Brew) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (b *Brew) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (b *Brew) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a package name.
func (b *Brew) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
// Homebrew does not embed architecture in package names.
func (b *Brew) ParseArch(name string) (string, string) {
return name, ""
}
// LatestVersion returns the latest available version of a package.
func (b *Brew) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (b *Brew) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (b *Brew) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings.
func (b *Brew) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes packages that were installed as dependencies but are no longer needed.
func (b *Brew) Autoremove(ctx context.Context, opts ...snack.Option) error {
b.Lock()
defer b.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached package files.
func (b *Brew) Clean(ctx context.Context) error {
b.Lock()
defer b.Unlock()
return clean(ctx)
}
// FileList returns all files installed by a package.
func (b *Brew) FileList(ctx context.Context, pkg string) ([]string, error) {
return fileList(ctx, pkg)
}
// Owner returns the package that owns a given file path.
func (b *Brew) Owner(ctx context.Context, path string) (string, error) {
return owner(ctx, path)
}
// UpgradePackages upgrades specific installed packages.
func (b *Brew) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
b.Lock()
defer b.Unlock()
return upgradePackages(ctx, pkgs, opts...)
}

87
brew/brew_other.go Normal file
View File

@@ -0,0 +1,87 @@
//go:build !darwin && !linux
package brew
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

223
brew/brew_test.go Normal file
View File

@@ -0,0 +1,223 @@
//go:build darwin || linux
package brew
import (
"testing"
"github.com/gogrlx/snack"
)
// Compile-time interface checks
var (
_ snack.Manager = (*Brew)(nil)
_ snack.VersionQuerier = (*Brew)(nil)
_ snack.Cleaner = (*Brew)(nil)
_ snack.FileOwner = (*Brew)(nil)
_ snack.NameNormalizer = (*Brew)(nil)
)
func TestNew(t *testing.T) {
b := New()
if b == nil {
t.Fatal("New() returned nil")
}
}
func TestName(t *testing.T) {
b := New()
if b.Name() != "brew" {
t.Errorf("Name() = %q, want %q", b.Name(), "brew")
}
}
func TestParseBrewList(t *testing.T) {
input := `git 2.43.0
go 1.21.6
vim 9.1.0
`
pkgs := parseBrewList(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "git" || pkgs[0].Version != "2.43.0" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
}
func TestParseBrewList_Empty(t *testing.T) {
pkgs := parseBrewList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseBrewList_SinglePackage(t *testing.T) {
input := "curl 8.6.0\n"
pkgs := parseBrewList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("expected name=curl, got %q", pkgs[0].Name)
}
}
func TestParseBrewSearch(t *testing.T) {
input := `==> Formulae
git
git-absorb
git-annex
==> Casks
git-credential-manager
`
pkgs := parseBrewSearch(input)
if len(pkgs) != 4 {
t.Fatalf("expected 4 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "git" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
}
func TestParseBrewSearch_Empty(t *testing.T) {
pkgs := parseBrewSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseBrewInfo(t *testing.T) {
input := `{"formulae":[{"name":"git","full_name":"git","desc":"Distributed revision control system","versions":{"stable":"2.43.0"},"installed":[{"version":"2.43.0"}]}],"casks":[]}`
pkg := parseBrewInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "git" {
t.Errorf("expected name 'git', got %q", pkg.Name)
}
if pkg.Version != "2.43.0" {
t.Errorf("unexpected version: %q", pkg.Version)
}
if !pkg.Installed {
t.Error("expected Installed=true")
}
}
func TestParseBrewInfo_NotInstalled(t *testing.T) {
input := `{"formulae":[{"name":"wget","full_name":"wget","desc":"Internet file retriever","versions":{"stable":"1.21"},"installed":[]}],"casks":[]}`
pkg := parseBrewInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Installed {
t.Error("expected Installed=false")
}
}
func TestParseBrewInfo_Cask(t *testing.T) {
input := `{"formulae":[],"casks":[{"token":"visual-studio-code","name":["Visual Studio Code"],"desc":"Open-source code editor","version":"1.85.0"}]}`
pkg := parseBrewInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "visual-studio-code" {
t.Errorf("expected name 'visual-studio-code', got %q", pkg.Name)
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"git", "git"},
{"python@3.12", "python"},
{"node@18", "node"},
{"go", "go"},
{"ruby@3.2", "ruby"},
}
b := New()
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := b.NormalizeName(tt.input)
if got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArch(t *testing.T) {
tests := []struct {
input string
wantName string
wantArch string
}{
{"git", "git", ""},
// Homebrew doesn't embed arch in names - @ is version suffix
{"python@3.12", "python@3.12", ""},
{"node@18", "node@18", ""},
}
b := New()
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
gotName, gotArch := b.ParseArch(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("ParseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
got bool
want bool
}{
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, true},
{"NameNormalize", caps.NameNormalize, true},
// Homebrew does not support these
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"DryRun", caps.DryRun, false},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
}
})
}
}
func TestInterfaceNonCompliance(t *testing.T) {
var m snack.Manager = New()
if _, ok := m.(snack.Holder); ok {
t.Error("Brew should not implement Holder")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("Brew should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("Brew should not implement KeyManager")
}
if _, ok := m.(snack.Grouper); ok {
t.Error("Brew should not implement Grouper")
}
}

473
brew/brew_unix.go Normal file
View File

@@ -0,0 +1,473 @@
//go:build darwin || linux
package brew
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("brew")
return err == nil
}
func run(ctx context.Context, args []string) (string, error) {
c := exec.CommandContext(ctx, "brew", args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") {
return "", fmt.Errorf("brew: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("brew: %s: %w", strings.TrimSpace(se), err)
}
return stdout.String(), nil
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toInstall []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.Reinstall || t.Version != "" || o.DryRun {
toInstall = append(toInstall, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if ok {
unchanged = append(unchanged, t.Name)
} else {
toInstall = append(toInstall, t)
}
}
for _, t := range toInstall {
args := []string{"install"}
pkg := t.Name
if t.Version != "" {
pkg = t.Name + "@" + t.Version
}
args = append(args, pkg)
if _, err := run(ctx, args); err != nil {
return snack.InstallResult{}, err
}
}
var installed []snack.Package
for _, t := range toInstall {
v, _ := version(ctx, t.Name)
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
o := snack.ApplyOptions(opts...)
var toRemove []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.DryRun {
toRemove = append(toRemove, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.RemoveResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toRemove = append(toRemove, t)
}
}
if len(toRemove) > 0 {
args := append([]string{"uninstall"}, snack.TargetNames(toRemove)...)
if _, err := run(ctx, args); err != nil {
return snack.RemoveResult{}, err
}
}
var removed []snack.Package
for _, t := range toRemove {
removed = append(removed, snack.Package{Name: t.Name})
}
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
}
func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
args := append([]string{"uninstall", "--zap"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args)
return err
}
func upgrade(ctx context.Context, _ ...snack.Option) error {
_, err := run(ctx, []string{"upgrade"})
return err
}
func update(ctx context.Context) error {
_, err := run(ctx, []string{"update"})
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"list", "--versions"})
if err != nil {
return nil, fmt.Errorf("brew list: %w", err)
}
return parseBrewList(out), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
out, err := run(ctx, []string{"search", query})
if err != nil {
if strings.Contains(err.Error(), "No formulae or casks found") {
return nil, nil
}
return nil, fmt.Errorf("brew search: %w", err)
}
return parseBrewSearch(out), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
out, err := run(ctx, []string{"info", "--json=v2", pkg})
if err != nil {
if strings.Contains(err.Error(), "No available formula") ||
strings.Contains(err.Error(), "No formulae or casks found") {
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("brew info: %w", err)
}
p := parseBrewInfo(out)
if p == nil {
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
}
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "brew", "list", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("brew isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"list", "--versions", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("brew version: %w", err)
}
pkgs := parseBrewList(out)
if len(pkgs) == 0 {
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
}
return pkgs[0].Version, nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"info", "--json=v2", pkg})
if err != nil {
if strings.Contains(err.Error(), "No available formula") {
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("brew latestVersion: %w", err)
}
ver := parseBrewInfoVersion(out)
if ver == "" {
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return ver, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"outdated", "--json=v2"})
if err != nil {
return nil, fmt.Errorf("brew listUpgrades: %w", err)
}
return parseBrewOutdated(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if u.Name == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func autoremove(ctx context.Context, _ ...snack.Option) error {
_, err := run(ctx, []string{"autoremove"})
return err
}
func clean(ctx context.Context) error {
_, err := run(ctx, []string{"cleanup"})
return err
}
// brewInfoJSON represents the JSON output from `brew info --json=v2`.
type brewInfoJSON struct {
Formulae []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Desc string `json:"desc"`
Versions struct {
Stable string `json:"stable"`
} `json:"versions"`
Installed []struct {
Version string `json:"version"`
} `json:"installed"`
} `json:"formulae"`
Casks []struct {
Token string `json:"token"`
Name []string `json:"name"`
Desc string `json:"desc"`
Version string `json:"version"`
} `json:"casks"`
}
// brewOutdatedJSON represents the JSON output from `brew outdated --json=v2`.
type brewOutdatedJSON struct {
Formulae []struct {
Name string `json:"name"`
InstalledVersions []string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
} `json:"formulae"`
Casks []struct {
Name string `json:"name"`
InstalledVersions string `json:"installed_versions"`
CurrentVersion string `json:"current_version"`
} `json:"casks"`
}
func parseBrewList(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 1 {
continue
}
pkg := snack.Package{
Name: fields[0],
Installed: true,
}
if len(fields) >= 2 {
pkg.Version = fields[1]
}
pkgs = append(pkgs, pkg)
}
return pkgs
}
func parseBrewSearch(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "==>") {
continue
}
for _, name := range strings.Fields(line) {
pkgs = append(pkgs, snack.Package{Name: name})
}
}
return pkgs
}
func parseBrewInfo(output string) *snack.Package {
var data brewInfoJSON
if err := json.Unmarshal([]byte(output), &data); err != nil {
return nil
}
if len(data.Formulae) > 0 {
f := data.Formulae[0]
pkg := &snack.Package{
Name: f.Name,
Version: f.Versions.Stable,
Description: f.Desc,
}
if len(f.Installed) > 0 {
pkg.Installed = true
pkg.Version = f.Installed[0].Version
}
return pkg
}
if len(data.Casks) > 0 {
c := data.Casks[0]
return &snack.Package{
Name: c.Token,
Version: c.Version,
Description: c.Desc,
}
}
return nil
}
func parseBrewInfoVersion(output string) string {
var data brewInfoJSON
if err := json.Unmarshal([]byte(output), &data); err != nil {
return ""
}
if len(data.Formulae) > 0 {
return data.Formulae[0].Versions.Stable
}
if len(data.Casks) > 0 {
return data.Casks[0].Version
}
return ""
}
func parseBrewOutdated(output string) []snack.Package {
var data brewOutdatedJSON
if err := json.Unmarshal([]byte(output), &data); err != nil {
return nil
}
var pkgs []snack.Package
for _, f := range data.Formulae {
pkgs = append(pkgs, snack.Package{
Name: f.Name,
Version: f.CurrentVersion,
Installed: true,
})
}
for _, c := range data.Casks {
pkgs = append(pkgs, snack.Package{
Name: c.Name,
Version: c.CurrentVersion,
Installed: true,
})
}
return pkgs
}
func semverCmp(a, b string) int {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
maxLen := len(partsA)
if len(partsB) > maxLen {
maxLen = len(partsB)
}
for i := 0; i < maxLen; i++ {
var numA, numB int
if i < len(partsA) {
fmt.Sscanf(partsA[i], "%d", &numA)
}
if i < len(partsB) {
fmt.Sscanf(partsB[i], "%d", &numB)
}
if numA < numB {
return -1
}
if numA > numB {
return 1
}
}
return 0
}
func fileList(ctx context.Context, pkg string) ([]string, error) {
out, err := run(ctx, []string{"list", "--formula", pkg})
if err != nil {
// Try cask
out, err = run(ctx, []string{"list", "--cask", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("brew fileList %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("brew fileList: %w", err)
}
}
var files []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files, nil
}
func owner(ctx context.Context, path string) (string, error) {
// brew doesn't have a direct "which package owns this file" command
// We need to iterate through installed packages and check their files
out, err := run(ctx, []string{"list", "--formula"})
if err != nil {
return "", fmt.Errorf("brew owner: %w", err)
}
for _, pkg := range strings.Fields(out) {
files, err := fileList(ctx, pkg)
if err != nil {
continue
}
for _, f := range files {
if f == path || strings.HasSuffix(f, "/"+path) {
return pkg, nil
}
}
}
return "", fmt.Errorf("brew owner %s: %w", path, snack.ErrNotFound)
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.DryRun {
toUpgrade = append(toUpgrade, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toUpgrade = append(toUpgrade, t)
}
}
for _, t := range toUpgrade {
if _, err := run(ctx, []string{"upgrade", t.Name}); err != nil {
return snack.InstallResult{}, fmt.Errorf("brew upgrade %s: %w", t.Name, err)
}
}
var upgraded []snack.Package
for _, t := range toUpgrade {
v, _ := version(ctx, t.Name)
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
}
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
}

12
brew/capabilities.go Normal file
View File

@@ -0,0 +1,12 @@
package brew
import "github.com/gogrlx/snack"
// Compile-time interface checks.
var (
_ snack.Manager = (*Brew)(nil)
_ snack.VersionQuerier = (*Brew)(nil)
_ snack.Cleaner = (*Brew)(nil)
_ snack.FileOwner = (*Brew)(nil)
_ snack.NameNormalizer = (*Brew)(nil)
)

21
brew/normalize.go Normal file
View File

@@ -0,0 +1,21 @@
package brew
import "strings"
// normalizeName returns the canonical form of a package name.
// Homebrew formulae can have version suffixes like `python@3.12`.
// This strips the version suffix to get the base formula name.
func normalizeName(name string) string {
n, _ := parseVersionSuffix(name)
return n
}
// parseVersionSuffix extracts the version suffix from a formula name.
// Homebrew uses @ to denote versioned formulae (e.g., "python@3.12").
// Returns the name without version and the version string.
func parseVersionSuffix(name string) (string, string) {
if idx := strings.LastIndex(name, "@"); idx > 0 {
return name[:idx], name[idx+1:]
}
return name, ""
}

View File

@@ -4,15 +4,16 @@ package snack
// Useful for grlx to determine what operations are available before
// attempting them.
type Capabilities struct {
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
PackageUpgrade bool
}
// GetCapabilities probes a Manager for all optional interface support.
@@ -26,15 +27,17 @@ func GetCapabilities(m Manager) Capabilities {
_, g := m.(Grouper)
_, nn := m.(NameNormalizer)
_, dr := m.(DryRunner)
_, pu := m.(PackageUpgrader)
return Capabilities{
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
PackageUpgrade: pu,
}
}

View File

@@ -77,6 +77,7 @@ func TestGetCapabilities_BaseManager(t *testing.T) {
assert.False(t, caps.Groups)
assert.False(t, caps.NameNormalize)
assert.False(t, caps.DryRun)
assert.False(t, caps.PackageUpgrade)
}
func TestGetCapabilities_FullManager(t *testing.T) {
@@ -90,4 +91,5 @@ func TestGetCapabilities_FullManager(t *testing.T) {
assert.True(t, caps.Groups)
assert.True(t, caps.NameNormalize)
assert.True(t, caps.DryRun)
assert.True(t, caps.PackageUpgrade)
}

View File

@@ -47,6 +47,9 @@ behind a single, consistent interface.`,
holdCmd(),
unholdCmd(),
cleanCmd(),
repoCmd(),
keyCmd(),
groupCmd(),
detectCmd(),
versionCmd(),
)
@@ -387,6 +390,9 @@ func detectCmd() *cobra.Command {
if caps.NameNormalize {
capList = append(capList, "normalize")
}
if caps.PackageUpgrade {
capList = append(capList, "pkg-upgrade")
}
capStr := ""
if len(capList) > 0 {
capStr = " [" + strings.Join(capList, ", ") + "]"
@@ -409,3 +415,237 @@ func versionCmd() *cobra.Command {
},
}
}
func repoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "repo",
Short: "Manage package repositories",
}
cmd.AddCommand(repoListCmd(), repoAddCmd(), repoRemoveCmd())
return cmd
}
func repoListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured repositories",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
rm, ok := m.(snack.RepoManager)
if !ok {
return fmt.Errorf("%s does not support repository management", m.Name())
}
repos, err := rm.ListRepos(cmd.Context())
if err != nil {
return err
}
for _, r := range repos {
status := "disabled"
if r.Enabled {
status = "enabled"
}
fmt.Printf("%s %s [%s]\n", r.ID, r.URL, status)
}
return nil
},
}
}
func repoAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <url>",
Short: "Add a package repository",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
rm, ok := m.(snack.RepoManager)
if !ok {
return fmt.Errorf("%s does not support repository management", m.Name())
}
repo := snack.Repository{
URL: args[0],
Enabled: true,
}
return rm.AddRepo(cmd.Context(), repo)
},
}
}
func repoRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <id>",
Short: "Remove a package repository",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
rm, ok := m.(snack.RepoManager)
if !ok {
return fmt.Errorf("%s does not support repository management", m.Name())
}
return rm.RemoveRepo(cmd.Context(), args[0])
},
}
}
func keyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "key",
Short: "Manage GPG signing keys",
}
cmd.AddCommand(keyListCmd(), keyAddCmd(), keyRemoveCmd())
return cmd
}
func keyListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List trusted signing keys",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
km, ok := m.(snack.KeyManager)
if !ok {
return fmt.Errorf("%s does not support key management", m.Name())
}
keys, err := km.ListKeys(cmd.Context())
if err != nil {
return err
}
for _, k := range keys {
fmt.Println(k)
}
return nil
},
}
}
func keyAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <key>",
Short: "Add a GPG signing key (URL, file path, or key ID)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
km, ok := m.(snack.KeyManager)
if !ok {
return fmt.Errorf("%s does not support key management", m.Name())
}
return km.AddKey(cmd.Context(), args[0])
},
}
}
func keyRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <key-id>",
Short: "Remove a GPG signing key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
km, ok := m.(snack.KeyManager)
if !ok {
return fmt.Errorf("%s does not support key management", m.Name())
}
return km.RemoveKey(cmd.Context(), args[0])
},
}
}
func groupCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "group",
Short: "Manage package groups",
}
cmd.AddCommand(groupListCmd(), groupInfoCmd(), groupInstallCmd())
return cmd
}
func groupListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List available package groups",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
m, err := getManager()
if err != nil {
return err
}
g, ok := m.(snack.Grouper)
if !ok {
return fmt.Errorf("%s does not support package groups", m.Name())
}
groups, err := g.GroupList(cmd.Context())
if err != nil {
return err
}
for _, grp := range groups {
fmt.Println(grp)
}
return nil
},
}
}
func groupInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "info <group>",
Short: "Show packages in a group",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
g, ok := m.(snack.Grouper)
if !ok {
return fmt.Errorf("%s does not support package groups", m.Name())
}
pkgs, err := g.GroupInfo(cmd.Context(), args[0])
if err != nil {
return err
}
for _, p := range pkgs {
fmt.Println(p.Name)
}
return nil
},
}
}
func groupInstallCmd() *cobra.Command {
return &cobra.Command{
Use: "install <group>",
Short: "Install all packages in a group",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
m, err := getManager()
if err != nil {
return err
}
g, ok := m.(snack.Grouper)
if !ok {
return fmt.Errorf("%s does not support package groups", m.Name())
}
return g.GroupInstall(cmd.Context(), args[0], opts()...)
},
}
}

150
cmd/snack/main_test.go Normal file
View File

@@ -0,0 +1,150 @@
package main
import (
"testing"
"github.com/gogrlx/snack"
)
func TestTargets(t *testing.T) {
tests := []struct {
name string
args []string
ver string
want []snack.Target
}{
{
name: "no_args",
args: nil,
want: nil,
},
{
name: "single_no_version",
args: []string{"curl"},
want: []snack.Target{{Name: "curl"}},
},
{
name: "multiple_no_version",
args: []string{"curl", "wget"},
want: []snack.Target{{Name: "curl"}, {Name: "wget"}},
},
{
name: "single_with_version",
args: []string{"curl"},
ver: "7.88",
want: []snack.Target{{Name: "curl", Version: "7.88"}},
},
{
name: "multiple_with_version",
args: []string{"curl", "wget"},
ver: "1.0",
want: []snack.Target{{Name: "curl", Version: "1.0"}, {Name: "wget", Version: "1.0"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := targets(tt.args, tt.ver)
if len(got) != len(tt.want) {
t.Fatalf("targets() returned %d, want %d", len(got), len(tt.want))
}
for i, g := range got {
if g.Name != tt.want[i].Name {
t.Errorf("[%d] Name = %q, want %q", i, g.Name, tt.want[i].Name)
}
if g.Version != tt.want[i].Version {
t.Errorf("[%d] Version = %q, want %q", i, g.Version, tt.want[i].Version)
}
}
})
}
}
func TestOpts(t *testing.T) {
// Reset flags
flagSudo = false
flagYes = false
flagDry = false
o := opts()
if len(o) != 0 {
t.Errorf("expected 0 options with no flags, got %d", len(o))
}
flagSudo = true
o = opts()
if len(o) != 1 {
t.Errorf("expected 1 option with sudo, got %d", len(o))
}
flagYes = true
flagDry = true
o = opts()
if len(o) != 3 {
t.Errorf("expected 3 options with all flags, got %d", len(o))
}
// Clean up
flagSudo = false
flagYes = false
flagDry = false
}
func TestOptsApply(t *testing.T) {
flagSudo = true
flagYes = true
flagDry = true
defer func() {
flagSudo = false
flagYes = false
flagDry = false
}()
applied := snack.ApplyOptions(opts()...)
if !applied.Sudo {
t.Error("expected Sudo=true")
}
if !applied.AssumeYes {
t.Error("expected AssumeYes=true")
}
if !applied.DryRun {
t.Error("expected DryRun=true")
}
}
func TestGetManager(t *testing.T) {
// Default detection
flagMgr = ""
m, err := getManager()
if err != nil {
t.Skipf("no manager available: %v", err)
}
if m.Name() == "" {
t.Error("expected non-empty manager name")
}
// Explicit override - use the detected manager's name
// since not all managers are available on all platforms
flagMgr = m.Name()
m2, err := getManager()
if err != nil {
t.Fatalf("getManager() with --manager=%s failed: %v", flagMgr, err)
}
if m2.Name() != flagMgr {
t.Errorf("expected Name()=%s, got %q", flagMgr, m2.Name())
}
// Unknown manager
flagMgr = "nonexistent-manager-xyz"
_, err = getManager()
if err == nil {
t.Error("expected error for unknown manager")
}
flagMgr = ""
}
func TestVersionString(t *testing.T) {
if version == "" {
t.Error("version should not be empty")
}
}

20
detect/detect_darwin.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build darwin
package detect
import (
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/brew"
)
// candidates returns manager factories in probe order for macOS.
func candidates() []managerFactory {
return []managerFactory{
func() snack.Manager { return brew.New() },
}
}
// allManagers returns all known manager factories (for ByName).
func allManagers() []managerFactory {
return candidates()
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/gogrlx/snack/apk"
"github.com/gogrlx/snack/apt"
"github.com/gogrlx/snack/aur"
"github.com/gogrlx/snack/brew"
"github.com/gogrlx/snack/dnf"
"github.com/gogrlx/snack/flatpak"
"github.com/gogrlx/snack/pacman"
@@ -23,11 +24,12 @@ func candidates() []managerFactory {
func() snack.Manager { return apk.New() },
func() snack.Manager { return flatpak.New() },
func() snack.Manager { return snap.New() },
func() snack.Manager { return brew.New() },
func() snack.Manager { return aur.New() },
}
}
// allManagers returns all known manager factories (for ByName).
// Includes supplemental managers like AUR that aren't primary candidates.
func allManagers() []managerFactory {
return append(candidates(), func() snack.Manager { return aur.New() })
return candidates()
}

13
detect/detect_other.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
package detect
// candidates returns an empty list on unsupported platforms.
func candidates() []managerFactory {
return nil
}
// allManagers returns an empty list on unsupported platforms.
func allManagers() []managerFactory {
return nil
}

View File

@@ -1,7 +1,12 @@
//go:build linux
package detect
import (
"errors"
"testing"
"github.com/gogrlx/snack"
)
func TestByNameUnknown(t *testing.T) {
@@ -9,15 +14,62 @@ func TestByNameUnknown(t *testing.T) {
if err == nil {
t.Fatal("expected error for unknown manager")
}
if !errors.Is(err, snack.ErrManagerNotFound) {
t.Errorf("expected ErrManagerNotFound, got %v", err)
}
}
func TestByNameKnown(t *testing.T) {
// All known manager names should be resolvable by ByName, even if
// unavailable on this system.
knownNames := []string{"apt", "dnf", "pacman", "apk", "flatpak", "snap", "aur"}
for _, name := range knownNames {
t.Run(name, func(t *testing.T) {
m, err := ByName(name)
if err != nil {
t.Fatalf("ByName(%q) returned error: %v", name, err)
}
if m.Name() != name {
t.Errorf("ByName(%q).Name() = %q", name, m.Name())
}
})
}
}
func TestByNameReturnsCorrectType(t *testing.T) {
m, err := ByName("apt")
if err != nil {
t.Skip("apt not in allManagers on this platform")
}
if m.Name() != "apt" {
t.Errorf("expected Name()=apt, got %q", m.Name())
}
}
func TestAllReturnsSlice(t *testing.T) {
// Just verify it doesn't panic; actual availability depends on system.
_ = All()
managers := All()
// On Linux with apt installed, we should get at least 1
// But don't fail if none — could be a weird CI environment
seen := make(map[string]bool)
for _, m := range managers {
name := m.Name()
if seen[name] {
t.Errorf("duplicate manager in All(): %s", name)
}
seen[name] = true
}
}
func TestAllManagersAreAvailable(t *testing.T) {
for _, m := range All() {
if !m.Available() {
t.Errorf("All() returned unavailable manager: %s", m.Name())
}
}
}
func TestDefaultDoesNotPanic(t *testing.T) {
// May return error if no managers available; that's fine.
Reset()
_, _ = Default()
}
@@ -37,9 +89,65 @@ func TestDefaultCachesResult(t *testing.T) {
}
}
func TestDefaultReturnsAvailableManager(t *testing.T) {
Reset()
m, err := Default()
if err != nil {
t.Skipf("no manager available on this system: %v", err)
}
if !m.Available() {
t.Error("Default() returned unavailable manager")
}
if m.Name() == "" {
t.Error("Default() returned manager with empty name")
}
}
func TestResetAllowsRedetection(t *testing.T) {
_, _ = Default()
Reset()
// After reset, defaultOnce should be fresh; calling Default() again should work.
// After reset, calling Default() again should work.
_, _ = Default()
}
func TestResetConcurrent(t *testing.T) {
done := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
defer func() { done <- struct{}{} }()
Reset()
_, _ = Default()
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
func TestHasBinary(t *testing.T) {
// sh should exist on any Unix system
if !HasBinary("sh") {
t.Error("expected HasBinary(sh) = true")
}
if HasBinary("this-binary-does-not-exist-anywhere-12345") {
t.Error("expected HasBinary(nonexistent) = false")
}
}
func TestCandidatesNotEmpty(t *testing.T) {
c := candidates()
if len(c) == 0 {
t.Error("candidates() returned empty slice")
}
}
func TestAllManagersNotEmpty(t *testing.T) {
a := allManagers()
if len(a) == 0 {
t.Error("allManagers() returned empty slice")
}
// allManagers should be a superset of candidates
if len(a) < len(candidates()) {
t.Error("allManagers() should include at least all candidates")
}
}

20
detect/detect_windows.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build windows
package detect
import (
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/winget"
)
// candidates returns manager factories in probe order for Windows.
func candidates() []managerFactory {
return []managerFactory{
func() snack.Manager { return winget.New() },
}
}
// allManagers returns all known manager factories (for ByName).
func allManagers() []managerFactory {
return candidates()
}

87
dnf/dnf_test.go Normal file
View File

@@ -0,0 +1,87 @@
package dnf
import (
"testing"
"github.com/gogrlx/snack"
)
// Compile-time interface assertions — DNF implements all optional interfaces.
var (
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
_ snack.DryRunner = (*DNF)(nil)
_ snack.PackageUpgrader = (*DNF)(nil)
)
func TestName(t *testing.T) {
d := New()
if got := d.Name(); got != "dnf" {
t.Errorf("Name() = %q, want %q", got, "dnf")
}
}
func TestSupportsDryRun(t *testing.T) {
d := New()
if !d.SupportsDryRun() {
t.Error("SupportsDryRun() = false, want true")
}
}
func TestGetCapabilities(t *testing.T) {
d := New()
caps := snack.GetCapabilities(d)
tests := []struct {
name string
got bool
}{
{"VersionQuery", caps.VersionQuery},
{"Hold", caps.Hold},
{"Clean", caps.Clean},
{"FileOwnership", caps.FileOwnership},
{"RepoManagement", caps.RepoManagement},
{"KeyManagement", caps.KeyManagement},
{"Groups", caps.Groups},
{"NameNormalize", caps.NameNormalize},
{"DryRun", caps.DryRun},
{"PackageUpgrade", caps.PackageUpgrade},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.got {
t.Errorf("Capabilities.%s = false, want true", tt.name)
}
})
}
}
func TestNormalizeNameMethod(t *testing.T) {
d := New()
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl", "curl"},
}
for _, tt := range tests {
if got := d.NormalizeName(tt.input); got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArchMethod(t *testing.T) {
d := New()
name, arch := d.ParseArch("nginx.x86_64")
if name != "nginx" || arch != "x86_64" {
t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch)
}
}

View File

@@ -3,8 +3,6 @@ package dnf
import (
"strings"
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
@@ -458,15 +456,399 @@ updates-testing Fedora 43 - x86_64 - Test Updates
}
}
// Ensure interface checks from capabilities.go are satisfied.
var (
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
)
// --- Edge case tests ---
func TestParseListEmpty(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListSinglePackage(t *testing.T) {
input := `Installed Packages
curl.x86_64 7.76.1-23.el9 @baseos
`
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseListMalformedLines(t *testing.T) {
input := `Installed Packages
curl.x86_64 7.76.1-23.el9 @baseos
thislinehasnospaces
only-one-field
bash.x86_64 5.1.8-6.el9 @anaconda
`
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
}
}
func TestParseListNoHeader(t *testing.T) {
// Lines that look like packages without the "Installed Packages" header
input := `curl.x86_64 7.76.1-23.el9 @baseos
bash.x86_64 5.1.8-6.el9 @anaconda
`
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
}
func TestParseListTwoColumns(t *testing.T) {
// Only name.arch and version, no repo column
input := `Installed Packages
curl.x86_64 7.76.1-23.el9
`
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Repository != "" {
t.Errorf("Repository = %q, want empty", pkgs[0].Repository)
}
}
func TestParseSearchEmpty(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseSearchSingleResult(t *testing.T) {
input := `=== Name Exactly Matched: curl ===
curl.x86_64 : A utility for getting files from remote servers
`
pkgs := parseSearch(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseSearchMalformedLines(t *testing.T) {
input := `=== Name Matched ===
curl.x86_64 : A utility
no-separator-here
another.line.without : proper : colons
bash.noarch : Shell
`
pkgs := parseSearch(input)
// "curl.x86_64 : A utility" and "another.line.without : proper : colons" and "bash.noarch : Shell"
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
}
func TestParseInfoEmpty(t *testing.T) {
p := parseInfo("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoNoName(t *testing.T) {
input := `Version : 1.0
Architecture : x86_64
`
p := parseInfo(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseInfoReleaseBeforeVersion(t *testing.T) {
// Release without prior Version should not panic
input := `Name : test
Release : 1.el9
Version : 2.0
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
// Release came before Version was set, so it won't append properly,
// but Version should at least be set
if p.Name != "test" {
t.Errorf("Name = %q, want test", p.Name)
}
}
func TestParseInfoFromRepo(t *testing.T) {
input := `Name : bash
Version : 5.1.8
Release : 6.el9
From repo : baseos
Summary : The GNU Bourne Again shell
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
if p.Repository != "baseos" {
t.Errorf("Repository = %q, want baseos", p.Repository)
}
}
func TestParseVersionLockEmpty(t *testing.T) {
pkgs := parseVersionLock("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseVersionLockSingleEntry(t *testing.T) {
input := `nginx-0:1.20.1-14.el9_2.1.*
`
pkgs := parseVersionLock(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("Name = %q, want nginx", pkgs[0].Name)
}
}
func TestParseRepoListEmpty(t *testing.T) {
repos := parseRepoList("")
if len(repos) != 0 {
t.Errorf("expected 0 repos from empty input, got %d", len(repos))
}
}
func TestParseRepoListSingleRepo(t *testing.T) {
input := `repo id repo name status
baseos CentOS Stream 9 - BaseOS enabled
`
repos := parseRepoList(input)
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].ID != "baseos" || !repos[0].Enabled {
t.Errorf("unexpected repo: %+v", repos[0])
}
}
func TestParseGroupListEmpty(t *testing.T) {
groups := parseGroupList("")
if len(groups) != 0 {
t.Errorf("expected 0 groups from empty input, got %d", len(groups))
}
}
func TestParseGroupInfoEmpty(t *testing.T) {
pkgs := parseGroupInfo("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseGroupInfoWithMarks(t *testing.T) {
input := `Group: Web Server
Mandatory Packages:
= httpd
+ mod_ssl
- php
`
pkgs := parseGroupInfo(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
names := map[string]bool{}
for _, p := range pkgs {
names[p.Name] = true
}
for _, want := range []string{"httpd", "mod_ssl", "php"} {
if !names[want] {
t.Errorf("missing package %q", want)
}
}
}
func TestParseGroupIsInstalledEmpty(t *testing.T) {
if parseGroupIsInstalled("", "anything") {
t.Error("expected false for empty input")
}
}
func TestNormalizeNameEdgeCases(t *testing.T) {
tests := []struct {
input, want string
}{
{"", ""},
{"pkg.unknown.ext", "pkg.unknown.ext"},
{"name.with.dots.x86_64", "name.with.dots"},
{"python3.11", "python3.11"},
{"glibc.s390x", "glibc"},
{"kernel.src", "kernel"},
{".x86_64", ""},
{"pkg.ppc64le", "pkg"},
{"pkg.armv7hl", "pkg"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArchEdgeCases(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"", "", ""},
{"pkg.i386", "pkg", "i386"},
{"pkg.ppc64le", "pkg", "ppc64le"},
{"pkg.s390x", "pkg", "s390x"},
{"pkg.armv7hl", "pkg", "armv7hl"},
{"pkg.src", "pkg", "src"},
{"pkg.unknown", "pkg.unknown", ""},
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
{".noarch", "", "noarch"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArch(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
}
})
}
}
// --- dnf5 edge case tests ---
func TestStripPreambleEmpty(t *testing.T) {
got := stripPreamble("")
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestStripPreambleNoPreamble(t *testing.T) {
input := "Installed packages\nbash.x86_64 5.3.0-2.fc43 abc\n"
got := stripPreamble(input)
if got != input {
t.Errorf("expected unchanged output when no preamble present")
}
}
func TestParseListDNF5Empty(t *testing.T) {
pkgs := parseListDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListDNF5SinglePackage(t *testing.T) {
input := `Installed packages
curl.aarch64 7.76.1-23.el9 abc123
`
pkgs := parseListDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" || pkgs[0].Arch != "aarch64" {
t.Errorf("unexpected: %+v", pkgs[0])
}
}
func TestParseSearchDNF5Empty(t *testing.T) {
pkgs := parseSearchDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseInfoDNF5Empty(t *testing.T) {
p := parseInfoDNF5("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoDNF5NoName(t *testing.T) {
input := `Version : 1.0
Architecture : x86_64
`
p := parseInfoDNF5(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseGroupListDNF5Empty(t *testing.T) {
groups := parseGroupListDNF5("")
if len(groups) != 0 {
t.Errorf("expected 0 groups from empty input, got %d", len(groups))
}
}
func TestParseGroupIsInstalledDNF5Empty(t *testing.T) {
if parseGroupIsInstalledDNF5("", "anything") {
t.Error("expected false for empty input")
}
}
func TestParseVersionLockDNF5Empty(t *testing.T) {
pkgs := parseVersionLockDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseVersionLockDNF5SingleEntry(t *testing.T) {
input := `# Added by 'versionlock add' command on 2026-02-26 03:14:29
Package name: nginx
evr = 1.20.1-14.el9
`
pkgs := parseVersionLockDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("Name = %q, want nginx", pkgs[0].Name)
}
}
func TestParseRepoListDNF5Empty(t *testing.T) {
repos := parseRepoListDNF5("")
if len(repos) != 0 {
t.Errorf("expected 0 repos from empty input, got %d", len(repos))
}
}
func TestParseGroupInfoDNF5Empty(t *testing.T) {
pkgs := parseGroupInfoDNF5("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseGroupInfoDNF5SinglePackage(t *testing.T) {
input := `Id : test-group
Name : Test
Mandatory packages : single-pkg
`
pkgs := parseGroupInfoDNF5(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "single-pkg" {
t.Errorf("Name = %q, want single-pkg", pkgs[0].Name)
}
}

View File

@@ -86,5 +86,145 @@ func TestUpdateUnsupported(t *testing.T) {
}
}
// Verify Dpkg implements snack.Manager at compile time.
var _ snack.Manager = (*Dpkg)(nil)
// Compile-time interface assertions.
var (
_ snack.Manager = (*Dpkg)(nil)
_ snack.FileOwner = (*Dpkg)(nil)
_ snack.NameNormalizer = (*Dpkg)(nil)
_ snack.DryRunner = (*Dpkg)(nil)
)
func TestSupportsDryRun(t *testing.T) {
d := New()
if !d.SupportsDryRun() {
t.Error("expected SupportsDryRun() = true")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
checks := []struct {
name string
got bool
want bool
}{
{"FileOwnership", caps.FileOwnership, true},
{"NameNormalize", caps.NameNormalize, true},
{"DryRun", caps.DryRun, true},
{"VersionQuery", caps.VersionQuery, false},
{"Hold", caps.Hold, false},
{"Clean", caps.Clean, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"PackageUpgrade", caps.PackageUpgrade, false},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
if c.got != c.want {
t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
}
})
}
}
func TestNormalizeNameMethod(t *testing.T) {
d := New()
if got := d.NormalizeName("curl:amd64"); got != "curl" {
t.Errorf("NormalizeName(curl:amd64) = %q, want %q", got, "curl")
}
}
func TestParseArchMethod(t *testing.T) {
d := New()
name, arch := d.ParseArch("bash:arm64")
if name != "bash" || arch != "arm64" {
t.Errorf("ParseArch(bash:arm64) = (%q, %q), want (bash, arm64)", name, arch)
}
}
func TestParseListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"empty", "", 0},
{"whitespace_only", " \n \n ", 0},
{"single_installed", "bash\t5.2-1\tinstall ok installed", 1},
{"no_status_field", "bash\t5.2-1", 1},
{"blank_lines_mixed", "\nbash\t5.2-1\tinstall ok installed\n\ncurl\t7.88\tinstall ok installed\n", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseList(tt.input)
if len(pkgs) != tt.want {
t.Errorf("parseList() returned %d packages, want %d", len(pkgs), tt.want)
}
})
}
}
func TestParseDpkgListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"empty", "", 0},
{"header_only", "Desired=Unknown/Install/Remove/Purge/Hold\n||/ Name Version Architecture Description\n+++-====-====-====-====", 0},
{"single_package", "ii bash 5.2-1 amd64 GNU Bourne Again SHell", 1},
{"held_package", "hi nginx 1.24 amd64 web server", 1},
{"purge_pending", "pn oldpkg 1.0 amd64 old package", 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseDpkgList(tt.input)
if len(pkgs) != tt.want {
t.Errorf("parseDpkgList() returned %d packages, want %d", len(pkgs), tt.want)
}
})
}
}
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("no_package_field", func(t *testing.T) {
_, err := parseInfo("Version: 1.0\nArchitecture: amd64\n")
if err != snack.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
})
t.Run("all_fields", func(t *testing.T) {
input := "Package: vim\nStatus: install ok installed\nVersion: 9.0\nArchitecture: arm64\nDescription: Vi IMproved\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Name != "vim" || p.Version != "9.0" || p.Arch != "arm64" || !p.Installed {
t.Errorf("unexpected: %+v", p)
}
})
t.Run("version_with_epoch", func(t *testing.T) {
input := "Package: systemd\nVersion: 1:252-2\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Version != "1:252-2" {
t.Errorf("Version = %q, want %q", p.Version, "1:252-2")
}
})
t.Run("not_installed_status", func(t *testing.T) {
input := "Package: curl\nStatus: deinstall ok config-files\nVersion: 7.88\n"
p, err := parseInfo(input)
if err != nil {
t.Fatal(err)
}
if p.Installed {
t.Error("expected Installed=false for deinstall status")
}
})
}

74
dpkg/normalize_test.go Normal file
View File

@@ -0,0 +1,74 @@
package dpkg
import "testing"
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"curl", "curl"},
{"curl:amd64", "curl"},
{"bash:arm64", "bash"},
{"python3:i386", "python3"},
{"libc6:armhf", "libc6"},
{"pkg:armel", "pkg"},
{"pkg:mips", "pkg"},
{"pkg:mipsel", "pkg"},
{"pkg:mips64el", "pkg"},
{"pkg:ppc64el", "pkg"},
{"pkg:s390x", "pkg"},
{"pkg:all", "pkg"},
{"pkg:any", "pkg"},
// Unknown arch suffix should be kept
{"pkg:unknown", "pkg:unknown"},
{"libstdc++6:amd64", "libstdc++6"},
{"", ""},
// Multiple colons — only last one checked
{"a:b:amd64", "a:b"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArch(t *testing.T) {
tests := []struct {
input string
wantName string
wantArch string
}{
{"curl:amd64", "curl", "amd64"},
{"bash:arm64", "bash", "arm64"},
{"python3", "python3", ""},
{"curl:i386", "curl", "i386"},
{"pkg:armhf", "pkg", "armhf"},
{"pkg:armel", "pkg", "armel"},
{"pkg:mips", "pkg", "mips"},
{"pkg:mipsel", "pkg", "mipsel"},
{"pkg:mips64el", "pkg", "mips64el"},
{"pkg:ppc64el", "pkg", "ppc64el"},
{"pkg:s390x", "pkg", "s390x"},
{"pkg:all", "pkg", "all"},
{"pkg:any", "pkg", "any"},
// Unknown arch — not split
{"pkg:foobar", "pkg:foobar", ""},
{"", "", ""},
// Multiple colons
{"a:b:arm64", "a:b", "arm64"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArch(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
}
})
}
}

View File

@@ -8,10 +8,32 @@ import (
// Compile-time interface checks.
var (
_ snack.Cleaner = (*Flatpak)(nil)
_ snack.RepoManager = (*Flatpak)(nil)
_ snack.VersionQuerier = (*Flatpak)(nil)
_ snack.Cleaner = (*Flatpak)(nil)
_ snack.RepoManager = (*Flatpak)(nil)
_ snack.NameNormalizer = (*Flatpak)(nil)
)
// LatestVersion returns the latest available version of a flatpak.
func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns flatpaks that have newer versions available.
func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings.
func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes unused runtimes and extensions.
func (f *Flatpak) Autoremove(ctx context.Context, opts ...snack.Option) error {
f.Lock()

View File

@@ -81,6 +81,16 @@ func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a flatpak app ID.
func (f *Flatpak) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a flatpak reference if present.
func (f *Flatpak) ParseArch(name string) (string, string) {
return parseRef(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Flatpak)(nil)
var _ snack.PackageUpgrader = (*Flatpak)(nil)

View File

@@ -24,12 +24,12 @@ func TestIntegration_Flatpak(t *testing.T) {
caps := snack.GetCapabilities(mgr)
assert.True(t, caps.Clean, "flatpak should support Clean")
assert.True(t, caps.RepoManagement, "flatpak should support RepoManagement")
assert.False(t, caps.VersionQuery)
assert.True(t, caps.VersionQuery, "flatpak should support VersionQuery")
assert.False(t, caps.Hold)
assert.False(t, caps.FileOwnership)
assert.False(t, caps.KeyManagement)
assert.False(t, caps.Groups)
assert.False(t, caps.NameNormalize)
assert.True(t, caps.NameNormalize, "flatpak should support NameNormalize")
t.Run("Update", func(t *testing.T) {
require.NoError(t, mgr.Update(ctx))

View File

@@ -198,6 +198,49 @@ func removeRepo(ctx context.Context, id string) error {
return err
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"remote-info", "flathub", pkg})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("flatpak latestVersion: %w", err)
}
p := parseInfo(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("flatpak latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"remote-ls", "--updates", "--columns=name,application,version,origin"})
if err != nil {
if strings.Contains(err.Error(), "No updates") {
return nil, nil
}
return nil, fmt.Errorf("flatpak listUpgrades: %w", err)
}
return parseList(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if u.Name == pkg || u.Description == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target
@@ -220,11 +263,8 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
if len(toUpgrade) > 0 {
for _, t := range toUpgrade {
args := []string{"update", "-y", t.Name}
cmd := exec.CommandContext(ctx, "flatpak", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w: %s", t.Name, err, stderr.String())
if _, err := run(ctx, args); err != nil {
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w", t.Name, err)
}
}
}

View File

@@ -62,6 +62,22 @@ func removeRepo(_ context.Context, _ string) error {
return snack.ErrUnsupportedPlatform
}
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -37,6 +37,71 @@ func TestParseListEmpty(t *testing.T) {
}
}
func TestParseListEdgeCases(t *testing.T) {
t.Run("single entry", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "Firefox" {
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
}
})
t.Run("two fields only", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "Firefox" {
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
}
if pkgs[0].Version != "" {
t.Errorf("expected empty version, got %q", pkgs[0].Version)
}
if pkgs[0].Repository != "" {
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
}
})
t.Run("single field skipped", func(t *testing.T) {
pkgs := parseList("Firefox\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
}
})
t.Run("extra fields", func(t *testing.T) {
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\textra\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Repository != "flathub" {
t.Errorf("expected flathub, got %q", pkgs[0].Repository)
}
})
t.Run("whitespace only lines", func(t *testing.T) {
pkgs := parseList(" \n\t\n \n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("three fields no repo", func(t *testing.T) {
pkgs := parseList("GIMP\torg.gimp.GIMP\t2.10.38\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Version != "2.10.38" {
t.Errorf("expected version 2.10.38, got %q", pkgs[0].Version)
}
if pkgs[0].Repository != "" {
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
}
})
}
func TestParseSearch(t *testing.T) {
input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" +
"Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n"
@@ -60,6 +125,32 @@ func TestParseSearchNoMatches(t *testing.T) {
}
}
func TestParseSearchEdgeCases(t *testing.T) {
t.Run("empty", func(t *testing.T) {
pkgs := parseSearch("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single field line skipped", func(t *testing.T) {
pkgs := parseSearch("JustAName\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
}
})
t.Run("not installed result", func(t *testing.T) {
pkgs := parseSearch("VLC\torg.videolan.VLC\t3.0.20\tflathub\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Installed {
t.Error("search results should not be marked installed")
}
})
}
func TestParseInfo(t *testing.T) {
input := `Name: Firefox
Description: Fast, private web browser
@@ -92,6 +183,46 @@ func TestParseInfoEmpty(t *testing.T) {
}
}
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("name only", func(t *testing.T) {
pkg := parseInfo("Name: VLC\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "VLC" {
t.Errorf("expected VLC, got %q", pkg.Name)
}
})
t.Run("no name returns nil", func(t *testing.T) {
pkg := parseInfo("Version: 1.0\nArch: x86_64\n")
if pkg != nil {
t.Error("expected nil when no Name field")
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
pkg := parseInfo("Name: Test\nsome random line without colon\nVersion: 2.0\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "2.0" {
t.Errorf("expected version 2.0, got %q", pkg.Version)
}
})
t.Run("value with colons", func(t *testing.T) {
pkg := parseInfo("Name: MyApp\nDescription: A tool: does things: well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
// parseInfo uses strings.Index for first colon
if pkg.Description != "A tool: does things: well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseRemotes(t *testing.T) {
input := "flathub\thttps://dl.flathub.org/repo/\t\n" +
"gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n"
@@ -113,10 +244,165 @@ func TestParseRemotes(t *testing.T) {
}
}
func TestParseRemotesEdgeCases(t *testing.T) {
t.Run("empty", func(t *testing.T) {
repos := parseRemotes("")
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
})
t.Run("single enabled remote", func(t *testing.T) {
repos := parseRemotes("flathub\thttps://dl.flathub.org/repo/\t\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if !repos[0].Enabled {
t.Error("expected enabled")
}
if repos[0].Name != "flathub" {
t.Errorf("expected Name=flathub, got %q", repos[0].Name)
}
})
t.Run("single disabled remote", func(t *testing.T) {
repos := parseRemotes("test-remote\thttps://example.com/\tdisabled\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].Enabled {
t.Error("expected disabled")
}
})
t.Run("no URL field", func(t *testing.T) {
repos := parseRemotes("myremote\n")
if len(repos) != 1 {
t.Fatalf("expected 1 repo, got %d", len(repos))
}
if repos[0].ID != "myremote" {
t.Errorf("expected myremote, got %q", repos[0].ID)
}
if repos[0].URL != "" {
t.Errorf("expected empty URL, got %q", repos[0].URL)
}
if !repos[0].Enabled {
t.Error("expected enabled by default")
}
})
t.Run("whitespace lines ignored", func(t *testing.T) {
repos := parseRemotes(" \n\n \n")
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
})
}
func TestSemverCmp(t *testing.T) {
tests := []struct {
name string
a, b string
want int
}{
{"equal", "1.0.0", "1.0.0", 0},
{"less major", "1.0.0", "2.0.0", -1},
{"greater major", "2.0.0", "1.0.0", 1},
{"less minor", "1.2.3", "1.3.0", -1},
{"less patch", "1.2.3", "1.2.4", -1},
{"multi-digit", "1.10.0", "1.9.0", 1},
{"short vs long equal", "1.0", "1.0.0", 0},
{"short vs long less", "1.0", "1.0.1", -1},
{"short vs long greater", "1.1", "1.0.9", 1},
{"single component", "5", "3", 1},
{"single equal", "3", "3", 0},
{"empty vs empty", "", "", 0},
{"empty vs version", "", "1.0", -1},
{"version vs empty", "1.0", "", 1},
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
{"pre-release stripped", "1.0.0beta2", "1.0.0rc1", 0},
{"four components", "1.2.3.4", "1.2.3.5", -1},
{"different lengths", "1.0.0.0", "1.0.0", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := semverCmp(tt.a, tt.b)
if got != tt.want {
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestStripNonNumeric(t *testing.T) {
tests := []struct {
input string
want string
}{
{"123", "123"},
{"123abc", "123"},
{"abc", ""},
{"0beta", "0"},
{"", ""},
{"42-rc1", "42"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripNonNumeric(tt.input)
if got != tt.want {
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Flatpak)(nil)
var _ snack.Cleaner = (*Flatpak)(nil)
var _ snack.RepoManager = (*Flatpak)(nil)
var _ snack.VersionQuerier = (*Flatpak)(nil)
var _ snack.PackageUpgrader = (*Flatpak)(nil)
}
// Compile-time interface checks in test file
var (
_ snack.VersionQuerier = (*Flatpak)(nil)
_ snack.PackageUpgrader = (*Flatpak)(nil)
)
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.RepoManagement {
t.Error("expected RepoManagement=true")
}
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
// Should be false
if caps.FileOwnership {
t.Error("expected FileOwnership=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {

29
flatpak/normalize.go Normal file
View File

@@ -0,0 +1,29 @@
package flatpak
import "strings"
// normalizeName returns the canonical form of a flatpak application ID.
// Flatpak references can include branch/arch suffixes like:
// - org.gnome.Calculator/x86_64/stable
// - org.gnome.Calculator//stable (default arch)
//
// This strips branch and arch to return just the app ID.
func normalizeName(name string) string {
n, _ := parseRef(name)
return n
}
// parseRef extracts the architecture from a flatpak reference if present.
// Flatpak references can be in the form:
// - app-id
// - app-id/arch/branch
// - app-id//branch (default arch)
//
// Returns the app-id and architecture (or empty string).
func parseRef(name string) (string, string) {
parts := strings.SplitN(name, "/", 3)
if len(parts) >= 2 {
return parts[0], parts[1]
}
return name, ""
}

View File

@@ -1,6 +1,7 @@
package flatpak
import (
"strconv"
"strings"
"github.com/gogrlx/snack"
@@ -127,3 +128,42 @@ func parseRemotes(output string) []snack.Repository {
}
return repos
}
// semverCmp does a basic semver-ish comparison.
// Returns -1 if a < b, 0 if equal, 1 if a > b.
func semverCmp(a, b string) int {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
maxLen := len(partsA)
if len(partsB) > maxLen {
maxLen = len(partsB)
}
for i := 0; i < maxLen; i++ {
var numA, numB int
if i < len(partsA) {
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
}
if i < len(partsB) {
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
}
if numA < numB {
return -1
}
if numA > numB {
return 1
}
}
return 0
}
// stripNonNumeric keeps only leading digits from a string.
func stripNonNumeric(s string) string {
for i, c := range s {
if c < '0' || c > '9' {
return s[:i]
}
}
return s
}

2
go.mod
View File

@@ -27,7 +27,7 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect

4
go.sum
View File

@@ -43,8 +43,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=

View File

@@ -0,0 +1,65 @@
//go:build linux
package pacman
import (
"testing"
"github.com/gogrlx/snack"
)
func TestBuildArgs(t *testing.T) {
tests := []struct {
name string
base []string
opts snack.Options
wantCmd string
wantArgs []string
}{
{
name: "basic",
base: []string{"-S", "vim"},
opts: snack.Options{},
wantCmd: "pacman",
wantArgs: []string{"-S", "vim"},
},
{
name: "with sudo",
base: []string{"-S", "vim"},
opts: snack.Options{Sudo: true},
wantCmd: "sudo",
wantArgs: []string{"pacman", "-S", "vim"},
},
{
name: "with root and noconfirm",
base: []string{"-S", "vim"},
opts: snack.Options{Root: "/mnt", AssumeYes: true},
wantCmd: "pacman",
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
},
{
name: "dry run",
base: []string{"-S", "vim"},
opts: snack.Options{DryRun: true},
wantCmd: "pacman",
wantArgs: []string{"-S", "vim", "--print"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, args := buildArgs(tt.base, tt.opts)
if cmd != tt.wantCmd {
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
}
if len(args) != len(tt.wantArgs) {
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
}
for i := range args {
if args[i] != tt.wantArgs[i] {
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
}
}
})
}
}

View File

@@ -12,6 +12,7 @@ var (
_ snack.Cleaner = (*Pacman)(nil)
_ snack.FileOwner = (*Pacman)(nil)
_ snack.Grouper = (*Pacman)(nil)
_ snack.NameNormalizer = (*Pacman)(nil)
_ snack.DryRunner = (*Pacman)(nil)
)

View File

@@ -1,3 +1,5 @@
//go:build linux
package pacman
import (
@@ -79,36 +81,88 @@ func TestBuildArgs_RootBeforeBaseArgs(t *testing.T) {
assert.Greater(t, sIdx, rIdx, "root flag should come before base args")
}
func TestParseUpgrades_Empty(t *testing.T) {
assert.Empty(t, parseUpgrades(""))
}
func TestParseUpgrades(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantNames []string
wantVers []string
}{
{
name: "empty",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n\t\n",
wantLen: 0,
},
{
name: "standard arrow format",
input: "linux 6.7.3.arch1-1 -> 6.7.4.arch1-1\nvim 9.0.2-1 -> 9.1.0-1\n",
wantLen: 2,
wantNames: []string{"linux", "vim"},
wantVers: []string{"6.7.4.arch1-1", "9.1.0-1"},
},
{
name: "single package arrow format",
input: "curl 8.6.0-1 -> 8.7.1-1\n",
wantLen: 1,
wantNames: []string{"curl"},
wantVers: []string{"8.7.1-1"},
},
{
name: "fallback two-field format",
input: "pkg 2.0\n",
wantLen: 1,
wantNames: []string{"pkg"},
wantVers: []string{"2.0"},
},
{
name: "mixed arrow and fallback",
input: "linux 6.7.3 -> 6.7.4\npkg 2.0\n",
wantLen: 2,
wantNames: []string{"linux", "pkg"},
wantVers: []string{"6.7.4", "2.0"},
},
{
name: "whitespace around entries",
input: "\n \nlinux 6.7.3 -> 6.7.4\n\n",
wantLen: 1,
wantNames: []string{"linux"},
wantVers: []string{"6.7.4"},
},
{
name: "single field line skipped",
input: "orphan\nvalid 1.0 -> 2.0\n",
wantLen: 1,
},
{
name: "epoch in version",
input: "java-runtime 1:21.0.2-1 -> 1:21.0.3-1\n",
wantLen: 1,
wantNames: []string{"java-runtime"},
wantVers: []string{"1:21.0.3-1"},
},
}
func TestParseUpgrades_Standard(t *testing.T) {
input := `linux 6.7.3.arch1-1 -> 6.7.4.arch1-1
vim 9.0.2-1 -> 9.1.0-1
`
pkgs := parseUpgrades(input)
require.Len(t, pkgs, 2)
assert.Equal(t, "linux", pkgs[0].Name)
assert.Equal(t, "6.7.4.arch1-1", pkgs[0].Version)
assert.True(t, pkgs[0].Installed)
assert.Equal(t, "vim", pkgs[1].Name)
assert.Equal(t, "9.1.0-1", pkgs[1].Version)
}
func TestParseUpgrades_FallbackFormat(t *testing.T) {
// Some versions of pacman might output "pkg newver" without the arrow
input := "pkg 2.0\n"
pkgs := parseUpgrades(input)
require.Len(t, pkgs, 1)
assert.Equal(t, "pkg", pkgs[0].Name)
assert.Equal(t, "2.0", pkgs[0].Version)
}
func TestParseUpgrades_WhitespaceLines(t *testing.T) {
input := "\n \nlinux 6.7.3 -> 6.7.4\n\n"
pkgs := parseUpgrades(input)
require.Len(t, pkgs, 1)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgrades(tt.input)
require.Len(t, pkgs, tt.wantLen)
for i, p := range pkgs {
assert.True(t, p.Installed, "all upgrade entries should have Installed=true")
if i < len(tt.wantNames) {
assert.Equal(t, tt.wantNames[i], p.Name)
}
if i < len(tt.wantVers) {
assert.Equal(t, tt.wantVers[i], p.Version)
}
}
})
}
}
func TestParseGroupPkgSet_Empty(t *testing.T) {
@@ -149,6 +203,31 @@ group pkg2
assert.Len(t, set, 2)
}
func TestParseGroupPkgSet_WhitespaceOnly(t *testing.T) {
set := parseGroupPkgSet(" \n \n\t\n")
assert.Empty(t, set)
}
func TestParseGroupPkgSet_MultipleGroups(t *testing.T) {
// Different group names, same package names — set uses pkg name (second field)
input := `base-devel gcc
xorg xorg-server
base-devel gcc
`
set := parseGroupPkgSet(input)
assert.Len(t, set, 2)
assert.Contains(t, set, "gcc")
assert.Contains(t, set, "xorg-server")
}
func TestParseGroupPkgSet_ExtraFields(t *testing.T) {
// Lines with more than 2 fields — should still use second field
input := "group pkg extra stuff\n"
set := parseGroupPkgSet(input)
assert.Len(t, set, 1)
assert.Contains(t, set, "pkg")
}
func TestNew(t *testing.T) {
p := New()
assert.NotNil(t, p)

16
pacman/normalize.go Normal file
View File

@@ -0,0 +1,16 @@
package pacman
// normalizeName returns the canonical form of a package name.
// Pacman package names do not include architecture suffixes, so this
// is essentially a pass-through. The package name is returned as-is.
func normalizeName(name string) string {
return name
}
// parseArch extracts the architecture from a package name if present.
// Pacman package names do not include architecture suffixes in the name itself
// (the arch is separate metadata), so this returns the name unchanged with an
// empty architecture string.
func parseArchNormalize(name string) (string, string) {
return name, ""
}

View File

@@ -83,6 +83,16 @@ func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a package name.
func (p *Pacman) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (p *Pacman) ParseArch(name string) (string, string) {
return parseArchNormalize(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Pacman)(nil)
var _ snack.PackageUpgrader = (*Pacman)(nil)

View File

@@ -29,7 +29,7 @@ func TestIntegration_Pacman(t *testing.T) {
assert.False(t, caps.Hold, "pacman should not support Hold")
assert.False(t, caps.RepoManagement, "pacman should not support RepoManagement")
assert.False(t, caps.KeyManagement, "pacman should not support KeyManagement")
assert.False(t, caps.NameNormalize, "pacman should not support NameNormalize")
assert.True(t, caps.NameNormalize, "pacman should support NameNormalize")
t.Run("Update", func(t *testing.T) {
require.NoError(t, mgr.Update(ctx))

View File

@@ -72,66 +72,63 @@ Architecture : x86_64
}
}
func TestBuildArgs(t *testing.T) {
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pacman)(nil)
var _ snack.VersionQuerier = (*Pacman)(nil)
var _ snack.Cleaner = (*Pacman)(nil)
var _ snack.FileOwner = (*Pacman)(nil)
var _ snack.Grouper = (*Pacman)(nil)
var _ snack.DryRunner = (*Pacman)(nil)
var _ snack.PackageUpgrader = (*Pacman)(nil)
}
func TestInterfaceNonCompliance(t *testing.T) {
p := New()
var m snack.Manager = p
if _, ok := m.(snack.Holder); ok {
t.Error("Pacman should not implement Holder")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("Pacman should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("Pacman should not implement KeyManager")
}
if _, ok := m.(snack.NameNormalizer); !ok {
t.Error("Pacman should implement NameNormalizer")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
tests := []struct {
name string
base []string
opts snack.Options
wantCmd string
wantArgs []string
name string
got bool
want bool
}{
{
name: "basic",
base: []string{"-S", "vim"},
opts: snack.Options{},
wantCmd: "pacman",
wantArgs: []string{"-S", "vim"},
},
{
name: "with sudo",
base: []string{"-S", "vim"},
opts: snack.Options{Sudo: true},
wantCmd: "sudo",
wantArgs: []string{"pacman", "-S", "vim"},
},
{
name: "with root and noconfirm",
base: []string{"-S", "vim"},
opts: snack.Options{Root: "/mnt", AssumeYes: true},
wantCmd: "pacman",
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
},
{
name: "dry run",
base: []string{"-S", "vim"},
opts: snack.Options{DryRun: true},
wantCmd: "pacman",
wantArgs: []string{"-S", "vim", "--print"},
},
{"VersionQuery", caps.VersionQuery, true},
{"Clean", caps.Clean, true},
{"FileOwnership", caps.FileOwnership, true},
{"Groups", caps.Groups, true},
{"DryRun", caps.DryRun, true},
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"NameNormalize", caps.NameNormalize, true},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, args := buildArgs(tt.base, tt.opts)
if cmd != tt.wantCmd {
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
}
if len(args) != len(tt.wantArgs) {
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
}
for i := range args {
if args[i] != tt.wantArgs[i] {
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
}
if tt.got != tt.want {
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pacman)(nil)
}
func TestName(t *testing.T) {
p := New()
if p.Name() != "pacman" {

View File

@@ -11,6 +11,7 @@ var (
_ snack.VersionQuerier = (*Pkg)(nil)
_ snack.Cleaner = (*Pkg)(nil)
_ snack.FileOwner = (*Pkg)(nil)
_ snack.NameNormalizer = (*Pkg)(nil)
)
// LatestVersion returns the latest available version from configured repositories.

17
pkg/normalize.go Normal file
View File

@@ -0,0 +1,17 @@
package pkg
// normalizeName returns the canonical form of a package name.
// FreeBSD pkg package names use "name-version" format. This function
// strips the version portion if present, returning just the name.
func normalizeName(name string) string {
n, _ := splitNameVersion(name)
return n
}
// parseArchNormalize extracts the architecture from a package name if present.
// FreeBSD pkg package names do not embed architecture in the name itself
// (the arch is separate metadata), so this returns the name unchanged with
// an empty architecture string.
func parseArchNormalize(name string) (string, string) {
return name, ""
}

View File

@@ -83,6 +83,16 @@ func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a package name.
func (p *Pkg) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (p *Pkg) ParseArch(name string) (string, string) {
return parseArchNormalize(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Pkg)(nil)
var _ snack.PackageUpgrader = (*Pkg)(nil)

View File

@@ -23,6 +23,94 @@ func TestParseQuery(t *testing.T) {
}
}
func TestParseQueryEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n",
wantLen: 0,
},
{
name: "single entry",
input: "vim\t9.0\tVi IMproved\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
},
},
{
name: "missing description (two fields only)",
input: "bash\t5.2\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.2", Description: "", Installed: true},
},
},
{
name: "single field only (no tabs, skipped)",
input: "justname\n",
wantLen: 0,
},
{
name: "description with tabs",
input: "pkg\t1.0\tA\ttabbed\tdescription\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "pkg", Version: "1.0", Description: "A\ttabbed\tdescription", Installed: true},
},
},
{
name: "trailing and leading whitespace on lines",
input: " nginx\t1.24.0\tWeb server \n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "nginx", Version: "1.24.0", Description: "Web server", Installed: true},
},
},
{
name: "multiple entries with blank lines between",
input: "a\t1.0\tAlpha\n\nb\t2.0\tBeta\n",
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseQuery(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseSearch(t *testing.T) {
input := `nginx-1.24.0 Robust and small WWW server
curl-8.5.0 Command line tool for transferring data
@@ -39,6 +127,81 @@ curl-8.5.0 Command line tool for transferring data
}
}
func TestParseSearchEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "name with many hyphens",
input: "py39-django-rest-framework-3.14.0 RESTful Web APIs for Django\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py39-django-rest-framework", Version: "3.14.0", Description: "RESTful Web APIs for Django"},
},
},
{
name: "no comment (name-version only)",
input: "zsh-5.9\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "zsh", Version: "5.9", Description: ""},
},
},
{
name: "very long output many packages",
input: "a-1.0 desc1\nb-2.0 desc2\nc-3.0 desc3\nd-4.0 desc4\ne-5.0 desc5\n",
wantLen: 5,
},
{
name: "single character name",
input: "R-4.3.2 Statistical Computing\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "R", Version: "4.3.2", Description: "Statistical Computing"},
},
},
{
name: "version with complex suffix",
input: "libressl-3.8.2_1 TLS library\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "libressl", Version: "3.8.2_1", Description: "TLS library"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseSearch(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
}
})
}
}
func TestParseInfo(t *testing.T) {
input := `Name : nginx
Version : 1.24.0
@@ -63,6 +226,77 @@ Arch : FreeBSD:14:amd64
}
}
func TestParseInfoEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantNil bool
want *snack.Package
}{
{
name: "empty input",
input: "",
wantNil: true,
},
{
name: "no name field returns nil",
input: "Version : 1.0\nComment : test\n",
wantNil: true,
},
{
name: "name only (missing other fields)",
input: "Name : bash\n",
want: &snack.Package{Name: "bash", Installed: true},
},
{
name: "extra unknown fields are ignored",
input: "Name : vim\nVersion : 9.0\nMaintainer : someone@example.com\nWWW : https://vim.org\nComment : Vi IMproved\n",
want: &snack.Package{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
},
{
name: "colon in value",
input: "Name : nginx\nComment : HTTP server: fast and reliable\n",
want: &snack.Package{Name: "nginx", Description: "HTTP server: fast and reliable", Installed: true},
},
{
name: "lines without colons are skipped",
input: "This is random text\nNo colons here\n",
wantNil: true,
},
{
name: "whitespace around values",
input: "Name : curl \nVersion : 8.5.0 \n",
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseInfo(tt.input)
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil package")
}
if got.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
}
if got.Version != tt.want.Version {
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
}
if got.Description != tt.want.Description {
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
}
if got.Installed != tt.want.Installed {
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
}
})
}
}
func TestParseUpgrades(t *testing.T) {
input := `Updating FreeBSD repository catalogue...
The following 2 package(s) will be affected:
@@ -84,6 +318,90 @@ Number of packages to be upgraded: 2
}
}
func TestParseUpgradesEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "no upgrade lines",
input: "Updating FreeBSD repository catalogue...\nAll packages are up to date.\n",
wantLen: 0,
},
{
name: "mix of Upgrading Installing Reinstalling",
input: `Upgrading nginx: 1.24.0 -> 1.26.0
Installing newpkg: 0 -> 1.0.0
Reinstalling bash: 5.2 -> 5.2
`,
wantLen: 3,
wantPkgs: []snack.Package{
{Name: "nginx", Version: "1.26.0", Installed: true},
{Name: "newpkg", Version: "1.0.0", Installed: true},
{Name: "bash", Version: "5.2", Installed: true},
},
},
{
name: "line with -> but no recognized prefix is skipped",
input: "Something: 1.0 -> 2.0\n",
wantLen: 0,
},
{
name: "upgrading line without colon is skipped",
input: "Upgrading nginx 1.24.0 -> 1.26.0\n",
wantLen: 0,
},
{
name: "upgrading line with -> but not enough parts after colon",
input: "Upgrading nginx: -> \n",
wantLen: 0,
},
{
name: "upgrading line with wrong arrow",
input: "Upgrading nginx: 1.24.0 => 1.26.0\n",
wantLen: 0,
},
{
name: "single upgrade line",
input: "Upgrading zsh: 5.8 -> 5.9\n",
wantPkgs: []snack.Package{
{Name: "zsh", Version: "5.9", Installed: true},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgrades(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseFileList(t *testing.T) {
input := `nginx-1.24.0:
/usr/local/sbin/nginx
@@ -99,6 +417,67 @@ func TestParseFileList(t *testing.T) {
}
}
func TestParseFileListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
want []string
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "header only no files",
input: "nginx-1.24.0:\n",
wantLen: 0,
},
{
name: "paths with spaces",
input: "pkg-1.0:\n\t/usr/local/share/my package/file name.txt\n\t/usr/local/share/another dir/test\n",
wantLen: 2,
want: []string{
"/usr/local/share/my package/file name.txt",
"/usr/local/share/another dir/test",
},
},
{
name: "single file",
input: "bash-5.2:\n\t/usr/local/bin/bash\n",
wantLen: 1,
want: []string{"/usr/local/bin/bash"},
},
{
name: "no header just file paths",
input: "/usr/local/bin/curl\n/usr/local/lib/libcurl.so\n",
wantLen: 2,
},
{
name: "blank lines between files",
input: "pkg-1.0:\n\t/usr/local/bin/a\n\n\t/usr/local/bin/b\n",
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files := parseFileList(tt.input)
if len(files) != tt.wantLen {
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
}
for i, w := range tt.want {
if i >= len(files) {
break
}
if files[i] != w {
t.Errorf("[%d] got %q, want %q", i, files[i], w)
}
}
})
}
}
func TestParseOwner(t *testing.T) {
input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n"
name := parseOwner(input)
@@ -107,6 +486,48 @@ func TestParseOwner(t *testing.T) {
}
}
func TestParseOwnerEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty input",
input: "",
want: "",
},
{
name: "standard format",
input: "/usr/local/bin/curl was installed by package curl-8.5.0\n",
want: "curl",
},
{
name: "package name with hyphens",
input: "/usr/local/lib/libpython3.so was installed by package py39-python-3.9.18\n",
want: "py39-python",
},
{
name: "no match returns trimmed input",
input: "some random output\n",
want: "some random output",
},
{
name: "whitespace around",
input: " /usr/local/bin/bash was installed by package bash-5.2.21 \n",
want: "bash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwner(tt.input)
if got != tt.want {
t.Errorf("parseOwner(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSplitNameVersion(t *testing.T) {
tests := []struct {
input string
@@ -126,6 +547,73 @@ func TestSplitNameVersion(t *testing.T) {
}
}
func TestSplitNameVersionEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantVersion string
}{
{
name: "empty string",
input: "",
wantName: "",
wantVersion: "",
},
{
name: "no hyphen",
input: "singleword",
wantName: "singleword",
wantVersion: "",
},
{
name: "multiple hyphens",
input: "py39-django-rest-3.14.0",
wantName: "py39-django-rest",
wantVersion: "3.14.0",
},
{
name: "leading hyphen",
input: "-1.0",
wantName: "-1.0",
wantVersion: "",
},
{
name: "trailing hyphen",
input: "nginx-",
wantName: "nginx",
wantVersion: "",
},
{
name: "only hyphen",
input: "-",
wantName: "-",
wantVersion: "",
},
{
name: "hyphen at index 1",
input: "a-1.0",
wantName: "a",
wantVersion: "1.0",
},
{
name: "version with underscore suffix",
input: "libressl-3.8.2_1",
wantName: "libressl",
wantVersion: "3.8.2_1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ver := splitNameVersion(tt.input)
if name != tt.wantName || ver != tt.wantVersion {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.wantName, tt.wantVersion)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Pkg)(nil)
var _ snack.VersionQuerier = (*Pkg)(nil)
@@ -133,9 +621,51 @@ func TestInterfaceCompliance(t *testing.T) {
var _ snack.FileOwner = (*Pkg)(nil)
}
func TestPackageUpgraderInterface(t *testing.T) {
var _ snack.PackageUpgrader = (*Pkg)(nil)
}
func TestName(t *testing.T) {
p := New()
if p.Name() != "pkg" {
t.Errorf("Name() = %q, want %q", p.Name(), "pkg")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
// Should be true
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

59
ports/capabilities.go Normal file
View File

@@ -0,0 +1,59 @@
package ports
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*Ports)(nil)
_ snack.Cleaner = (*Ports)(nil)
_ snack.FileOwner = (*Ports)(nil)
_ snack.NameNormalizer = (*Ports)(nil)
)
// LatestVersion returns the latest available version from configured repositories.
func (p *Ports) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (p *Ports) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings.
func (p *Ports) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes packages that are no longer needed.
func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached package files.
func (p *Ports) Clean(ctx context.Context) error {
p.Lock()
defer p.Unlock()
return clean(ctx)
}
// FileList returns all files installed by a package.
func (p *Ports) FileList(ctx context.Context, pkg string) ([]string, error) {
return fileList(ctx, pkg)
}
// Owner returns the package that owns a given file path.
func (p *Ports) Owner(ctx context.Context, path string) (string, error) {
return owner(ctx, path)
}

View File

@@ -0,0 +1,112 @@
//go:build openbsd
package ports
import (
"context"
"fmt"
"strings"
"github.com/gogrlx/snack"
)
func latestVersion(ctx context.Context, pkg string) (string, error) {
// pkg_info -Q returns available packages matching the query.
out, err := runCmd(ctx, "pkg_info", []string{"-Q", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("ports latestVersion: %w", err)
}
// Find the best match: exact name match with highest version.
var best string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, ver := splitNameVersion(line)
if name == pkg && ver != "" {
if best == "" || ver > best {
best = ver
}
}
}
if best == "" {
return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return best, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
// pkg_add -u -n simulates upgrading all packages.
out, err := runCmd(ctx, "pkg_add", []string{"-u", "-n"}, snack.Options{})
if err != nil {
// Exit status 1 with no output means nothing to upgrade.
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("ports listUpgrades: %w", err)
}
return parseUpgradeOutput(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if u.Name == pkg {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
// OpenBSD has no native version comparison tool.
// Use simple string comparison.
switch {
case ver1 < ver2:
return -1, nil
case ver1 > ver2:
return 1, nil
default:
return 0, nil
}
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.DryRun {
toUpgrade = append(toUpgrade, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toUpgrade = append(toUpgrade, t)
}
}
if len(toUpgrade) > 0 {
args := append([]string{"-u"}, snack.TargetNames(toUpgrade)...)
if _, err := runCmd(ctx, "pkg_add", args, o); err != nil {
return snack.InstallResult{}, err
}
}
var upgraded []snack.Package
for _, t := range toUpgrade {
v, _ := version(ctx, t.Name)
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
}
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
}

16
ports/normalize.go Normal file
View File

@@ -0,0 +1,16 @@
package ports
// normalizeName returns the canonical form of a package name.
// OpenBSD packages use "name-version" format. This strips the version
// portion if present, returning just the name.
func normalizeName(name string) string {
n, _ := splitNameVersion(name)
return n
}
// parseArchNormalize extracts the architecture from a package name if present.
// OpenBSD package names do not embed architecture in the name itself
// (the arch is separate), so this returns the name unchanged.
func parseArchNormalize(name string) (string, string) {
return name, ""
}

View File

@@ -105,6 +105,55 @@ func parseInfoOutput(output string, pkg string) *snack.Package {
return p
}
// parseUpgradeOutput parses the output of `pkg_add -u -n`.
// Lines like "name-oldver -> name-newver" indicate available upgrades.
func parseUpgradeOutput(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "->") {
continue
}
parts := strings.Fields(line)
// Expect: "name-oldver -> name-newver"
if len(parts) < 3 || parts[1] != "->" {
continue
}
name, _ := splitNameVersion(parts[0])
_, newVer := splitNameVersion(parts[2])
if name != "" {
pkgs = append(pkgs, snack.Package{
Name: name,
Version: newVer,
Installed: true,
})
}
}
return pkgs
}
// parseFileListOutput parses `pkg_info -L <pkg>` output.
// Lines starting with "/" after the header are file paths.
func parseFileListOutput(output string) []string {
var files []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "/") {
files = append(files, line)
}
}
return files
}
// parseOwnerOutput parses `pkg_info -E <path>` output.
// Returns the package name that owns the file.
func parseOwnerOutput(output string) string {
output = strings.TrimSpace(output)
// pkg_info -E returns the package name-version
name, _ := splitNameVersion(output)
return name
}
// splitNameVersion splits "name-version" at the last hyphen.
// OpenBSD packages use the last hyphen before a version number as separator.
func splitNameVersion(s string) (string, string) {

View File

@@ -81,5 +81,23 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a package name.
func (p *Ports) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (p *Ports) ParseArch(name string) (string, string) {
return parseArchNormalize(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Ports)(nil)
var _ snack.PackageUpgrader = (*Ports)(nil)
// UpgradePackages upgrades specific installed packages.
func (p *Ports) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
p.Lock()
defer p.Unlock()
return upgradePackages(ctx, pkgs, opts...)
}

View File

@@ -183,3 +183,55 @@ func version(ctx context.Context, pkg string) (string, error) {
}
return p.Version, nil
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// pkg_delete -a removes all packages not required by other packages
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
return err
}
func clean(_ context.Context) error {
// OpenBSD doesn't cache packages by default, no-op
return nil
}
func fileList(ctx context.Context, pkg string) ([]string, error) {
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("ports fileList: %w", err)
}
var files []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Information for") {
continue
}
if strings.HasPrefix(line, "Files:") {
continue
}
if strings.HasPrefix(line, "/") {
files = append(files, line)
}
}
return files, nil
}
func owner(ctx context.Context, path string) (string, error) {
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
}
return "", fmt.Errorf("ports owner: %w", err)
}
out = strings.TrimSpace(out)
if out == "" {
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
}
// Output is the package name
return strings.Split(out, "\n")[0], nil
}

View File

@@ -49,3 +49,39 @@ func isInstalled(_ context.Context, _ string) (bool, error) {
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -29,6 +29,93 @@ python-3.11.7p0 interpreted object-oriented programming language
}
}
func TestParseListEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n \n\n",
wantLen: 0,
},
{
name: "single entry",
input: "vim-9.0.2100 Vi IMproved\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0.2100", Description: "Vi IMproved", Installed: true},
},
},
{
name: "no description",
input: "vim-9.0.2100\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "vim", Version: "9.0.2100", Description: "", Installed: true},
},
},
{
name: "empty lines between entries",
input: "bash-5.2 Shell\n\n\ncurl-8.5.0 Transfer tool\n",
wantLen: 2,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.2", Description: "Shell", Installed: true},
{Name: "curl", Version: "8.5.0", Description: "Transfer tool", Installed: true},
},
},
{
name: "package with p-suffix version (OpenBSD style)",
input: "python-3.11.7p0 interpreted language\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.7p0", Description: "interpreted language", Installed: true},
},
},
{
name: "package name with multiple hyphens",
input: "py3-django-rest-3.14.0 REST framework\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest", Version: "3.14.0", Description: "REST framework", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseList(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Description != want.Description {
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseSearchResults(t *testing.T) {
input := `nginx-1.24.0
nginx-1.25.3
@@ -42,6 +129,83 @@ nginx-1.25.3
}
}
func TestParseSearchResultsEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\n \n",
wantLen: 0,
},
{
name: "single result",
input: "curl-8.5.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "curl", Version: "8.5.0"},
},
},
{
name: "name with many hyphens",
input: "py3-django-rest-framework-3.14.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest-framework", Version: "3.14.0"},
},
},
{
name: "no version (no hyphen)",
input: "quirks\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "quirks", Version: ""},
},
},
{
name: "p-suffix version",
input: "python-3.11.7p0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.7p0"},
},
},
{
name: "multiple results with blank lines",
input: "a-1.0\n\nb-2.0\n\nc-3.0\n",
wantLen: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseSearchResults(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
}
})
}
}
func TestParseInfoOutput(t *testing.T) {
input := `Information for nginx-1.24.0:
@@ -82,6 +246,131 @@ curl is a tool to transfer data from or to a server.
}
}
func TestParseInfoOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
pkgArg string
wantNil bool
want *snack.Package
}{
{
name: "empty input and empty pkg arg",
input: "",
pkgArg: "",
wantNil: true,
},
{
name: "empty input with pkg arg fallback",
input: "",
pkgArg: "curl-8.5.0",
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
},
{
name: "no header, falls back to pkg arg",
input: "Some random output\nwithout the expected header",
pkgArg: "vim-9.0",
want: &snack.Package{Name: "vim", Version: "9.0", Installed: true},
},
{
name: "comment on same line as Comment: label",
input: `Information for zsh-5.9:
Comment: Zsh shell
`,
pkgArg: "zsh-5.9",
want: &snack.Package{Name: "zsh", Version: "5.9", Description: "Zsh shell", Installed: true},
},
{
name: "comment on next line after Comment: label (not captured as description)",
input: `Information for bash-5.2:
Comment:
GNU Bourne Again Shell
`,
pkgArg: "bash-5.2",
// Comment: with nothing after the colon sets Description="",
// and the next line isn't in a Description: block so it's ignored.
want: &snack.Package{Name: "bash", Version: "5.2", Description: "", Installed: true},
},
{
name: "description spans multiple lines",
input: `Information for git-2.43.0:
Comment: distributed version control system
Description:
Git is a fast, scalable, distributed revision control system
with an unusually rich command set.
`,
pkgArg: "git-2.43.0",
want: &snack.Package{Name: "git", Version: "2.43.0", Description: "distributed version control system", Installed: true},
},
{
name: "extra fields are ignored",
input: `Information for tmux-3.3a:
Comment: terminal multiplexer
Maintainer: someone@openbsd.org
WWW: https://tmux.github.io
Description:
tmux is a terminal multiplexer.
`,
pkgArg: "tmux-3.3a",
want: &snack.Package{Name: "tmux", Version: "3.3a", Description: "terminal multiplexer", Installed: true},
},
{
name: "pkg arg with no version (no hyphen)",
input: "",
pkgArg: "quirks",
want: &snack.Package{Name: "quirks", Version: "", Installed: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseInfoOutput(tt.input, tt.pkgArg)
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil package")
}
if got.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
}
if got.Version != tt.want.Version {
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
}
if tt.want.Description != "" && got.Description != tt.want.Description {
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
}
if got.Installed != tt.want.Installed {
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
}
})
}
}
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) {
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 TestSplitNameVersion(t *testing.T) {
tests := []struct {
input string
@@ -101,57 +390,76 @@ 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 TestSplitNameVersionEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantVersion string
}{
{
name: "empty string",
input: "",
wantName: "",
wantVersion: "",
},
{
name: "no hyphen",
input: "singleword",
wantName: "singleword",
wantVersion: "",
},
{
name: "multiple hyphens",
input: "py3-django-rest-3.14.0",
wantName: "py3-django-rest",
wantVersion: "3.14.0",
},
{
name: "leading hyphen (idx=0, returns whole string)",
input: "-1.0",
wantName: "-1.0",
wantVersion: "",
},
{
name: "trailing hyphen",
input: "nginx-",
wantName: "nginx",
wantVersion: "",
},
{
name: "only hyphen",
input: "-",
wantName: "-",
wantVersion: "",
},
{
name: "hyphen at index 1",
input: "a-1.0",
wantName: "a",
wantVersion: "1.0",
},
{
name: "p-suffix version",
input: "python-3.11.7p0",
wantName: "python",
wantVersion: "3.11.7p0",
},
{
name: "version with v prefix",
input: "go-v1.21.5",
wantName: "go",
wantVersion: "v1.21.5",
},
}
}
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)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ver := splitNameVersion(tt.input)
if name != tt.wantName || ver != tt.wantVersion {
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
tt.input, name, ver, tt.wantName, tt.wantVersion)
}
})
}
}
@@ -163,21 +471,295 @@ func TestSplitNameVersionNoHyphen(t *testing.T) {
}
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 TestParseUpgradeOutput(t *testing.T) {
input := `quirks-7.14 -> quirks-7.18
curl-8.5.0 -> curl-8.6.0
python-3.11.7p0 -> python-3.11.8p0
`
pkgs := parseUpgradeOutput(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "quirks" || pkgs[0].Version != "7.18" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[1].Name != "curl" || pkgs[1].Version != "8.6.0" {
t.Errorf("unexpected second package: %+v", pkgs[1])
}
if pkgs[2].Name != "python" || pkgs[2].Version != "3.11.8p0" {
t.Errorf("unexpected third package: %+v", pkgs[2])
}
}
func TestParseUpgradeOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantPkgs []snack.Package
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\n \n",
wantLen: 0,
},
{
name: "single upgrade",
input: "bash-5.2 -> bash-5.3\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.3", Installed: true},
},
},
{
name: "line without -> is skipped",
input: "Some info line\nbash-5.2 -> bash-5.3\nAnother line\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "bash", Version: "5.3", Installed: true},
},
},
{
name: "malformed line (-> but not enough fields)",
input: "-> bash-5.3\n",
wantLen: 0,
},
{
name: "wrong arrow (=> instead of ->)",
input: "bash-5.2 => bash-5.3\n",
wantLen: 0,
},
{
name: "package name with multiple hyphens",
input: "py3-django-rest-3.14.0 -> py3-django-rest-3.15.0\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "py3-django-rest", Version: "3.15.0", Installed: true},
},
},
{
name: "p-suffix versions",
input: "python-3.11.7p0 -> python-3.11.8p1\n",
wantLen: 1,
wantPkgs: []snack.Package{
{Name: "python", Version: "3.11.8p1", Installed: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parseUpgradeOutput(tt.input)
if len(pkgs) != tt.wantLen {
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
}
for i, want := range tt.wantPkgs {
if i >= len(pkgs) {
break
}
got := pkgs[i]
if got.Name != want.Name {
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
}
if got.Version != want.Version {
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
}
if got.Installed != want.Installed {
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
}
}
})
}
}
func TestParseUpgradeOutputEmpty(t *testing.T) {
pkgs := parseUpgradeOutput("")
if len(pkgs) != 0 {
t.Fatalf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseFileListOutput(t *testing.T) {
input := `Information for curl-8.5.0:
Files:
/usr/local/bin/curl
/usr/local/include/curl/curl.h
/usr/local/lib/libcurl.so.26.0
/usr/local/man/man1/curl.1
`
files := parseFileListOutput(input)
if len(files) != 4 {
t.Fatalf("expected 4 files, got %d", len(files))
}
if files[0] != "/usr/local/bin/curl" {
t.Errorf("unexpected first file: %q", files[0])
}
}
func TestParseFileListOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
want []string
}{
{
name: "empty input",
input: "",
wantLen: 0,
},
{
name: "header only no files",
input: "Information for pkg-1.0:\n\nFiles:\n",
wantLen: 0,
},
{
name: "paths with spaces",
input: "Information for pkg-1.0:\n\nFiles:\n/usr/local/share/my dir/file name.txt\n/usr/local/share/another path/test\n",
wantLen: 2,
want: []string{
"/usr/local/share/my dir/file name.txt",
"/usr/local/share/another path/test",
},
},
{
name: "single file",
input: "Files:\n/usr/local/bin/bash\n",
wantLen: 1,
want: []string{"/usr/local/bin/bash"},
},
{
name: "no header just paths",
input: "/usr/local/bin/a\n/usr/local/bin/b\n",
wantLen: 2,
},
{
name: "blank lines between files",
input: "Files:\n/usr/local/bin/a\n\n/usr/local/bin/b\n",
wantLen: 2,
},
{
name: "non-path lines are skipped",
input: "Information for pkg-1.0:\n\nFiles:\nNot a path\n/usr/local/bin/real\n",
wantLen: 1,
want: []string{"/usr/local/bin/real"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files := parseFileListOutput(tt.input)
if len(files) != tt.wantLen {
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
}
for i, w := range tt.want {
if i >= len(files) {
break
}
if files[i] != w {
t.Errorf("[%d] got %q, want %q", i, files[i], w)
}
}
})
}
}
func TestParseFileListOutputEmpty(t *testing.T) {
files := parseFileListOutput("")
if len(files) != 0 {
t.Fatalf("expected 0 files, got %d", len(files))
}
}
func TestParseOwnerOutput(t *testing.T) {
tests := []struct {
input string
want string
}{
{"curl-8.5.0", "curl"},
{"python-3.11.7p0", "python"},
{"", ""},
}
for _, tt := range tests {
got := parseOwnerOutput(tt.input)
if got != tt.want {
t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseOwnerOutputEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty input",
input: "",
want: "",
},
{
name: "whitespace only",
input: " \n ",
want: "",
},
{
name: "name with many hyphens",
input: "py3-django-rest-framework-3.14.0",
want: "py3-django-rest-framework",
},
{
name: "no version (no hyphen)",
input: "quirks",
want: "quirks",
},
{
name: "leading/trailing whitespace",
input: " curl-8.5.0 ",
want: "curl",
},
{
name: "p-suffix version",
input: "python-3.11.7p0",
want: "python",
},
{
name: "trailing newline",
input: "bash-5.2\n",
want: "bash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseOwnerOutput(tt.input)
if got != tt.want {
t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Ports)(nil)
var _ snack.VersionQuerier = (*Ports)(nil)
var _ snack.Cleaner = (*Ports)(nil)
var _ snack.FileOwner = (*Ports)(nil)
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestPackageUpgraderInterface(t *testing.T) {
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestName(t *testing.T) {
@@ -186,3 +768,41 @@ func TestName(t *testing.T) {
t.Errorf("Name() = %q, want %q", p.Name(), "ports")
}
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
// Should be true
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
if !caps.FileOwnership {
t.Error("expected FileOwnership=true")
}
// Should be false
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

View File

@@ -2,8 +2,6 @@ package rpm
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
@@ -95,9 +93,128 @@ func TestParseArchSuffix(t *testing.T) {
}
}
// Compile-time interface checks.
var (
_ snack.Manager = (*RPM)(nil)
_ snack.FileOwner = (*RPM)(nil)
_ snack.NameNormalizer = (*RPM)(nil)
)
// --- Edge case tests ---
func TestParseListEmpty(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages from empty input, got %d", len(pkgs))
}
}
func TestParseListSinglePackage(t *testing.T) {
input := "curl\t7.76.1-23.el9\tA utility\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" {
t.Errorf("Name = %q, want curl", pkgs[0].Name)
}
}
func TestParseListNoDescription(t *testing.T) {
input := "bash\t5.1.8-6.el9\n"
pkgs := parseList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "" {
t.Errorf("Description = %q, want empty", pkgs[0].Description)
}
}
func TestParseListMalformedLines(t *testing.T) {
input := "bash\t5.1.8-6.el9\tShell\nno-tab-here\ncurl\t7.76.1\tHTTP tool\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs))
}
}
func TestParseInfoEmpty(t *testing.T) {
p := parseInfo("")
if p != nil {
t.Errorf("expected nil from empty input, got %+v", p)
}
}
func TestParseInfoNoName(t *testing.T) {
input := `Version : 1.0
Architecture: x86_64
`
p := parseInfo(input)
if p != nil {
t.Errorf("expected nil when no Name field, got %+v", p)
}
}
func TestParseInfoArchField(t *testing.T) {
// Test both "Architecture" and "Arch" key forms
input := `Name : test
Version : 1.0
Release : 1.el9
Arch : aarch64
Summary : Test package
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected non-nil package")
}
if p.Arch != "aarch64" {
t.Errorf("Arch = %q, want aarch64", p.Arch)
}
}
func TestNormalizeNameEdgeCases(t *testing.T) {
tests := []struct {
input, want string
}{
{"", ""},
{"pkg.unknown.ext", "pkg.unknown.ext"},
{"name.with.dots.x86_64", "name.with.dots"},
{"python3.11", "python3.11"},
{"glibc.s390x", "glibc"},
{"kernel.src", "kernel"},
{".x86_64", ""},
{"pkg.ppc64le", "pkg"},
{"pkg.armv7hl", "pkg"},
{"pkg.i386", "pkg"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArchSuffixEdgeCases(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"", "", ""},
{"pkg.i386", "pkg", "i386"},
{"pkg.ppc64le", "pkg", "ppc64le"},
{"pkg.s390x", "pkg", "s390x"},
{"pkg.armv7hl", "pkg", "armv7hl"},
{"pkg.src", "pkg", "src"},
{"pkg.aarch64", "pkg", "aarch64"},
{"pkg.noarch", "pkg", "noarch"},
{"pkg.unknown", "pkg.unknown", ""},
{"name.with.many.dots.noarch", "name.with.many.dots", "noarch"},
{".noarch", "", "noarch"},
{"pkg.x86_64.extra", "pkg.x86_64.extra", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
name, arch := parseArchSuffix(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
tt.input, name, arch, tt.wantName, tt.wantArch)
}
})
}
}

88
rpm/rpm_test.go Normal file
View File

@@ -0,0 +1,88 @@
package rpm
import (
"testing"
"github.com/gogrlx/snack"
)
// Compile-time interface assertions.
var (
_ snack.Manager = (*RPM)(nil)
_ snack.FileOwner = (*RPM)(nil)
_ snack.NameNormalizer = (*RPM)(nil)
)
func TestName(t *testing.T) {
r := New()
if got := r.Name(); got != "rpm" {
t.Errorf("Name() = %q, want %q", got, "rpm")
}
}
func TestGetCapabilities(t *testing.T) {
r := New()
caps := snack.GetCapabilities(r)
wantTrue := map[string]bool{
"FileOwnership": caps.FileOwnership,
"NameNormalize": caps.NameNormalize,
}
for name, got := range wantTrue {
t.Run(name+"_true", func(t *testing.T) {
if !got {
t.Errorf("Capabilities.%s = false, want true", name)
}
})
}
wantFalse := map[string]bool{
"VersionQuery": caps.VersionQuery,
"Hold": caps.Hold,
"Clean": caps.Clean,
"RepoManagement": caps.RepoManagement,
"KeyManagement": caps.KeyManagement,
"Groups": caps.Groups,
"DryRun": caps.DryRun,
"PackageUpgrade": caps.PackageUpgrade,
}
for name, got := range wantFalse {
t.Run(name+"_false", func(t *testing.T) {
if got {
t.Errorf("Capabilities.%s = true, want false", name)
}
})
}
}
func TestNormalizeNameMethod(t *testing.T) {
r := New()
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl", "curl"},
{"bash.noarch", "bash"},
}
for _, tt := range tests {
if got := r.NormalizeName(tt.input); got != tt.want {
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArchMethod(t *testing.T) {
r := New()
name, arch := r.ParseArch("nginx.x86_64")
if name != "nginx" || arch != "x86_64" {
t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch)
}
}
// RPM should NOT implement DryRunner.
func TestNotDryRunner(t *testing.T) {
r := New()
if _, ok := interface{}(r).(snack.DryRunner); ok {
t.Error("RPM should not implement DryRunner")
}
}

View File

@@ -7,7 +7,11 @@ import (
)
// Compile-time interface checks.
var _ snack.VersionQuerier = (*Snap)(nil)
var (
_ snack.VersionQuerier = (*Snap)(nil)
_ snack.Cleaner = (*Snap)(nil)
_ snack.NameNormalizer = (*Snap)(nil)
)
// LatestVersion returns the latest stable version of a snap.
func (s *Snap) LatestVersion(ctx context.Context, pkg string) (string, error) {
@@ -28,3 +32,15 @@ func (s *Snap) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove is a no-op for snap (snap doesn't have orphan packages).
func (s *Snap) Autoremove(_ context.Context, _ ...snack.Option) error {
return nil
}
// Clean removes old snap revisions to free up space.
func (s *Snap) Clean(ctx context.Context) error {
s.Lock()
defer s.Unlock()
return clean(ctx)
}

15
snap/normalize.go Normal file
View File

@@ -0,0 +1,15 @@
package snap
// normalizeName returns the canonical form of a snap name.
// Snap package names are simple identifiers without architecture or version
// suffixes, so this is essentially a pass-through.
func normalizeName(name string) string {
return name
}
// parseArch extracts the architecture from a snap name if present.
// Snap package names do not include architecture suffixes,
// so this returns the name unchanged with an empty string.
func parseArch(name string) (string, string) {
return name, ""
}

View File

@@ -81,6 +81,16 @@ func (s *Snap) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a snap name.
func (s *Snap) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a snap name if present.
func (s *Snap) ParseArch(name string) (string, string) {
return parseArch(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Snap)(nil)
var _ snack.PackageUpgrader = (*Snap)(nil)

View File

@@ -24,12 +24,12 @@ func TestIntegration_Snap(t *testing.T) {
caps := snack.GetCapabilities(mgr)
assert.True(t, caps.VersionQuery, "snap should support VersionQuery")
assert.False(t, caps.Hold)
assert.False(t, caps.Clean)
assert.True(t, caps.Clean, "snap should support 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.True(t, caps.NameNormalize, "snap should support NameNormalize")
t.Run("Update", func(t *testing.T) {
require.NoError(t, mgr.Update(ctx))

View File

@@ -232,6 +232,26 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func clean(ctx context.Context) error {
// Remove disabled snap revisions to free up space
out, err := run(ctx, []string{"list", "--all"})
if err != nil {
return err
}
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, "disabled") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 3 {
name := fields[0]
rev := fields[2]
_, _ = run(ctx, []string{"remove", name, "--revision=" + rev})
}
}
return nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target
@@ -251,14 +271,9 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
toUpgrade = append(toUpgrade, t)
}
}
if len(toUpgrade) > 0 {
for _, t := range toUpgrade {
cmd := exec.CommandContext(ctx, "snap", "refresh", t.Name)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w: %s", t.Name, err, stderr.String())
}
for _, t := range toUpgrade {
if _, err := run(ctx, []string{"refresh", t.Name}); err != nil {
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w", t.Name, err)
}
}
var upgraded []snack.Package

View File

@@ -66,6 +66,10 @@ func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}

View File

@@ -35,6 +35,56 @@ func TestParseSnapListEmpty(t *testing.T) {
}
}
func TestParseSnapListEdgeCases(t *testing.T) {
t.Run("single entry", func(t *testing.T) {
input := `Name Version Rev Tracking Publisher Notes
core22 20240111 1122 latest/stable canonical✓ base
`
pkgs := parseSnapList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "core22" {
t.Errorf("expected core22, got %q", pkgs[0].Name)
}
})
t.Run("header only no trailing newline", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single field line skipped", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\nsinglefield\n"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=2 fields), got %d", len(pkgs))
}
})
t.Run("extra whitespace lines", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\n \n\n"
pkgs := parseSnapList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("many columns", func(t *testing.T) {
input := "Name Version Rev Tracking Publisher Notes\nsnap1 2.0 100 latest/stable pub note extra more\n"
pkgs := parseSnapList(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "snap1" || pkgs[0].Version != "2.0" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
})
}
func TestParseSnapFind(t *testing.T) {
input := `Name Version Publisher Notes Summary
firefox 131.0 mozilla✓ - Mozilla Firefox web browser
@@ -52,6 +102,67 @@ chromium 129.0 nickvdp - Chromium web browser
}
}
func TestParseSnapFindEdgeCases(t *testing.T) {
t.Run("single result", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfirefox 131.0 mozilla - Web browser\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "firefox" {
t.Errorf("expected firefox, got %q", pkgs[0].Name)
}
if pkgs[0].Description != "Web browser" {
t.Errorf("expected 'Web browser', got %q", pkgs[0].Description)
}
})
t.Run("header only", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("too few fields skipped", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=4 fields), got %d", len(pkgs))
}
})
t.Run("exactly four fields no summary", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub note\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("multi-word summary", func(t *testing.T) {
input := "Name Version Publisher Notes Summary\nmysnap 2.0 me - A very long description with many words\n"
pkgs := parseSnapFind(input)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Description != "A very long description with many words" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
t.Run("empty input", func(t *testing.T) {
pkgs := parseSnapFind("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
}
func TestParseSnapInfo(t *testing.T) {
input := `name: firefox
summary: Mozilla Firefox web browser
@@ -84,6 +195,57 @@ func TestParseSnapInfoEmpty(t *testing.T) {
}
}
func TestParseSnapInfoEdgeCases(t *testing.T) {
t.Run("not installed snap", func(t *testing.T) {
input := `name: hello-world
summary: A simple hello world snap
publisher: Canonical✓ (canonical✓)
snap-id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ
`
pkg := parseSnapInfo(input)
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "hello-world" {
t.Errorf("expected hello-world, got %q", pkg.Name)
}
if pkg.Installed {
t.Error("expected Installed=false for snap without installed field")
}
if pkg.Version != "" {
t.Errorf("expected empty version, got %q", pkg.Version)
}
})
t.Run("name only", func(t *testing.T) {
pkg := parseSnapInfo("name: test\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Name != "test" {
t.Errorf("expected test, got %q", pkg.Name)
}
})
t.Run("no name returns nil", func(t *testing.T) {
pkg := parseSnapInfo("summary: something\ninstalled: 1.0 (1) 10MB\n")
if pkg != nil {
t.Error("expected nil when no name field")
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
input := "name: mysnap\nrandom text without colon\nsummary: A snap\n"
pkg := parseSnapInfo(input)
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A snap" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}
func TestParseSnapInfoVersion(t *testing.T) {
input := `name: firefox
channels:
@@ -105,6 +267,39 @@ func TestParseSnapInfoVersionMissing(t *testing.T) {
}
}
func TestParseSnapInfoVersionEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
ver := parseSnapInfoVersion("")
if ver != "" {
t.Errorf("expected empty, got %q", ver)
}
})
t.Run("stable channel with dashes", func(t *testing.T) {
input := " latest/stable: 2024.01.15 2024-01-15 (100) 50MB -\n"
ver := parseSnapInfoVersion(input)
if ver != "2024.01.15" {
t.Errorf("expected 2024.01.15, got %q", ver)
}
})
t.Run("closed channel marked --", func(t *testing.T) {
input := " latest/stable: --\n"
ver := parseSnapInfoVersion(input)
if ver != "" {
t.Errorf("expected empty for closed channel, got %q", ver)
}
})
t.Run("closed channel marked ^", func(t *testing.T) {
input := " latest/stable: ^ \n"
ver := parseSnapInfoVersion(input)
if ver != "" {
t.Errorf("expected empty for ^ channel, got %q", ver)
}
})
}
func TestParseSnapRefreshList(t *testing.T) {
input := `Name Version Rev Publisher Notes
firefox 132.0 4650 mozilla✓ -
@@ -128,30 +323,130 @@ All snaps up to date.
}
}
func TestParseSnapRefreshListEdgeCases(t *testing.T) {
t.Run("multiple upgrades", func(t *testing.T) {
input := "Name Version Rev Publisher Notes\nfoo 2.0 10 pub -\nbar 3.0 20 pub -\n"
pkgs := parseSnapRefreshList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "foo" || pkgs[1].Name != "bar" {
t.Errorf("unexpected packages: %+v, %+v", pkgs[0], pkgs[1])
}
})
t.Run("header only", func(t *testing.T) {
input := "Name Version Rev Publisher Notes\n"
pkgs := parseSnapRefreshList(input)
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
}
func TestSemverCmp(t *testing.T) {
tests := []struct {
name string
a, b string
want int
}{
{"1.0.0", "1.0.0", 0},
{"1.0.0", "2.0.0", -1},
{"2.0.0", "1.0.0", 1},
{"1.2.3", "1.2.4", -1},
{"1.10.0", "1.9.0", 1},
{"1.0", "1.0.0", 0},
{"131.0", "132.0", -1},
{"equal", "1.0.0", "1.0.0", 0},
{"less major", "1.0.0", "2.0.0", -1},
{"greater major", "2.0.0", "1.0.0", 1},
{"less patch", "1.2.3", "1.2.4", -1},
{"multi-digit minor", "1.10.0", "1.9.0", 1},
{"short vs long equal", "1.0", "1.0.0", 0},
{"real versions", "131.0", "132.0", -1},
// Edge cases
{"single component", "5", "3", 1},
{"single equal", "3", "3", 0},
{"empty vs empty", "", "", 0},
{"empty vs version", "", "1.0", -1},
{"version vs empty", "1.0", "", 1},
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
{"four components", "1.2.3.4", "1.2.3.5", -1},
{"different lengths padded", "1.0.0.0", "1.0.0", 0},
{"short less", "1.0", "1.0.1", -1},
{"short greater", "1.1", "1.0.9", 1},
}
for _, tt := range tests {
got := semverCmp(tt.a, tt.b)
if got != tt.want {
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
t.Run(tt.name, func(t *testing.T) {
got := semverCmp(tt.a, tt.b)
if got != tt.want {
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestStripNonNumeric(t *testing.T) {
tests := []struct {
input string
want string
}{
{"123", "123"},
{"123abc", "123"},
{"abc", ""},
{"0beta", "0"},
{"", ""},
{"42-rc1", "42"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripNonNumeric(tt.input)
if got != tt.want {
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Snap)(nil)
var _ snack.VersionQuerier = (*Snap)(nil)
var _ snack.Cleaner = (*Snap)(nil)
var _ snack.PackageUpgrader = (*Snap)(nil)
}
// Compile-time interface checks
var (
_ snack.Cleaner = (*Snap)(nil)
_ snack.PackageUpgrader = (*Snap)(nil)
)
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.Clean {
t.Error("expected Clean=true")
}
// Should be false
if caps.FileOwnership {
t.Error("expected FileOwnership=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.RepoManagement {
t.Error("expected RepoManagement=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {

49
winget/capabilities.go Normal file
View File

@@ -0,0 +1,49 @@
package winget
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*Winget)(nil)
_ snack.RepoManager = (*Winget)(nil)
_ snack.NameNormalizer = (*Winget)(nil)
)
// LatestVersion returns the latest available version of a package.
func (w *Winget) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (w *Winget) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (w *Winget) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings.
func (w *Winget) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// ListRepos returns configured winget sources.
func (w *Winget) ListRepos(ctx context.Context) ([]snack.Repository, error) {
return sourceList(ctx)
}
// AddRepo adds a new winget source.
func (w *Winget) AddRepo(ctx context.Context, repo snack.Repository) error {
return sourceAdd(ctx, repo)
}
// RemoveRepo removes a configured winget source.
func (w *Winget) RemoveRepo(ctx context.Context, id string) error {
return sourceRemove(ctx, id)
}

19
winget/normalize.go Normal file
View File

@@ -0,0 +1,19 @@
package winget
import "strings"
// normalizeName returns the canonical form of a winget package ID.
// Winget IDs use dot-separated Publisher.Package format (e.g.
// "Microsoft.VisualStudioCode"). This trims whitespace but otherwise
// preserves the ID as-is since winget IDs are case-insensitive but
// conventionally PascalCase.
func normalizeName(name string) string {
return strings.TrimSpace(name)
}
// parseArch extracts the architecture from a winget package name if present.
// Winget IDs do not include architecture suffixes, so this returns the
// name unchanged with an empty string.
func parseArch(name string) (string, string) {
return name, ""
}

333
winget/parse.go Normal file
View File

@@ -0,0 +1,333 @@
package winget
import (
"strconv"
"strings"
"github.com/gogrlx/snack"
)
// parseTable parses winget tabular output (list, search, upgrade).
//
// Winget uses fixed-width columns whose positions are determined by a
// header row with dashes (e.g. "---- ------ -------").
// The header names vary by locale, so we detect columns positionally.
//
// Typical `winget list` output:
//
// Name Id Version Available Source
// --------------------------------------------------------------
// Visual Studio Microsoft.VisualStudio 17.8.0 17.9.0 winget
//
// Typical `winget search` output:
//
// Name Id Version Match Source
// --------------------------------------------------------------
// Visual Studio Microsoft.VisualStudio 17.9.0 winget
//
// When installed is true, we mark all parsed packages as Installed.
func parseTable(output string, installed bool) []snack.Package {
lines := strings.Split(output, "\n")
// Find the separator line (all dashes/spaces) to determine column positions.
sepIdx := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if isSeparatorLine(trimmed) {
sepIdx = i
break
}
}
if sepIdx < 1 {
return nil
}
// Use the header line (just above separator) to determine column starts.
header := lines[sepIdx-1]
cols := detectColumns(header)
if len(cols) < 2 {
return nil
}
var pkgs []snack.Package
for _, line := range lines[sepIdx+1:] {
if strings.TrimSpace(line) == "" {
continue
}
// Skip footer lines like "X upgrades available."
if isFooterLine(line) {
continue
}
fields := extractFields(line, cols)
if len(fields) < 2 {
continue
}
pkg := snack.Package{
Name: fields[0],
Installed: installed,
}
// Column order: Name, Id, Version, [Available], [Source]
// For search: Name, Id, Version, [Match], [Source]
if len(fields) >= 3 {
pkg.Version = fields[2]
}
// Use the ID as the package name for consistency (winget uses
// Publisher.Package IDs as the canonical identifier).
if len(fields) >= 2 && fields[1] != "" {
pkg.Description = fields[0] // keep display name as description
pkg.Name = fields[1]
}
// If there's a Source column (typically the last), use it.
if len(fields) >= 5 && fields[len(fields)-1] != "" {
pkg.Repository = fields[len(fields)-1]
}
pkgs = append(pkgs, pkg)
}
return pkgs
}
// isSeparatorLine returns true if the line is a column separator
// (composed entirely of dashes and spaces, with at least some dashes).
func isSeparatorLine(line string) bool {
hasDash := false
for _, c := range line {
switch c {
case '-':
hasDash = true
case ' ', '\t':
// allowed
default:
return false
}
}
return hasDash
}
// detectColumns returns the starting index of each column based on
// the header line. Columns are separated by 2+ spaces.
func detectColumns(header string) []int {
var cols []int
inWord := false
for i, c := range header {
if c != ' ' && c != '\t' {
if !inWord {
cols = append(cols, i)
inWord = true
}
} else {
// Need at least 2 spaces to end a column
if inWord && i+1 < len(header) && (header[i+1] == ' ' || header[i+1] == '\t') {
inWord = false
} else if inWord && i+1 >= len(header) {
inWord = false
}
}
}
return cols
}
// extractFields splits a data line according to detected column positions.
func extractFields(line string, cols []int) []string {
fields := make([]string, len(cols))
for i, start := range cols {
if start >= len(line) {
break
}
end := len(line)
if i+1 < len(cols) {
end = cols[i+1]
if end > len(line) {
end = len(line)
}
}
fields[i] = strings.TrimSpace(line[start:end])
}
return fields
}
// isFooterLine returns true for winget output footer lines.
func isFooterLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "upgrades available") ||
strings.Contains(lower, "package(s)") ||
strings.Contains(lower, "installed package") ||
strings.HasPrefix(lower, "the following") {
return true
}
return false
}
// parseShow parses `winget show` key-value output into a Package.
//
// Typical output:
//
// Found Visual Studio Code [Microsoft.VisualStudioCode]
// Version: 1.85.0
// Publisher: Microsoft Corporation
// Description: Code editing. Redefined.
// ...
func parseShow(output string) *snack.Package {
pkg := &snack.Package{}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Parse "Found <Name> [<Id>]" header line.
if strings.HasPrefix(line, "Found ") {
if idx := strings.LastIndex(line, "["); idx > 0 {
endIdx := strings.LastIndex(line, "]")
if endIdx > idx {
pkg.Name = strings.TrimSpace(line[idx+1 : endIdx])
pkg.Description = strings.TrimSpace(line[6:idx])
}
}
continue
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
switch strings.ToLower(key) {
case "version":
pkg.Version = val
case "description":
if pkg.Description == "" {
pkg.Description = val
}
case "publisher":
// informational only
}
}
if pkg.Name == "" {
return nil
}
return pkg
}
// parseSourceList parses `winget source list` output into Repositories.
//
// Output format:
//
// Name Argument
// ----------------------------------------------
// winget https://cdn.winget.microsoft.com/cache
// msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
func parseSourceList(output string) []snack.Repository {
lines := strings.Split(output, "\n")
sepIdx := -1
for i, line := range lines {
if isSeparatorLine(strings.TrimSpace(line)) {
sepIdx = i
break
}
}
if sepIdx < 0 {
return nil
}
var repos []snack.Repository
for _, line := range lines[sepIdx+1:] {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) < 2 {
continue
}
repos = append(repos, snack.Repository{
ID: fields[0],
Name: fields[0],
URL: fields[1],
Enabled: true,
})
}
return repos
}
// stripVT removes ANSI/VT100 escape sequences from a string.
func stripVT(s string) string {
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
// Skip CSI sequence: ESC [ ... final byte
j := i + 2
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
j++
}
if j < len(s) {
j++ // skip final byte
}
i = j
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
// isProgressLine returns true for lines that are only progress indicators.
func isProgressLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
// Lines containing block characters and/or percentage
hasBlock := strings.ContainsAny(trimmed, "█▓░")
hasPercent := strings.Contains(trimmed, "%")
if hasBlock || (hasPercent && len(trimmed) < 40) {
return true
}
return false
}
// semverCmp does a basic semver-ish comparison.
// Returns -1 if a < b, 0 if equal, 1 if a > b.
func semverCmp(a, b string) int {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
maxLen := len(partsA)
if len(partsB) > maxLen {
maxLen = len(partsB)
}
for i := 0; i < maxLen; i++ {
var numA, numB int
if i < len(partsA) {
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
}
if i < len(partsB) {
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
}
if numA < numB {
return -1
}
if numA > numB {
return 1
}
}
return 0
}
// stripNonNumeric keeps only leading digits from a string.
func stripNonNumeric(s string) string {
for i, c := range s {
if c < '0' || c > '9' {
return s[:i]
}
}
return s
}

103
winget/winget.go Normal file
View File

@@ -0,0 +1,103 @@
// Package winget provides Go bindings for the Windows Package Manager (winget).
package winget
import (
"context"
"github.com/gogrlx/snack"
)
// Winget wraps the winget CLI.
type Winget struct {
snack.Locker
}
// New returns a new Winget manager.
func New() *Winget {
return &Winget{}
}
// Name returns "winget".
func (w *Winget) Name() string { return "winget" }
// Available reports whether winget is present on the system.
func (w *Winget) Available() bool { return available() }
// Install one or more packages.
func (w *Winget) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
w.Lock()
defer w.Unlock()
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (w *Winget) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
w.Lock()
defer w.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including configuration data.
func (w *Winget) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
w.Lock()
defer w.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade all installed packages.
func (w *Winget) Upgrade(ctx context.Context, opts ...snack.Option) error {
w.Lock()
defer w.Unlock()
return upgrade(ctx, opts...)
}
// Update refreshes the winget source index.
func (w *Winget) Update(ctx context.Context) error {
return update(ctx)
}
// List returns all installed packages.
func (w *Winget) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the winget repository for packages matching the query.
func (w *Winget) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (w *Winget) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (w *Winget) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (w *Winget) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// NormalizeName returns the canonical form of a winget package ID.
func (w *Winget) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (w *Winget) ParseArch(name string) (string, string) {
return parseArch(name)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*Winget)(nil)
var _ snack.PackageUpgrader = (*Winget)(nil)
// UpgradePackages upgrades specific installed packages.
func (w *Winget) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
w.Lock()
defer w.Unlock()
return upgradePackages(ctx, pkgs, opts...)
}

View File

@@ -0,0 +1,140 @@
//go:build integration
package winget_test
import (
"context"
"testing"
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/winget"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIntegration_Winget(t *testing.T) {
var mgr snack.Manager = winget.New()
if !mgr.Available() {
t.Skip("winget not available")
}
ctx := context.Background()
assert.Equal(t, "winget", mgr.Name())
caps := snack.GetCapabilities(mgr)
assert.True(t, caps.VersionQuery, "winget should support VersionQuery")
assert.True(t, caps.RepoManagement, "winget should support RepoManagement")
assert.True(t, caps.NameNormalize, "winget should support NameNormalize")
assert.True(t, caps.PackageUpgrade, "winget should support PackageUpgrade")
assert.False(t, caps.Hold)
assert.False(t, caps.Clean)
assert.False(t, caps.FileOwnership)
assert.False(t, caps.KeyManagement)
assert.False(t, caps.Groups)
assert.False(t, caps.DryRun)
t.Run("Update", func(t *testing.T) {
require.NoError(t, mgr.Update(ctx))
})
t.Run("Search", func(t *testing.T) {
pkgs, err := mgr.Search(ctx, "Microsoft.PowerToys")
require.NoError(t, err)
require.NotEmpty(t, pkgs)
})
t.Run("Search_NoResults", func(t *testing.T) {
pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz")
require.NoError(t, err)
assert.Empty(t, pkgs)
})
t.Run("Info", func(t *testing.T) {
pkg, err := mgr.Info(ctx, "Microsoft.PowerToys")
require.NoError(t, err)
require.NotNil(t, pkg)
assert.Equal(t, "Microsoft.PowerToys", pkg.Name)
assert.NotEmpty(t, pkg.Version)
})
t.Run("Info_NotFound", func(t *testing.T) {
_, err := mgr.Info(ctx, "xyznonexistentpackage999.notreal")
assert.Error(t, err)
})
t.Run("List", func(t *testing.T) {
pkgs, err := mgr.List(ctx)
require.NoError(t, err)
t.Logf("installed packages: %d", len(pkgs))
})
// --- VersionQuerier ---
t.Run("VersionQuerier", func(t *testing.T) {
vq, ok := mgr.(snack.VersionQuerier)
require.True(t, ok)
t.Run("LatestVersion", func(t *testing.T) {
ver, err := vq.LatestVersion(ctx, "Microsoft.PowerToys")
require.NoError(t, err)
assert.NotEmpty(t, ver)
t.Logf("PowerToys latest: %s", ver)
})
t.Run("LatestVersion_NotFound", func(t *testing.T) {
_, err := vq.LatestVersion(ctx, "xyznonexistentpackage999.notreal")
assert.Error(t, err)
})
t.Run("ListUpgrades", func(t *testing.T) {
pkgs, err := vq.ListUpgrades(ctx)
require.NoError(t, err)
t.Logf("upgradable packages: %d", len(pkgs))
})
t.Run("VersionCmp", func(t *testing.T) {
tests := []struct {
v1, v2 string
want int
}{
{"1.0", "2.0", -1},
{"2.0", "1.0", 1},
{"1.0", "1.0", 0},
{"1.0.1", "1.0.0", 1},
}
for _, tt := range tests {
cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2)
require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2)
assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2)
}
})
})
// --- RepoManager ---
t.Run("RepoManager", func(t *testing.T) {
rm, ok := mgr.(snack.RepoManager)
require.True(t, ok)
t.Run("ListRepos", func(t *testing.T) {
repos, err := rm.ListRepos(ctx)
require.NoError(t, err)
require.NotEmpty(t, repos, "should have at least winget and msstore sources")
t.Logf("sources: %d", len(repos))
for _, r := range repos {
t.Logf(" %s -> %s", r.Name, r.URL)
}
})
})
// --- NameNormalizer ---
t.Run("NameNormalizer", func(t *testing.T) {
nn, ok := mgr.(snack.NameNormalizer)
require.True(t, ok)
name := nn.NormalizeName(" Microsoft.VisualStudioCode ")
assert.Equal(t, "Microsoft.VisualStudioCode", name)
n, arch := nn.ParseArch("Microsoft.VisualStudioCode")
assert.Equal(t, "Microsoft.VisualStudioCode", n)
assert.Empty(t, arch)
})
}

83
winget/winget_other.go Normal file
View File

@@ -0,0 +1,83 @@
//go:build !windows
package winget
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
}
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
}
func sourceList(_ context.Context) ([]snack.Repository, error) {
return nil, snack.ErrUnsupportedPlatform
}
func sourceAdd(_ context.Context, _ snack.Repository) error {
return snack.ErrUnsupportedPlatform
}
func sourceRemove(_ context.Context, _ string) error {
return snack.ErrUnsupportedPlatform
}

449
winget/winget_test.go Normal file
View File

@@ -0,0 +1,449 @@
package winget
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseTableList(t *testing.T) {
input := `Name Id Version Available Source
---------------------------------------------------------------------------------------------------------
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
Git Git.Git 2.43.0 winget
Google Chrome Google.Chrome 120.0.6099.130 winget
`
pkgs := parseTable(input, true)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
t.Errorf("expected ID as name, got %q", pkgs[0].Name)
}
if pkgs[0].Description != "Visual Studio Code" {
t.Errorf("expected display name as description, got %q", pkgs[0].Description)
}
if pkgs[0].Version != "1.85.0" {
t.Errorf("expected version '1.85.0', got %q", pkgs[0].Version)
}
if !pkgs[0].Installed {
t.Error("expected Installed=true")
}
if pkgs[0].Repository != "winget" {
t.Errorf("expected repository 'winget', got %q", pkgs[0].Repository)
}
if pkgs[1].Name != "Git.Git" {
t.Errorf("expected 'Git.Git', got %q", pkgs[1].Name)
}
}
func TestParseTableSearch(t *testing.T) {
input := `Name Id Version Match Source
-------------------------------------------------------------------------------------------------
Visual Studio Code Microsoft.VisualStudioCode 1.86.0 Moniker: vscode winget
VSCodium VSCodium.VSCodium 1.85.2 winget
`
pkgs := parseTable(input, false)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
}
if pkgs[0].Installed {
t.Error("expected Installed=false for search")
}
}
func TestParseTableEmpty(t *testing.T) {
input := `Name Id Version Source
------------------------------
`
pkgs := parseTable(input, true)
if len(pkgs) != 0 {
t.Fatalf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseTableNoSeparator(t *testing.T) {
pkgs := parseTable("No installed package found matching input criteria.", true)
if len(pkgs) != 0 {
t.Fatalf("expected 0 packages, got %d", len(pkgs))
}
}
func TestParseTableWithFooter(t *testing.T) {
input := `Name Id Version Available Source
--------------------------------------------------------------
Git Git.Git 2.43.0 2.44.0 winget
3 upgrades available.
`
pkgs := parseTable(input, false)
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "Git.Git" {
t.Errorf("expected 'Git.Git', got %q", pkgs[0].Name)
}
}
func TestParseTableUpgrade(t *testing.T) {
input := `Name Id Version Available Source
---------------------------------------------------------------------------------------------------------
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
Node.js OpenJS.NodeJS 20.10.0 21.5.0 winget
2 upgrades available.
`
pkgs := parseTable(input, false)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
}
if pkgs[1].Name != "OpenJS.NodeJS" {
t.Errorf("expected 'OpenJS.NodeJS', got %q", pkgs[1].Name)
}
}
func TestParseShow(t *testing.T) {
input := `Found Visual Studio Code [Microsoft.VisualStudioCode]
Version: 1.86.0
Publisher: Microsoft Corporation
Publisher URL: https://code.visualstudio.com
Author: Microsoft Corporation
Moniker: vscode
Description: Code editing. Redefined.
License: MIT
Installer Type: inno
`
pkg := parseShow(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if pkg.Name != "Microsoft.VisualStudioCode" {
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkg.Name)
}
if pkg.Version != "1.86.0" {
t.Errorf("expected version '1.86.0', got %q", pkg.Version)
}
if pkg.Description != "Visual Studio Code" {
t.Errorf("expected 'Visual Studio Code', got %q", pkg.Description)
}
}
func TestParseShowNotFound(t *testing.T) {
pkg := parseShow("No package found matching input criteria.")
if pkg != nil {
t.Error("expected nil for not-found output")
}
}
func TestParseShowEmpty(t *testing.T) {
pkg := parseShow("")
if pkg != nil {
t.Error("expected nil for empty input")
}
}
func TestParseShowNoID(t *testing.T) {
input := `Version: 1.0.0
Publisher: Someone
`
pkg := parseShow(input)
if pkg != nil {
t.Error("expected nil when no Found header with ID")
}
}
func TestParseSourceList(t *testing.T) {
input := `Name Argument
--------------------------------------------------------------
winget https://cdn.winget.microsoft.com/cache
msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
`
repos := parseSourceList(input)
if len(repos) != 2 {
t.Fatalf("expected 2 repos, got %d", len(repos))
}
if repos[0].Name != "winget" {
t.Errorf("expected 'winget', got %q", repos[0].Name)
}
if repos[0].URL != "https://cdn.winget.microsoft.com/cache" {
t.Errorf("unexpected URL: %q", repos[0].URL)
}
if !repos[0].Enabled {
t.Error("expected Enabled=true")
}
if repos[1].Name != "msstore" {
t.Errorf("expected 'msstore', got %q", repos[1].Name)
}
}
func TestParseSourceListEmpty(t *testing.T) {
repos := parseSourceList("")
if len(repos) != 0 {
t.Fatalf("expected 0 repos, got %d", len(repos))
}
}
func TestIsSeparatorLine(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"---", true},
{"--------------------------------------------------------------", true},
{"--- --- ---", true},
{"Name Id Version", false},
{"", false},
{" ", false},
{"--a--", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isSeparatorLine(tt.input)
if got != tt.want {
t.Errorf("isSeparatorLine(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestDetectColumns(t *testing.T) {
header := "Name Id Version Available Source"
cols := detectColumns(header)
if len(cols) != 5 {
t.Fatalf("expected 5 columns, got %d: %v", len(cols), cols)
}
if cols[0] != 0 {
t.Errorf("expected first column at 0, got %d", cols[0])
}
}
func TestExtractFields(t *testing.T) {
line := "Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget"
cols := []int{0, 34, 67, 82, 93}
fields := extractFields(line, cols)
if len(fields) != 5 {
t.Fatalf("expected 5 fields, got %d", len(fields))
}
if fields[0] != "Visual Studio Code" {
t.Errorf("field[0] = %q, want 'Visual Studio Code'", fields[0])
}
if fields[1] != "Microsoft.VisualStudioCode" {
t.Errorf("field[1] = %q, want 'Microsoft.VisualStudioCode'", fields[1])
}
if fields[2] != "1.85.0" {
t.Errorf("field[2] = %q, want '1.85.0'", fields[2])
}
if fields[3] != "1.86.0" {
t.Errorf("field[3] = %q, want '1.86.0'", fields[3])
}
if fields[4] != "winget" {
t.Errorf("field[4] = %q, want 'winget'", fields[4])
}
}
func TestExtractFieldsShortLine(t *testing.T) {
line := "Git"
cols := []int{0, 34, 67}
fields := extractFields(line, cols)
if fields[0] != "Git" {
t.Errorf("field[0] = %q, want 'Git'", fields[0])
}
if fields[1] != "" {
t.Errorf("field[1] = %q, want ''", fields[1])
}
}
func TestIsFooterLine(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"3 upgrades available.", true},
{"1 package(s) found.", true},
{"No installed package found.", true},
{"The following packages have upgrades:", true},
{"Git Git.Git 2.43.0", false},
{"", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isFooterLine(tt.input)
if got != tt.want {
t.Errorf("isFooterLine(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestSemverCmp(t *testing.T) {
tests := []struct {
name string
a, b string
want int
}{
{"equal", "1.0.0", "1.0.0", 0},
{"less major", "1.0.0", "2.0.0", -1},
{"greater major", "2.0.0", "1.0.0", 1},
{"less patch", "1.2.3", "1.2.4", -1},
{"multi-digit minor", "1.10.0", "1.9.0", 1},
{"short vs long equal", "1.0", "1.0.0", 0},
{"real versions", "1.85.0", "1.86.0", -1},
{"single component", "5", "3", 1},
{"empty vs empty", "", "", 0},
{"empty vs version", "", "1.0", -1},
{"version vs empty", "1.0", "", 1},
{"four components", "120.0.6099.130", "120.0.6099.131", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := semverCmp(tt.a, tt.b)
if got != tt.want {
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestStripNonNumeric(t *testing.T) {
tests := []struct {
input string
want string
}{
{"123", "123"},
{"123abc", "123"},
{"abc", ""},
{"0beta", "0"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripNonNumeric(tt.input)
if got != tt.want {
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*Winget)(nil)
var _ snack.VersionQuerier = (*Winget)(nil)
var _ snack.RepoManager = (*Winget)(nil)
var _ snack.NameNormalizer = (*Winget)(nil)
var _ snack.PackageUpgrader = (*Winget)(nil)
}
func TestCapabilities(t *testing.T) {
caps := snack.GetCapabilities(New())
if !caps.VersionQuery {
t.Error("expected VersionQuery=true")
}
if !caps.RepoManagement {
t.Error("expected RepoManagement=true")
}
if !caps.NameNormalize {
t.Error("expected NameNormalize=true")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
// Should be false
if caps.Clean {
t.Error("expected Clean=false")
}
if caps.FileOwnership {
t.Error("expected FileOwnership=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if caps.Hold {
t.Error("expected Hold=false")
}
if caps.KeyManagement {
t.Error("expected KeyManagement=false")
}
if caps.Groups {
t.Error("expected Groups=false")
}
}
func TestName(t *testing.T) {
w := New()
if w.Name() != "winget" {
t.Errorf("Name() = %q, want %q", w.Name(), "winget")
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Microsoft.VisualStudioCode", "Microsoft.VisualStudioCode"},
{" Git.Git ", "Git.Git"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParseArch(t *testing.T) {
name, arch := parseArch("Microsoft.VisualStudioCode")
if name != "Microsoft.VisualStudioCode" || arch != "" {
t.Errorf("parseArch returned (%q, %q), want (%q, %q)",
name, arch, "Microsoft.VisualStudioCode", "")
}
}
func TestStripVT(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"no escapes", "hello", "hello"},
{"simple CSI", "\x1b[2Khello", "hello"},
{"color code", "\x1b[32mgreen\x1b[0m", "green"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripVT(tt.input)
if got != tt.want {
t.Errorf("stripVT(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestIsProgressLine(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"████████ 50%", true},
{"100%", true},
{"Git Git.Git 2.43.0", false},
{"", false},
{"Installing...", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isProgressLine(tt.input)
if got != tt.want {
t.Errorf("isProgressLine(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

360
winget/winget_windows.go Normal file
View File

@@ -0,0 +1,360 @@
//go:build windows
package winget
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("winget")
return err == nil
}
// commonArgs returns flags used by all winget commands for non-interactive operation.
func commonArgs() []string {
return []string{
"--accept-source-agreements",
"--disable-interactivity",
}
}
func run(ctx context.Context, args []string) (string, error) {
c := exec.CommandContext(ctx, "winget", args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
out := stdout.String()
// winget writes progress and VT sequences to stdout; strip them.
out = stripProgress(out)
if err != nil {
se := stderr.String()
if strings.Contains(se, "Access is denied") ||
strings.Contains(out, "administrator") {
return "", fmt.Errorf("winget: %w", snack.ErrPermissionDenied)
}
combined := se + out
if strings.Contains(combined, "No package found") ||
strings.Contains(combined, "No installed package found") {
return "", fmt.Errorf("winget: %w", snack.ErrNotFound)
}
errMsg := strings.TrimSpace(se)
if errMsg == "" {
errMsg = strings.TrimSpace(out)
}
return "", fmt.Errorf("winget: %s: %w", errMsg, err)
}
return out, nil
}
// stripProgress removes VT100 escape sequences and progress lines from output.
func stripProgress(s string) string {
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
clean := stripVT(line)
clean = strings.TrimRight(clean, "\r")
// Skip pure progress lines (e.g. "██████████████ 50%")
if isProgressLine(clean) {
continue
}
b.WriteString(clean)
b.WriteByte('\n')
}
return b.String()
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toInstall []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.Reinstall || t.Version != "" || o.DryRun {
toInstall = append(toInstall, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if ok {
unchanged = append(unchanged, t.Name)
} else {
toInstall = append(toInstall, t)
}
}
for _, t := range toInstall {
args := []string{"install", "--id", t.Name, "--exact", "--silent"}
args = append(args, commonArgs()...)
args = append(args, "--accept-package-agreements")
if t.Version != "" {
args = append(args, "--version", t.Version)
}
if t.FromRepo != "" {
args = append(args, "--source", t.FromRepo)
} else if o.FromRepo != "" {
args = append(args, "--source", o.FromRepo)
}
if _, err := run(ctx, args); err != nil {
return snack.InstallResult{}, fmt.Errorf("winget install %s: %w", t.Name, err)
}
}
var installed []snack.Package
for _, t := range toInstall {
v, _ := version(ctx, t.Name)
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
o := snack.ApplyOptions(opts...)
var toRemove []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.DryRun {
toRemove = append(toRemove, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.RemoveResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toRemove = append(toRemove, t)
}
}
for _, t := range toRemove {
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent"}
args = append(args, commonArgs()...)
if _, err := run(ctx, args); err != nil {
return snack.RemoveResult{}, fmt.Errorf("winget uninstall %s: %w", t.Name, err)
}
}
var removed []snack.Package
for _, t := range toRemove {
removed = append(removed, snack.Package{Name: t.Name})
}
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
}
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
for _, t := range pkgs {
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent", "--purge"}
args = append(args, commonArgs()...)
if _, err := run(ctx, args); err != nil {
return fmt.Errorf("winget purge %s: %w", t.Name, err)
}
}
return nil
}
func upgrade(ctx context.Context, _ ...snack.Option) error {
args := []string{"upgrade", "--all", "--silent"}
args = append(args, commonArgs()...)
args = append(args, "--accept-package-agreements")
_, err := run(ctx, args)
return err
}
func update(ctx context.Context) error {
_, err := run(ctx, []string{"source", "update"})
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
args := []string{"list"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
return nil, fmt.Errorf("winget list: %w", err)
}
return parseTable(out, true), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
args := []string{"search", query}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
return nil, nil
}
return nil, fmt.Errorf("winget search: %w", err)
}
return parseTable(out, false), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
args := []string{"show", "--id", pkg, "--exact"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("winget info: %w", err)
}
p := parseShow(out)
if p == nil {
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
}
// Check if installed
ok, _ := isInstalled(ctx, pkg)
p.Installed = ok
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
args := []string{"list", "--id", pkg, "--exact"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
// "No installed package found" is returned as an error by run()
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
return false, nil
}
return false, fmt.Errorf("winget isInstalled: %w", err)
}
pkgs := parseTable(out, true)
return len(pkgs) > 0, nil
}
func version(ctx context.Context, pkg string) (string, error) {
args := []string{"list", "--id", pkg, "--exact"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("winget version: %w", err)
}
pkgs := parseTable(out, true)
if len(pkgs) == 0 {
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
}
return pkgs[0].Version, nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
args := []string{"show", "--id", pkg, "--exact"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("winget latestVersion: %w", err)
}
p := parseShow(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
args := []string{"upgrade"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
// No upgrades available may exit non-zero on some versions
return nil, nil
}
return parseTable(out, false), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
upgrades, err := listUpgrades(ctx)
if err != nil {
return false, err
}
for _, u := range upgrades {
if strings.EqualFold(u.Name, pkg) {
return true, nil
}
}
return false, nil
}
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target
var unchanged []string
for _, t := range pkgs {
if o.DryRun {
toUpgrade = append(toUpgrade, t)
continue
}
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if !ok {
unchanged = append(unchanged, t.Name)
} else {
toUpgrade = append(toUpgrade, t)
}
}
for _, t := range toUpgrade {
args := []string{"upgrade", "--id", t.Name, "--exact", "--silent"}
args = append(args, commonArgs()...)
args = append(args, "--accept-package-agreements")
if t.Version != "" {
args = append(args, "--version", t.Version)
}
if _, err := run(ctx, args); err != nil {
return snack.InstallResult{}, fmt.Errorf("winget upgrade %s: %w", t.Name, err)
}
}
var upgraded []snack.Package
for _, t := range toUpgrade {
v, _ := version(ctx, t.Name)
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
}
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
}
// sourceList returns configured winget sources.
func sourceList(ctx context.Context) ([]snack.Repository, error) {
args := []string{"source", "list"}
args = append(args, commonArgs()...)
out, err := run(ctx, args)
if err != nil {
return nil, fmt.Errorf("winget source list: %w", err)
}
return parseSourceList(out), nil
}
// sourceAdd adds a new winget source.
func sourceAdd(ctx context.Context, repo snack.Repository) error {
args := []string{"source", "add", "--name", repo.Name, "--arg", repo.URL}
args = append(args, commonArgs()...)
if repo.Type != "" {
args = append(args, "--type", repo.Type)
}
_, err := run(ctx, args)
return err
}
// sourceRemove removes a winget source.
func sourceRemove(ctx context.Context, name string) error {
args := []string{"source", "remove", "--name", name}
args = append(args, commonArgs()...)
_, err := run(ctx, args)
return err
}