13 Commits

Author SHA1 Message Date
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
31 changed files with 4505 additions and 373 deletions

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

97
.goreleaser.yaml Normal file
View File

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

132
README.md
View File

@@ -11,20 +11,22 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
## Supported Package Managers
| Package | Manager | Platform | Status |
| Package | Manager | Platform | Extras |
|---------|---------|----------|--------|
| `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 | đźš§ |
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | DryRun, FileOwner, Holder, KeyManager, NameNormalizer, RepoManager |
| `dpkg` | dpkg | Debian/Ubuntu | DryRun, FileOwner, NameNormalizer |
| `dnf` | DNF 4/5 | Fedora/RHEL | DryRun, FileOwner, Grouper, Holder, KeyManager, NameNormalizer, RepoManager |
| `rpm` | RPM | Fedora/RHEL | FileOwner, NameNormalizer |
| `pacman` | pacman | Arch Linux | DryRun, FileOwner, Grouper |
| `aur` | AUR (makepkg) | Arch Linux | — |
| `apk` | apk-tools | Alpine Linux | DryRun, FileOwner |
| `flatpak` | Flatpak | Cross-distro | RepoManager |
| `snap` | snapd | Cross-distro | — |
| `pkg` | pkg(8) | FreeBSD | FileOwner |
| `ports` | ports/packages | OpenBSD | FileOwner |
| `detect` | Auto-detection | All | — |
All providers implement `Manager`, `VersionQuerier`, `Cleaner`, and `PackageUpgrader`. The **Extras** column lists additional capabilities beyond that baseline.
## Install
@@ -50,11 +52,12 @@ func main() {
ctx := context.Background()
mgr := apt.New()
// Install a package
err := mgr.Install(ctx, []string{"nginx"}, snack.WithSudo(), snack.WithAssumeYes())
// Install packages
result, err := mgr.Install(ctx, snack.Targets("nginx", "curl"), snack.WithSudo(), snack.WithAssumeYes())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Installed: %d, Unchanged: %d\n", len(result.Installed), len(result.Unchanged))
// Check if installed
installed, err := mgr.IsInstalled(ctx, "nginx")
@@ -62,6 +65,14 @@ func main() {
log.Fatal(err)
}
fmt.Println("nginx installed:", installed)
// Upgrade specific packages
if up, ok := mgr.(snack.PackageUpgrader); ok {
_, err := up.UpgradePackages(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes())
if err != nil {
log.Fatal(err)
}
}
}
```
@@ -75,6 +86,11 @@ if err != nil {
log.Fatal(err)
}
fmt.Println("Detected:", mgr.Name())
// All available managers
for _, m := range detect.All() {
fmt.Println(m.Name())
}
```
## Interfaces
@@ -83,17 +99,21 @@ snack uses a layered interface design. Every provider implements `Manager` (the
```go
// Base — every provider
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
snack.Manager // Install, Remove, Purge, Upgrade, Update, List, Search, Info, IsInstalled, Version
// Optional capabilities — type-assert to check
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
snack.Holder // Hold, Unhold, ListHeld (version pinning)
snack.Cleaner // Autoremove, Clean (orphan/cache cleanup)
snack.FileOwner // FileList, Owner (file-to-package queries)
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys)
snack.Grouper // GroupList, GroupInfo, GroupInstall
snack.NameNormalizer // NormalizeName, ParseArch
// Core optional — implemented by all providers
snack.VersionQuerier // LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp
snack.Cleaner // Autoremove, Clean (orphan/cache cleanup)
snack.PackageUpgrader // UpgradePackages (upgrade specific packages)
// Provider-specific — type-assert to check
snack.Holder // Hold, Unhold, ListHeld, IsHeld (version pinning)
snack.FileOwner // FileList, Owner (file-to-package queries)
snack.RepoManager // ListRepos, AddRepo, RemoveRepo
snack.KeyManager // AddKey, RemoveKey, ListKeys (GPG keys)
snack.Grouper // GroupList, GroupInfo, GroupInstall, GroupIsInstalled
snack.NameNormalizer // NormalizeName, ParseArch
snack.DryRunner // SupportsDryRun (honors WithDryRun option)
```
Check capabilities at runtime:
@@ -103,8 +123,49 @@ caps := snack.GetCapabilities(mgr)
if caps.Hold {
mgr.(snack.Holder).Hold(ctx, []string{"nginx"})
}
if caps.FileOwnership {
owner, _ := mgr.(snack.FileOwner).Owner(ctx, "/usr/bin/curl")
fmt.Println("Owned by:", owner)
}
```
## Options
All mutating operations accept functional options:
```go
snack.WithSudo() // prepend sudo
snack.WithAssumeYes() // auto-confirm prompts
snack.WithDryRun() // simulate (if DryRunner)
snack.WithVerbose() // verbose output
snack.WithRefresh() // refresh index before operation
snack.WithReinstall() // reinstall even if current
snack.WithRoot("/mnt") // alternate root filesystem
snack.WithFromRepo("sid") // install from specific repository
```
## CLI
A companion CLI is included at `cmd/snack`:
```bash
snack install nginx curl # install packages
snack remove nginx # remove packages
snack upgrade # upgrade all packages
snack update # refresh package index
snack search redis # search for packages
snack info nginx # show package details
snack list # list installed packages
snack which /usr/bin/curl # find owning package
snack hold nginx # pin package version
snack unhold nginx # unpin package version
snack clean # autoremove + clean cache
snack detect # show detected managers + capabilities
snack version # show version
```
Global flags: `--manager <name>`, `--sudo`, `--yes`, `--dry-run`
## Design
- **Thin CLI wrappers** — each sub-package wraps a package manager's CLI tools. No FFI, no library bindings.
@@ -115,27 +176,6 @@ if caps.Hold {
- **Platform-safe** — build tags ensure packages compile everywhere but only run where appropriate.
- **No root assumption** — use `snack.WithSudo()` when elevated privileges are needed.
## Implementation Priority
1. pacman + AUR (Arch Linux)
2. apk (Alpine Linux)
3. apt + dpkg (Debian/Ubuntu)
4. dnf + rpm (Fedora/RHEL)
5. flatpak + snap (cross-distro)
6. pkg + ports (BSD)
## CLI
A companion CLI tool is planned for direct terminal usage:
```bash
snack install nginx
snack remove nginx
snack search redis
snack list
snack upgrade
```
## License
0BSD — see [LICENSE](LICENSE).

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

@@ -127,10 +127,12 @@ 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])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@ package aur
import (
"testing"
"github.com/gogrlx/snack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParsePackageList(t *testing.T) {
@@ -63,3 +65,142 @@ func TestNewWithOptions(t *testing.T) {
assert.Equal(t, "/tmp/aur-builds", a.BuildDir)
assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags)
}
func TestInterfaceCompliance(t *testing.T) {
var _ snack.Manager = (*AUR)(nil)
var _ snack.VersionQuerier = (*AUR)(nil)
var _ snack.Cleaner = (*AUR)(nil)
var _ snack.PackageUpgrader = (*AUR)(nil)
}
func TestInterfaceNonCompliance(t *testing.T) {
a := New()
var m snack.Manager = a
if _, ok := m.(snack.FileOwner); ok {
t.Error("AUR should not implement FileOwner")
}
if _, ok := m.(snack.Holder); ok {
t.Error("AUR should not implement Holder")
}
if _, ok := m.(snack.RepoManager); ok {
t.Error("AUR should not implement RepoManager")
}
if _, ok := m.(snack.KeyManager); ok {
t.Error("AUR should not implement KeyManager")
}
if _, ok := m.(snack.Grouper); ok {
t.Error("AUR should not implement Grouper")
}
if _, ok := m.(snack.NameNormalizer); ok {
t.Error("AUR should not implement NameNormalizer")
}
if _, ok := m.(snack.DryRunner); ok {
t.Error("AUR should not implement DryRunner")
}
}
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, false},
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, 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 TestName(t *testing.T) {
a := New()
assert.Equal(t, "aur", a.Name())
}
func TestParsePackageList_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantNames []string
wantVers []string
}{
{
name: "empty string",
input: "",
wantLen: 0,
},
{
name: "whitespace only",
input: " \n\t\n \n",
wantLen: 0,
},
{
name: "single package",
input: "yay 12.5.7-1\n",
wantLen: 1,
wantNames: []string{"yay"},
wantVers: []string{"12.5.7-1"},
},
{
name: "malformed single field",
input: "orphan\n",
wantLen: 0,
},
{
name: "malformed mixed with valid",
input: "orphan\nyay 12.5.7-1\nbadline\nparu 2.0-1\n",
wantLen: 2,
wantNames: []string{"yay", "paru"},
wantVers: []string{"12.5.7-1", "2.0-1"},
},
{
name: "extra fields ignored",
input: "yay 12.5.7-1 extra stuff\n",
wantLen: 1,
wantNames: []string{"yay"},
wantVers: []string{"12.5.7-1"},
},
{
name: "trailing and leading whitespace on lines",
input: " yay 12.5.7-1 \n paru 2.0.4-1\n\n",
wantLen: 2,
wantNames: []string{"yay", "paru"},
wantVers: []string{"12.5.7-1", "2.0.4-1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parsePackageList(tt.input)
require.Len(t, pkgs, tt.wantLen)
for i, p := range pkgs {
assert.Equal(t, "aur", p.Repository, "all packages should have Repository=aur")
assert.True(t, p.Installed, "all packages 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)
}
}
})
}
}

View File

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

View File

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

View File

@@ -387,6 +387,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, ", ") + "]"

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

@@ -0,0 +1,149 @@
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
flagMgr = "apt"
m, err = getManager()
if err != nil {
t.Fatalf("getManager() with --manager=apt failed: %v", err)
}
if m.Name() != "apt" {
t.Errorf("expected Name()=apt, got %q", m.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")
}
}

13
detect/detect_other.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !linux && !freebsd && !openbsd
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,10 @@
package detect
import (
"errors"
"testing"
"github.com/gogrlx/snack"
)
func TestByNameUnknown(t *testing.T) {
@@ -9,15 +12,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 +87,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")
}
}

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

@@ -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=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {

2
go.mod
View File

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

4
go.sum
View File

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

View File

@@ -79,36 +79,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 +201,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)

View File

@@ -130,6 +130,59 @@ 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 not implement NameNormalizer")
}
}
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},
{"Groups", caps.Groups, true},
{"DryRun", caps.DryRun, true},
{"Hold", caps.Hold, false},
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"NameNormalize", caps.NameNormalize, 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 TestName(t *testing.T) {

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=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

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=false")
}
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

View File

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

88
rpm/rpm_test.go Normal file
View File

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

View File

@@ -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=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {