39 Commits

Author SHA1 Message Date
b86a793e1c Merge pull request #45 from gogrlx/dependabot/go_modules/github.com/go-git/go-git/v5-5.17.1
chore(deps): bump github.com/go-git/go-git/v5 from 5.17.0 to 5.17.1
2026-04-01 02:31:21 -04:00
dependabot[bot]
adb8de7bee chore(deps): bump github.com/go-git/go-git/v5 from 5.17.0 to 5.17.1
Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.17.0 to 5.17.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.17.0...v5.17.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.17.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 17:16:13 +00:00
4ea7c3f93b build(release): add Homebrew tap, install script, fix deprecations
- Replace deprecated brews with homebrew_casks
- Replace deprecated nfpms.builds with nfpms.ids
- Remove deprecated snapshot.name_template
- Create gogrlx/homebrew-tap repo for Homebrew distribution
- Add install.sh for curl-pipe-sh installation
- Users can now: brew install gogrlx/tap/snack
2026-03-25 19:08:06 +00:00
5863cea51e chore(deps): update all dependencies 2026-03-10 17:55:30 +00:00
a171459a66 fix(flatpak): remove duplicate stubs in flatpak_other.go
Functions latestVersion, listUpgrades, upgradeAvailable, versionCmp
were declared in both flatpak_other.go and capabilities_other.go,
causing build failures on non-Linux platforms.
2026-03-10 17:54:14 +00:00
84e4f8e2ff feat(aur): rewrite with go-git, RPC batch queries, functional options
- Replace shell git clone with go-git for cloning/pulling PKGBUILDs
- Add rpcInfoMulti for batch AUR queries (single HTTP request)
- Add functional options: WithBuildDir, WithMakepkgFlags
- Implement proper remove/purge via pacman -R/-Rns
- Fix temp directory leak: buildPackage returns cleanup path
- Remove NameNormalizer (AUR names are plain identifiers)
- Update README capability matrix
- Remove duplicate platform stubs (flatpak, ports)
2026-03-10 17:41:03 +00:00
1a51a40e4e Merge branch 'cd/aur-implementation': AUR rewrite with go-git, RPC batch queries, functional options
# Conflicts:
#	flatpak/capabilities.go
#	ports/capabilities.go
#	ports/capabilities_openbsd.go
#	ports/ports_test.go
#	snap/capabilities.go
#	snap/snap_linux.go
#	snap/snap_other.go
2026-03-10 17:35:15 +00:00
6db6e993f0 Merge pull request #43 from gogrlx/cd/update-deps-go1.26.1
chore: update Go to 1.26.1, fix formatting, add parse tests
2026-03-08 14:28:48 -04:00
1410e4888c chore: update Go to 1.26.1, fix goimports formatting, add tests
- Update go.mod from Go 1.26.0 to 1.26.1
- Update dependencies: golang.org/x/sync, golang.org/x/sys,
  charmbracelet/x/exp/charmtone, mattn/go-runewidth
- Fix goimports formatting in 10 files
- Add apk/normalize_test.go: tests for normalizeName and
  parseArchNormalize with all known arch suffixes
- Add rpm/parse_test.go: tests for parseList, parseInfo,
  parseArchSuffix, and normalizeName (all at 100% coverage)
- All tests pass with -race, staticcheck and go vet clean
2026-03-08 12:47:30 +00:00
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
82 changed files with 7823 additions and 673 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

107
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,107 @@
# 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
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
ids: [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.
homebrew_casks:
- ids: [snack, snack-universal]
name: snack
binaries:
- snack
repository:
owner: gogrlx
name: homebrew-tap
directory: Casks
homepage: https://github.com/gogrlx/snack
description: "A unified CLI for system package managers"
license: 0BSD
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 (RPC + 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, ""
}

71
apk/normalize_test.go Normal file
View File

@@ -0,0 +1,71 @@
package apk
import (
"testing"
)
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"curl", "curl"},
{"curl-x86_64", "curl"},
{"openssl-aarch64", "openssl"},
{"musl-armhf", "musl"},
{"busybox-armv7", "busybox"},
{"lib-ssl-dev-x86", "lib-ssl-dev"},
{"zlib-ppc64le", "zlib"},
{"kernel-s390x", "kernel"},
{"toolchain-riscv64", "toolchain"},
{"app-loongarch64", "app"},
// No arch suffix — unchanged
{"python", "python"},
{"go", "go"},
{"", ""},
// Suffix that isn't an arch — unchanged
{"my-pkg-foo", "my-pkg-foo"},
{"libfoo-1.0", "libfoo-1.0"},
}
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 TestParseArchNormalize(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantArch string
}{
{"x86_64", "curl-x86_64", "curl", "x86_64"},
{"x86", "musl-x86", "musl", "x86"},
{"aarch64", "openssl-aarch64", "openssl", "aarch64"},
{"armhf", "busybox-armhf", "busybox", "armhf"},
{"armv7", "lib-armv7", "lib", "armv7"},
{"ppc64le", "app-ppc64le", "app", "ppc64le"},
{"s390x", "pkg-s390x", "pkg", "s390x"},
{"riscv64", "tool-riscv64", "tool", "riscv64"},
{"loongarch64", "gcc-loongarch64", "gcc", "loongarch64"},
{"no arch", "curl", "curl", ""},
{"unknown suffix", "pkg-foobar", "pkg-foobar", ""},
{"empty", "", "", ""},
{"hyphen but not arch", "lib-ssl-dev", "lib-ssl-dev", ""},
{"multi hyphen with arch", "lib-ssl-dev-x86_64", "lib-ssl-dev", "x86_64"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotArch := parseArchNormalize(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("parseArchNormalize(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}

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,52 +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
if repo.Type != "" {

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

@@ -14,8 +14,8 @@ func TestParseList_EdgeCases(t *testing.T) {
}{
{"empty", "", 0},
{"whitespace_only", " \n \n ", 0},
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
{"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1},
{"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2},
{"trailing_newline", "bash\t5.2-1\n", 1},
@@ -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

@@ -69,19 +69,29 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
}
}
pkgFile, err := a.buildPackage(ctx, t)
pkgFile, cleanupDir, err := a.buildPackage(ctx, t)
if err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
}
if o.DryRun {
if cleanupDir != "" {
os.RemoveAll(cleanupDir)
}
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)
installErr := func() error {
if cleanupDir != "" {
defer os.RemoveAll(cleanupDir)
}
_, err := runPacman(ctx, args, o.Sudo)
return err
}()
if installErr != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, installErr)
}
v, _ := version(ctx, t.Name)
@@ -97,23 +107,25 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
}
// 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) {
// Returns the path to the built .pkg.tar.zst file and an optional cleanup
// directory (non-empty only when a temp dir was created; caller must remove it).
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (pkgPath string, cleanupDir string, err 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)
return "", "", fmt.Errorf("creating temp dir: %w", err)
}
buildDir = tmp
cleanupDir = tmp
}
pkgDir := filepath.Join(buildDir, t.Name)
// Clone or update the PKGBUILD repo
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
return "", err
return "", cleanupDir, err
}
// Run makepkg
@@ -125,15 +137,15 @@ func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error)
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)
return "", cleanupDir, 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 "", cleanupDir, fmt.Errorf("makepkg %s: no package file produced", t.Name)
}
return matches[len(matches)-1], nil
return matches[len(matches)-1], cleanupDir, nil
}
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.

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

@@ -13,6 +13,7 @@ type Capabilities struct {
Groups bool
NameNormalize bool
DryRun bool
PackageUpgrade bool
}
// GetCapabilities probes a Manager for all optional interface support.
@@ -26,6 +27,7 @@ func GetCapabilities(m Manager) Capabilities {
_, g := m.(Grouper)
_, nn := m.(NameNormalizer)
_, dr := m.(DryRunner)
_, pu := m.(PackageUpgrader)
return Capabilities{
VersionQuery: vq,
Hold: h,
@@ -36,5 +38,6 @@ func GetCapabilities(m Manager) Capabilities {
Groups: g,
NameNormalize: nn,
DryRun: dr,
PackageUpgrade: pu,
}
}

View File

@@ -18,15 +18,15 @@ func (m *mockManager) Remove(context.Context, []snack.Target, ...snack.Option) (
return snack.RemoveResult{}, nil
}
func (m *mockManager) Purge(context.Context, []snack.Target, ...snack.Option) error { return nil }
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
func (m *mockManager) Update(context.Context) error { return nil }
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
func (m *mockManager) Available() bool { return true }
func (m *mockManager) Name() string { return "mock" }
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
func (m *mockManager) Update(context.Context) error { return nil }
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
func (m *mockManager) Available() bool { return true }
func (m *mockManager) Name() string { return "mock" }
// fullMockManager implements Manager plus all optional interfaces.
type fullMockManager struct {
@@ -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,11 +8,32 @@ import (
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*Flatpak)(nil)
_ snack.Cleaner = (*Flatpak)(nil)
_ snack.RepoManager = (*Flatpak)(nil)
_ snack.VersionQuerier = (*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()
@@ -43,25 +64,3 @@ func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error {
defer f.Unlock()
return removeRepo(ctx, id)
}
// LatestVersion returns the latest available version of a flatpak from
// configured remotes.
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 using basic semver comparison.
// Flatpak has no native version comparison tool.
func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}

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

@@ -220,11 +220,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,8 @@ func removeRepo(_ context.Context, _ string) error {
return 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, ""
}

39
go.mod
View File

@@ -1,39 +1,39 @@
module github.com/gogrlx/snack
go 1.26.0
go 1.26.1
require (
github.com/charmbracelet/fang v0.4.4
github.com/go-git/go-git/v5 v5.17.0
github.com/charmbracelet/fang v1.0.0
github.com/go-git/go-git/v5 v5.17.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
)
require (
charm.land/lipgloss/v2 v2.0.0 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
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
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
@@ -51,12 +51,13 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -72,15 +73,15 @@ require (
github.com/muesli/roff v0.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
@@ -95,10 +96,10 @@ require (
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect

82
go.sum
View File

@@ -1,5 +1,5 @@
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -9,28 +9,28 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg6
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 h1:wQs/I0JSEkcHzobvAgfzeJOKm9A8mkeDOkWQxAo0AZc=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -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=
@@ -58,8 +58,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -87,8 +87,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk=
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -109,10 +109,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -126,8 +128,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -162,8 +164,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -175,15 +177,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -229,15 +231,15 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -249,11 +251,11 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

53
install.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/sh
# Install snack - a unified CLI for system package managers
# Usage: curl -sSfL https://raw.githubusercontent.com/gogrlx/snack/main/install.sh | sh
set -e
REPO="gogrlx/snack"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
# Detect OS and arch
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
armv*) ARCH="arm" ;;
esac
# macOS universal binary
if [ "$OS" = "darwin" ]; then
ARCH="universal"
fi
echo "Detected: ${OS}/${ARCH}"
# Get latest release tag
TAG="$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
VERSION="${TAG#v}"
if [ -z "$VERSION" ]; then
echo "Error: could not determine latest version" >&2
exit 1
fi
echo "Installing snack ${VERSION}..."
TARBALL="snack-${VERSION}-${OS}-${ARCH}.tar.gz"
URL="https://github.com/${REPO}/releases/download/${TAG}/${TARBALL}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -sSfL "$URL" -o "${TMP}/${TARBALL}"
tar xzf "${TMP}/${TARBALL}" -C "$TMP"
if [ -w "$INSTALL_DIR" ]; then
mv "${TMP}/snack" "${INSTALL_DIR}/snack"
else
echo "Installing to ${INSTALL_DIR} (requires sudo)..."
sudo mv "${TMP}/snack" "${INSTALL_DIR}/snack"
fi
chmod +x "${INSTALL_DIR}/snack"
echo "snack ${VERSION} installed to ${INSTALL_DIR}/snack"

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")
}
}

View File

@@ -11,6 +11,7 @@ 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.
@@ -29,13 +30,11 @@ func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error)
}
// VersionCmp compares two version strings.
// OpenBSD has no native version comparison tool, so this uses a simple
// lexicographic comparison of the version strings.
func (p *Ports) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes packages no longer required as dependencies.
// Autoremove removes packages that are no longer needed.
func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error {
p.Lock()
defer p.Unlock()

View File

@@ -78,40 +78,6 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
return err
}
func clean(_ context.Context) error {
// OpenBSD does not maintain a package cache like FreeBSD/apt.
// Downloaded packages are removed after installation by default.
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)
}
return parseFileListOutput(out), 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)
}
return parseOwnerOutput(out), nil
}
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var toUpgrade []snack.Target

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

@@ -81,6 +81,16 @@ 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)

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

@@ -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,14 +471,7 @@ 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)
}
@@ -196,6 +497,91 @@ python-3.11.7p0 -> python-3.11.8p0
}
}
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 {
@@ -221,6 +607,73 @@ Files:
}
}
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 {
@@ -245,6 +698,58 @@ func TestParseOwnerOutput(t *testing.T) {
}
}
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)
@@ -253,9 +758,51 @@ func TestInterfaceCompliance(t *testing.T) {
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestPackageUpgraderInterface(t *testing.T) {
var _ snack.PackageUpgrader = (*Ports)(nil)
}
func TestName(t *testing.T) {
p := New()
if p.Name() != "ports" {
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,18 +2,80 @@ package rpm
import (
"testing"
"github.com/gogrlx/snack"
)
func TestNormalizeName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"nginx", "nginx"},
{"nginx.x86_64", "nginx"},
{"curl.noarch", "curl"},
{"kernel.aarch64", "kernel"},
{"bash.i686", "bash"},
{"glibc.i386", "glibc"},
{"libfoo.armv7hl", "libfoo"},
{"module.ppc64le", "module"},
{"app.s390x", "app"},
{"source.src", "source"},
{"nodot", "nodot"},
{"", ""},
{"pkg.unknown", "pkg.unknown"},
{"multi.dot.x86_64", "multi.dot"},
}
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 TestParseArchSuffix(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantArch string
}{
{"x86_64", "nginx.x86_64", "nginx", "x86_64"},
{"noarch", "bash.noarch", "bash", "noarch"},
{"aarch64", "kernel.aarch64", "kernel", "aarch64"},
{"i686", "glibc.i686", "glibc", "i686"},
{"i386", "compat.i386", "compat", "i386"},
{"armv7hl", "lib.armv7hl", "lib", "armv7hl"},
{"ppc64le", "app.ppc64le", "app", "ppc64le"},
{"s390x", "z.s390x", "z", "s390x"},
{"src", "pkg.src", "pkg", "src"},
{"no dot", "curl", "curl", ""},
{"unknown arch", "pkg.foobar", "pkg.foobar", ""},
{"empty", "", "", ""},
{"multiple dots", "a.b.x86_64", "a.b", "x86_64"},
{"dot but not arch", "libfoo.so", "libfoo.so", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotArch := parseArchSuffix(tt.input)
if gotName != tt.wantName || gotArch != tt.wantArch {
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
}
})
}
}
func TestParseList(t *testing.T) {
input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n"
input := "bash\t5.2.15-3.fc38\tThe GNU Bourne Again shell\n" +
"curl\t8.0.1-1.fc38\tA utility for getting files from remote servers\n"
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" {
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2.15-3.fc38" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[0].Description != "The GNU Bourne Again shell" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
@@ -23,81 +85,151 @@ func TestParseList(t *testing.T) {
}
}
func TestParseListEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkgs := parseList("")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("whitespace only", func(t *testing.T) {
pkgs := parseList(" \n\n \n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages, got %d", len(pkgs))
}
})
t.Run("single entry no description", func(t *testing.T) {
pkgs := parseList("vim\t9.0.1\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.1" {
t.Errorf("unexpected package: %+v", pkgs[0])
}
if pkgs[0].Description != "" {
t.Errorf("expected empty description, got %q", pkgs[0].Description)
}
})
t.Run("single field line skipped", func(t *testing.T) {
pkgs := parseList("justname\n")
if len(pkgs) != 0 {
t.Errorf("expected 0 packages (need >=2 tab fields), got %d", len(pkgs))
}
})
t.Run("description with tabs", func(t *testing.T) {
pkgs := parseList("pkg\t1.0\tA description\twith tabs\n")
if len(pkgs) != 1 {
t.Fatalf("expected 1 package, got %d", len(pkgs))
}
// SplitN with 3 means the third part includes everything after the second tab
if pkgs[0].Description != "A description\twith tabs" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
})
}
func TestParseInfo(t *testing.T) {
input := `Name : bash
Version : 5.1.8
Release : 6.el9
input := `Name : curl
Version : 8.0.1
Release : 1.fc38
Architecture: x86_64
Install Date: Mon 01 Jan 2024 12:00:00 AM UTC
Group : System Environment/Shells
Size : 7896043
License : GPLv3+
Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123
Source RPM : bash-5.1.8-6.el9.src.rpm
Build Date : Mon 01 Jan 2024 12:00:00 AM UTC
Build Host : builder.example.com
Packager : CentOS Buildsys <bugs@centos.org>
Vendor : CentOS
URL : https://www.gnu.org/software/bash
Summary : The GNU Bourne Again shell
Description :
The GNU Bourne Again shell (Bash) is a shell or command language
interpreter that is compatible with the Bourne shell (sh).
Summary : A utility for getting files from remote servers
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected package, got nil")
pkg := parseInfo(input)
if pkg == nil {
t.Fatal("expected non-nil package")
}
if p.Name != "bash" {
t.Errorf("Name = %q, want bash", p.Name)
if pkg.Name != "curl" {
t.Errorf("expected name 'curl', got %q", pkg.Name)
}
if p.Version != "5.1.8-6.el9" {
t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version)
if pkg.Version != "8.0.1-1.fc38" {
t.Errorf("expected version '8.0.1-1.fc38', got %q", pkg.Version)
}
if p.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", p.Arch)
if pkg.Arch != "x86_64" {
t.Errorf("expected arch 'x86_64', got %q", pkg.Arch)
}
if p.Description != "The GNU Bourne Again shell" {
t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description)
if pkg.Description != "A utility for getting files from remote servers" {
t.Errorf("unexpected description: %q", pkg.Description)
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl.aarch64", "curl"},
{"bash.noarch", "bash"},
{"python3", "python3"},
}
for _, tt := range tests {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
func TestParseInfoEdgeCases(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
pkg := parseInfo("")
if pkg != nil {
t.Error("expected nil for empty input")
}
}
}
})
func TestParseArchSuffix(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"nginx.x86_64", "nginx", "x86_64"},
{"bash", "bash", ""},
{"glibc.i686", "glibc", "i686"},
}
for _, tt := range tests {
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)
t.Run("name only", func(t *testing.T) {
pkg := parseInfo("Name : bash\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
}
}
if pkg.Name != "bash" {
t.Errorf("expected bash, got %q", pkg.Name)
}
})
// Compile-time interface checks.
var (
_ snack.Manager = (*RPM)(nil)
_ snack.FileOwner = (*RPM)(nil)
_ snack.NameNormalizer = (*RPM)(nil)
)
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("version without release", func(t *testing.T) {
pkg := parseInfo("Name : test\nVersion : 2.5\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "2.5" {
t.Errorf("expected version '2.5', got %q", pkg.Version)
}
})
t.Run("release without version", func(t *testing.T) {
// Release only appends if version is non-empty
pkg := parseInfo("Name : test\nRelease : 3.el9\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Version != "" {
t.Errorf("expected empty version (release alone shouldn't set it), got %q", pkg.Version)
}
})
t.Run("arch key variant", func(t *testing.T) {
pkg := parseInfo("Name : test\nArch : aarch64\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Arch != "aarch64" {
t.Errorf("expected aarch64, got %q", pkg.Arch)
}
})
t.Run("no colon lines ignored", func(t *testing.T) {
pkg := parseInfo("Name : test\nrandom line\nSummary : A tool\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A tool" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
t.Run("value with colons", func(t *testing.T) {
pkg := parseInfo("Name : myapp\nSummary : A tool: does things: well\n")
if pkg == nil {
t.Fatal("expected non-nil")
}
if pkg.Description != "A tool: does things: well" {
t.Errorf("unexpected description: %q", pkg.Description)
}
})
}

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

@@ -10,6 +10,7 @@ import (
var (
_ snack.VersionQuerier = (*Snap)(nil)
_ snack.Cleaner = (*Snap)(nil)
_ snack.NameNormalizer = (*Snap)(nil)
)
// LatestVersion returns the latest stable version of a snap.
@@ -32,13 +33,12 @@ func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove is a no-op for snap. Snaps are self-contained and do not
// have orphan dependencies.
func (s *Snap) Autoremove(ctx context.Context, opts ...snack.Option) error {
return autoremove(ctx, opts...)
// 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 disabled snap revisions to free disk space.
// Clean removes old snap revisions to free up space.
func (s *Snap) Clean(ctx context.Context) error {
s.Lock()
defer s.Unlock()

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,43 +232,21 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
return semverCmp(ver1, ver2), nil
}
// autoremove is a no-op for snap. Snaps are self-contained and do not
// have orphan dependencies.
func autoremove(_ context.Context, _ ...snack.Option) error {
return nil
}
// clean removes old disabled snap revisions to free disk space.
// It runs `snap list --all` to find disabled revisions, then removes
// each one with `snap remove --revision=<rev> <name>`.
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 fmt.Errorf("snap clean: %w", err)
return err
}
// Parse output for disabled revisions
// Header: Name Version Rev Tracking Publisher Notes
// Disabled snaps have "disabled" in the Notes column
lines := strings.Split(out, "\n")
for i, line := range lines {
if i == 0 { // skip header
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, "disabled") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
name := fields[0]
rev := fields[2]
if _, err := run(ctx, []string{"remove", "--revision=" + rev, name}); err != nil {
return fmt.Errorf("snap clean %s rev %s: %w", name, rev, err)
if len(fields) >= 3 {
name := fields[0]
rev := fields[2]
_, _ = run(ctx, []string{"remove", name, "--revision=" + rev})
}
}
return nil
@@ -293,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,10 +66,6 @@ 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
}

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
}