mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-03 05:38:43 -07:00
Compare commits
39 Commits
cd/aur-imp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b86a793e1c | |||
|
|
adb8de7bee | ||
| 4ea7c3f93b | |||
| 5863cea51e | |||
| a171459a66 | |||
| 84e4f8e2ff | |||
| 1a51a40e4e | |||
| 6db6e993f0 | |||
| 1410e4888c | |||
| e8b0454851 | |||
| 2422655c1d | |||
| 4a9a2b1980 | |||
| 1da329dfb5 | |||
| f53534ce6f | |||
| d30c9fec0e | |||
| 98bfc56960 | |||
| c913d96de3 | |||
| ffbe0e12ba | |||
| 6237a5f23a | |||
| ac15ab5a49 | |||
| 9e9fb1a822 | |||
| aed2ee8b86 | |||
| 84f9cbc9cf | |||
| 151c657398 | |||
| 934c6610c5 | |||
| 724ecc866e | |||
| 77bdcfef3a | |||
| 80ea10dcff | |||
| 85e06ffc44 | |||
| 1fa7de6d66 | |||
| 60b68060e7 | |||
| c34b7a467c | |||
| e38a787beb | |||
| 6ba3d75258 | |||
| 4a711f0187 | |||
| 0b4c596fad | |||
|
|
18fabed79a | ||
| e1d89fa485 | |||
| 42c2e8ac05 |
36
.github/workflows/integration.yml
vendored
36
.github/workflows/integration.yml
vendored
@@ -179,6 +179,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak
|
||||
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install -y flathub com.github.tchx84.Flatseal
|
||||
- name: Integration tests
|
||||
run: sudo -E go test -v -tags integration -count=1 -coverprofile=coverage-flatpak.out ./flatpak/
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -187,10 +188,41 @@ jobs:
|
||||
name: coverage-flatpak
|
||||
path: coverage-flatpak.out
|
||||
|
||||
cross-compile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, darwin, freebsd, openbsd]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build for ${{ matrix.goos }}
|
||||
run: GOOS=${{ matrix.goos }} go build ./...
|
||||
|
||||
windows:
|
||||
name: Windows (winget)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
run: go test -race -coverprofile=coverage-windows.out ./winget/ ./detect/
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.out
|
||||
|
||||
codecov:
|
||||
name: Upload Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak]
|
||||
needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak, windows, cross-compile]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -202,7 +234,7 @@ jobs:
|
||||
run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found"
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out
|
||||
files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out,coverage-windows.out
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
30
.github/workflows/release.yml
vendored
Normal file
30
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
coverage.out
|
||||
coverage.html
|
||||
.DS_Store
|
||||
.crush/
|
||||
AGENTS.md
|
||||
|
||||
107
.goreleaser.yaml
Normal file
107
.goreleaser.yaml
Normal file
@@ -0,0 +1,107 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd/snack/
|
||||
id: snack
|
||||
binary: snack
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- freebsd
|
||||
- openbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
ldflags:
|
||||
- -X main.version={{.Version}}
|
||||
|
||||
universal_binaries:
|
||||
- id: snack-universal
|
||||
ids:
|
||||
- snack
|
||||
name_template: "snack-{{.Version}}-darwin-universal"
|
||||
replace: true
|
||||
|
||||
archives:
|
||||
- formats: tar.gz
|
||||
allow_different_binary_count: true
|
||||
ids:
|
||||
- snack
|
||||
- snack-universal
|
||||
name_template: "snack-{{.Version}}-{{.Os}}-{{.Arch}}"
|
||||
|
||||
nfpms:
|
||||
- id: snack
|
||||
package_name: snack
|
||||
ids: [snack]
|
||||
formats: [apk, deb, rpm]
|
||||
bindir: /usr/bin
|
||||
description: "A unified CLI for system package managers"
|
||||
maintainer: Tai Groot <tai@taigrr.com>
|
||||
license: 0BSD
|
||||
homepage: https://github.com/gogrlx/snack
|
||||
vendor: Adatomic, Inc.
|
||||
|
||||
homebrew_casks:
|
||||
- ids: [snack, snack-universal]
|
||||
name: snack
|
||||
binaries:
|
||||
- snack
|
||||
repository:
|
||||
owner: gogrlx
|
||||
name: homebrew-tap
|
||||
directory: Casks
|
||||
homepage: https://github.com/gogrlx/snack
|
||||
description: "A unified CLI for system package managers"
|
||||
license: 0BSD
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: gogrlx
|
||||
name: snack
|
||||
ids:
|
||||
- snack
|
||||
- snack-universal
|
||||
draft: true
|
||||
prerelease: auto
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
abbrev: -1
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
- "^chore:"
|
||||
- "^style:"
|
||||
groups:
|
||||
- title: "Breaking Changes"
|
||||
regexp: "^.*!:+.*$"
|
||||
order: 0
|
||||
- title: "Features"
|
||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||
order: 1
|
||||
- title: "Bug Fixes"
|
||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||
order: 2
|
||||
- title: "Performance"
|
||||
regexp: "^.*perf[(\\w)]*:+.*$"
|
||||
order: 3
|
||||
- title: "Refactor"
|
||||
regexp: "^.*refactor[(\\w)]*:+.*$"
|
||||
order: 4
|
||||
- title: "Build"
|
||||
regexp: "^.*build[(\\w)]*:+.*$"
|
||||
order: 5
|
||||
- title: Others
|
||||
order: 999
|
||||
45
README.md
45
README.md
@@ -13,18 +13,36 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem.
|
||||
|
||||
| Package | Manager | Platform | Status |
|
||||
|---------|---------|----------|--------|
|
||||
| `pacman` | pacman | Arch Linux | 🚧 |
|
||||
| `aur` | AUR (makepkg) | Arch Linux | 🚧 |
|
||||
| `apk` | apk-tools | Alpine Linux | 🚧 |
|
||||
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | 🚧 |
|
||||
| `dpkg` | dpkg | Debian/Ubuntu | 🚧 |
|
||||
| `dnf` | DNF | Fedora/RHEL | 🚧 |
|
||||
| `rpm` | RPM | Fedora/RHEL | 🚧 |
|
||||
| `flatpak` | Flatpak | Cross-distro | 🚧 |
|
||||
| `snap` | snapd | Cross-distro | 🚧 |
|
||||
| `pkg` | pkg(8) | FreeBSD | 🚧 |
|
||||
| `ports` | ports/packages | OpenBSD | 🚧 |
|
||||
| `detect` | Auto-detection | All | 🚧 |
|
||||
| `pacman` | pacman | Arch Linux | ✅ |
|
||||
| `aur` | AUR (RPC + makepkg) | Arch Linux | ✅ |
|
||||
| `apk` | apk-tools | Alpine Linux | ✅ |
|
||||
| `apt` | APT (apt-get/apt-cache) | Debian/Ubuntu | ✅ |
|
||||
| `dpkg` | dpkg | Debian/Ubuntu | ✅ |
|
||||
| `dnf` | DNF | Fedora/RHEL | ✅ |
|
||||
| `rpm` | RPM | Fedora/RHEL | ✅ |
|
||||
| `flatpak` | Flatpak | Linux | ✅ |
|
||||
| `snap` | snapd | Linux | ✅ |
|
||||
| `brew` | Homebrew | macOS/Linux | ✅ |
|
||||
| `pkg` | pkg(8) | FreeBSD | ✅ |
|
||||
| `ports` | ports/packages | OpenBSD | ✅ |
|
||||
| `winget` | Windows Package Manager | Windows | ✅ |
|
||||
| `detect` | Auto-detection | All | ✅ |
|
||||
|
||||
### Capability Matrix
|
||||
|
||||
| Provider | VersionQuery | Hold | Clean | FileOwner | RepoMgmt | KeyMgmt | Groups | NameNorm | DryRun | PkgUpgrade |
|
||||
|----------|:------------:|:----:|:-----:|:---------:|:--------:|:-------:|:------:|:--------:|:------:|:----------:|
|
||||
| apt | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ |
|
||||
| pacman | ✅ | - | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | ✅ |
|
||||
| aur | ✅ | - | ✅ | - | - | - | - | - | - | ✅ |
|
||||
| apk | ✅ | - | ✅ | ✅ | - | - | - | ✅ | ✅ | ✅ |
|
||||
| dnf | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| flatpak | ✅ | - | ✅ | - | ✅ | - | - | ✅ | - | ✅ |
|
||||
| snap | ✅ | - | ✅ | - | - | - | - | ✅ | - | ✅ |
|
||||
| brew | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| pkg | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| ports | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ |
|
||||
| winget | ✅ | - | - | - | ✅ | - | - | ✅ | - | ✅ |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -51,7 +69,7 @@ func main() {
|
||||
mgr := apt.New()
|
||||
|
||||
// Install a package
|
||||
err := mgr.Install(ctx, []string{"nginx"}, snack.WithSudo(), snack.WithAssumeYes())
|
||||
_, err := mgr.Install(ctx, snack.Targets("nginx"), snack.WithSudo(), snack.WithAssumeYes())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -123,6 +141,7 @@ if caps.Hold {
|
||||
4. dnf + rpm (Fedora/RHEL)
|
||||
5. flatpak + snap (cross-distro)
|
||||
6. pkg + ports (BSD)
|
||||
7. winget (Windows)
|
||||
|
||||
## CLI
|
||||
|
||||
|
||||
19
apk/apk.go
19
apk/apk.go
@@ -17,9 +17,12 @@ func New() *Apk {
|
||||
return &Apk{}
|
||||
}
|
||||
|
||||
// compile-time check
|
||||
var _ snack.Manager = (*Apk)(nil)
|
||||
var _ snack.PackageUpgrader = (*Apk)(nil)
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Apk)(nil)
|
||||
_ snack.PackageUpgrader = (*Apk)(nil)
|
||||
_ snack.NameNormalizer = (*Apk)(nil)
|
||||
)
|
||||
|
||||
// Name returns "apk".
|
||||
func (a *Apk) Name() string { return "apk" }
|
||||
@@ -93,3 +96,13 @@ func (a *Apk) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...
|
||||
defer a.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (a *Apk) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (a *Apk) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Apk(t *testing.T) {
|
||||
assert.False(t, caps.RepoManagement, "apk should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "apk should not support KeyManagement")
|
||||
assert.False(t, caps.Groups, "apk should not support Groups")
|
||||
assert.False(t, caps.NameNormalize, "apk should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "apk should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
399
apk/apk_test.go
399
apk/apk_test.go
@@ -17,13 +17,23 @@ func TestSplitNameVersion(t *testing.T) {
|
||||
{"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"},
|
||||
{"go-1.21.5-r0", "go", "1.21.5-r0"},
|
||||
{"noversion", "noversion", ""},
|
||||
// Edge cases
|
||||
{"", "", ""},
|
||||
{"a-1", "a", "1"},
|
||||
{"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"},
|
||||
{"a-b-c-3.0", "a-b-c", "3.0"},
|
||||
{"single", "single", ""},
|
||||
{"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0
|
||||
{"pkg-0", "pkg", "0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, ver := splitNameVersion(tt.input)
|
||||
if name != tt.name || ver != tt.version {
|
||||
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, name, ver, tt.name, tt.version)
|
||||
}
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
name, ver := splitNameVersion(tt.input)
|
||||
if name != tt.name || ver != tt.version {
|
||||
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, name, ver, tt.name, tt.version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +56,111 @@ musl-1.2.4-r2 x86_64 {musl} (MIT) [installed]
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListInstalledEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkgs := parseListInstalled("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace only", func(t *testing.T) {
|
||||
pkgs := parseListInstalled(" \n \n ")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single package", func(t *testing.T) {
|
||||
pkgs := parseListInstalled("busybox-1.36.1-r5 x86_64 {busybox} (GPL-2.0-only) [installed]\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "busybox" || pkgs[0].Version != "1.36.1-r5" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not installed", func(t *testing.T) {
|
||||
pkgs := parseListInstalled("curl-8.5.0-r0 x86_64 {curl} (MIT)\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Installed {
|
||||
t.Error("expected Installed=false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseListLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
wantName string
|
||||
wantVer string
|
||||
wantArch string
|
||||
installed bool
|
||||
}{
|
||||
{
|
||||
name: "full line",
|
||||
line: "curl-8.5.0-r0 x86_64 {curl} (MIT) [installed]",
|
||||
wantName: "curl",
|
||||
wantVer: "8.5.0-r0",
|
||||
wantArch: "x86_64",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
name: "no installed marker",
|
||||
line: "vim-9.0-r0 x86_64 {vim} (Vim)",
|
||||
wantName: "vim",
|
||||
wantVer: "9.0-r0",
|
||||
wantArch: "x86_64",
|
||||
installed: false,
|
||||
},
|
||||
{
|
||||
name: "name only",
|
||||
line: "curl-8.5.0-r0",
|
||||
wantName: "curl",
|
||||
wantVer: "8.5.0-r0",
|
||||
wantArch: "",
|
||||
installed: false,
|
||||
},
|
||||
{
|
||||
name: "empty line",
|
||||
line: "",
|
||||
wantName: "",
|
||||
wantVer: "",
|
||||
wantArch: "",
|
||||
installed: false,
|
||||
},
|
||||
{
|
||||
name: "aarch64 arch",
|
||||
line: "openssl-3.1.4-r0 aarch64 {openssl} (Apache-2.0) [installed]",
|
||||
wantName: "openssl",
|
||||
wantVer: "3.1.4-r0",
|
||||
wantArch: "aarch64",
|
||||
installed: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkg := parseListLine(tt.line)
|
||||
if pkg.Name != tt.wantName {
|
||||
t.Errorf("Name = %q, want %q", pkg.Name, tt.wantName)
|
||||
}
|
||||
if pkg.Version != tt.wantVer {
|
||||
t.Errorf("Version = %q, want %q", pkg.Version, tt.wantVer)
|
||||
}
|
||||
if pkg.Arch != tt.wantArch {
|
||||
t.Errorf("Arch = %q, want %q", pkg.Arch, tt.wantArch)
|
||||
}
|
||||
if pkg.Installed != tt.installed {
|
||||
t.Errorf("Installed = %v, want %v", pkg.Installed, tt.installed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
// verbose output
|
||||
output := `curl-8.5.0-r0 - URL retrieval utility and library
|
||||
@@ -76,6 +191,51 @@ curl-doc-8.5.0-r0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkgs := parseSearch("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single result verbose", func(t *testing.T) {
|
||||
pkgs := parseSearch("nginx-1.24.0-r0 - HTTP and reverse proxy server\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "nginx" {
|
||||
t.Errorf("expected nginx, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "HTTP and reverse proxy server" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single result plain", func(t *testing.T) {
|
||||
pkgs := parseSearch("nginx-1.24.0-r0\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0-r0" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("description with hyphens", func(t *testing.T) {
|
||||
pkgs := parseSearch("git-2.43.0-r0 - Distributed version control system - fast\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Description != "Distributed version control system - fast" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
output := `curl-8.5.0-r0 installed size:
|
||||
description: URL retrieval utility and library
|
||||
@@ -94,11 +254,84 @@ webpage: https://curl.se/
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkg := parseInfo("")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil (parseInfo always returns a pkg)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no description", func(t *testing.T) {
|
||||
pkg := parseInfo("arch: aarch64\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Arch != "aarch64" {
|
||||
t.Errorf("expected aarch64, got %q", pkg.Arch)
|
||||
}
|
||||
if pkg.Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple colons in value", func(t *testing.T) {
|
||||
pkg := parseInfo("description: A tool: does things: really well\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
// Note: strings.Cut splits on first colon only
|
||||
if pkg.Description != "A tool: does things: really well" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseInfoNameVersion(t *testing.T) {
|
||||
output := "curl-8.5.0-r0 description:\nsome stuff"
|
||||
name, ver := parseInfoNameVersion(output)
|
||||
if name != "curl" || ver != "8.5.0-r0" {
|
||||
t.Errorf("got (%q, %q), want (curl, 8.5.0-r0)", name, ver)
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantN string
|
||||
wantV string
|
||||
}{
|
||||
{
|
||||
name: "standard",
|
||||
input: "curl-8.5.0-r0 description:\nsome stuff",
|
||||
wantN: "curl",
|
||||
wantV: "8.5.0-r0",
|
||||
},
|
||||
{
|
||||
name: "single line no version",
|
||||
input: "noversion",
|
||||
wantN: "noversion",
|
||||
wantV: "",
|
||||
},
|
||||
{
|
||||
name: "multi-hyphen name",
|
||||
input: "lib-ssl-dev-3.0.0-r0 some text",
|
||||
wantN: "lib-ssl-dev",
|
||||
wantV: "3.0.0-r0",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantN: "",
|
||||
wantV: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace_only",
|
||||
input: " ",
|
||||
wantN: "",
|
||||
wantV: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name, ver := parseInfoNameVersion(tt.input)
|
||||
if name != tt.wantN || ver != tt.wantV {
|
||||
t.Errorf("got (%q, %q), want (%q, %q)", name, ver, tt.wantN, tt.wantV)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,3 +345,151 @@ func TestName(t *testing.T) {
|
||||
t.Errorf("expected apk, got %q", a.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time interface compliance checks
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Apk)(nil)
|
||||
_ snack.Cleaner = (*Apk)(nil)
|
||||
_ snack.FileOwner = (*Apk)(nil)
|
||||
_ snack.DryRunner = (*Apk)(nil)
|
||||
_ snack.PackageUpgrader = (*Apk)(nil)
|
||||
)
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
// Verify at test time as well
|
||||
var m snack.Manager = New()
|
||||
if _, ok := m.(snack.VersionQuerier); !ok {
|
||||
t.Error("Apk should implement VersionQuerier")
|
||||
}
|
||||
if _, ok := m.(snack.Cleaner); !ok {
|
||||
t.Error("Apk should implement Cleaner")
|
||||
}
|
||||
if _, ok := m.(snack.FileOwner); !ok {
|
||||
t.Error("Apk should implement FileOwner")
|
||||
}
|
||||
if _, ok := m.(snack.DryRunner); !ok {
|
||||
t.Error("Apk should implement DryRunner")
|
||||
}
|
||||
if _, ok := m.(snack.PackageUpgrader); !ok {
|
||||
t.Error("Apk should implement PackageUpgrader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.Clean {
|
||||
t.Error("expected Clean=true")
|
||||
}
|
||||
if !caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=true")
|
||||
}
|
||||
if !caps.DryRun {
|
||||
t.Error("expected DryRun=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsDryRun(t *testing.T) {
|
||||
a := New()
|
||||
if !a.SupportsDryRun() {
|
||||
t.Error("SupportsDryRun() should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgradeSimulation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "OK only",
|
||||
input: "OK: 123 MiB in 45 packages\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single upgrade",
|
||||
input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "curl", Version: "8.6.0-r0", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple upgrades",
|
||||
input: `(1/3) Upgrading musl (1.2.4-r2 -> 1.2.5-r0)
|
||||
(2/3) Upgrading openssl (3.1.4-r0 -> 3.2.0-r0)
|
||||
(3/3) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)
|
||||
OK: 123 MiB in 45 packages
|
||||
`,
|
||||
wantLen: 3,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "musl", Version: "1.2.5-r0", Installed: true},
|
||||
{Name: "openssl", Version: "3.2.0-r0", Installed: true},
|
||||
{Name: "curl", Version: "8.6.0-r0", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-upgrade lines only",
|
||||
input: "Purging old package\nInstalling new-pkg\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "upgrade without version parens",
|
||||
input: "(1/1) Upgrading busybox\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "busybox", Version: "", Installed: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseUpgradeSimulation(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
if pkgs[i].Name != want.Name {
|
||||
t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, want.Name)
|
||||
}
|
||||
if pkgs[i].Version != want.Version {
|
||||
t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, want.Version)
|
||||
}
|
||||
if pkgs[i].Installed != want.Installed {
|
||||
t.Errorf("pkg[%d].Installed = %v, want %v", i, pkgs[i].Installed, want.Installed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Apk)(nil)
|
||||
_ snack.Cleaner = (*Apk)(nil)
|
||||
_ snack.FileOwner = (*Apk)(nil)
|
||||
_ snack.NameNormalizer = (*Apk)(nil)
|
||||
_ snack.DryRunner = (*Apk)(nil)
|
||||
)
|
||||
|
||||
|
||||
@@ -44,45 +44,6 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return parseUpgradeSimulation(string(out)), nil
|
||||
}
|
||||
|
||||
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
|
||||
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
func parseUpgradeSimulation(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "Upgrading") {
|
||||
continue
|
||||
}
|
||||
// "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
idx := strings.Index(line, "Upgrading ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[idx+len("Upgrading "):]
|
||||
// "pkg (oldver -> newver)"
|
||||
parts := strings.SplitN(rest, " (", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
var ver string
|
||||
if len(parts) == 2 {
|
||||
// "oldver -> newver)"
|
||||
verPart := strings.TrimSuffix(parts[1], ")")
|
||||
arrow := strings.Split(verPart, " -> ")
|
||||
if len(arrow) == 2 {
|
||||
ver = strings.TrimSpace(arrow[1])
|
||||
}
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: name,
|
||||
Version: ver,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
|
||||
38
apk/normalize.go
Normal file
38
apk/normalize.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package apk
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Alpine package names sometimes include version suffixes in queries.
|
||||
// This strips common version patterns.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseArchNormalize(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// Alpine package names typically don't embed architecture in the package name,
|
||||
// but some query outputs may include it. Common patterns:
|
||||
// - package-x86_64
|
||||
// - package-aarch64
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
knownArchs := map[string]bool{
|
||||
"x86_64": true,
|
||||
"x86": true,
|
||||
"aarch64": true,
|
||||
"armhf": true,
|
||||
"armv7": true,
|
||||
"ppc64le": true,
|
||||
"s390x": true,
|
||||
"riscv64": true,
|
||||
"loongarch64": true,
|
||||
}
|
||||
|
||||
if idx := strings.LastIndex(name, "-"); idx >= 0 {
|
||||
suffix := name[idx+1:]
|
||||
if knownArchs[suffix] {
|
||||
return name[:idx], suffix
|
||||
}
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
71
apk/normalize_test.go
Normal file
71
apk/normalize_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package apk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"curl", "curl"},
|
||||
{"curl-x86_64", "curl"},
|
||||
{"openssl-aarch64", "openssl"},
|
||||
{"musl-armhf", "musl"},
|
||||
{"busybox-armv7", "busybox"},
|
||||
{"lib-ssl-dev-x86", "lib-ssl-dev"},
|
||||
{"zlib-ppc64le", "zlib"},
|
||||
{"kernel-s390x", "kernel"},
|
||||
{"toolchain-riscv64", "toolchain"},
|
||||
{"app-loongarch64", "app"},
|
||||
// No arch suffix — unchanged
|
||||
{"python", "python"},
|
||||
{"go", "go"},
|
||||
{"", ""},
|
||||
// Suffix that isn't an arch — unchanged
|
||||
{"my-pkg-foo", "my-pkg-foo"},
|
||||
{"libfoo-1.0", "libfoo-1.0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"x86_64", "curl-x86_64", "curl", "x86_64"},
|
||||
{"x86", "musl-x86", "musl", "x86"},
|
||||
{"aarch64", "openssl-aarch64", "openssl", "aarch64"},
|
||||
{"armhf", "busybox-armhf", "busybox", "armhf"},
|
||||
{"armv7", "lib-armv7", "lib", "armv7"},
|
||||
{"ppc64le", "app-ppc64le", "app", "ppc64le"},
|
||||
{"s390x", "pkg-s390x", "pkg", "s390x"},
|
||||
{"riscv64", "tool-riscv64", "tool", "riscv64"},
|
||||
{"loongarch64", "gcc-loongarch64", "gcc", "loongarch64"},
|
||||
{"no arch", "curl", "curl", ""},
|
||||
{"unknown suffix", "pkg-foobar", "pkg-foobar", ""},
|
||||
{"empty", "", "", ""},
|
||||
{"hyphen but not arch", "lib-ssl-dev", "lib-ssl-dev", ""},
|
||||
{"multi hyphen with arch", "lib-ssl-dev-x86_64", "lib-ssl-dev", "x86_64"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotArch := parseArchNormalize(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("parseArchNormalize(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
apk/parse.go
49
apk/parse.go
@@ -127,10 +127,51 @@ func parseInfo(output string) *snack.Package {
|
||||
// The first line is typically "pkgname-version description".
|
||||
func parseInfoNameVersion(output string) (string, string) {
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) == 0 {
|
||||
if len(lines) == 0 || lines[0] == "" {
|
||||
return "", ""
|
||||
}
|
||||
// first line: name-version
|
||||
first := strings.Fields(lines[0])[0]
|
||||
return splitNameVersion(first)
|
||||
fields := strings.Fields(lines[0])
|
||||
if len(fields) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
return splitNameVersion(fields[0])
|
||||
}
|
||||
|
||||
// parseUpgradeSimulation parses `apk upgrade --simulate` output.
|
||||
// Lines look like: "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
func parseUpgradeSimulation(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "Upgrading") {
|
||||
continue
|
||||
}
|
||||
// "(1/3) Upgrading pkg (oldver -> newver)"
|
||||
idx := strings.Index(line, "Upgrading ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[idx+len("Upgrading "):]
|
||||
// "pkg (oldver -> newver)"
|
||||
parts := strings.SplitN(rest, " (", 2)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
var ver string
|
||||
if len(parts) == 2 {
|
||||
// "oldver -> newver)"
|
||||
verPart := strings.TrimSuffix(parts[1], ")")
|
||||
arrow := strings.Split(verPart, " -> ")
|
||||
if len(arrow) == 2 {
|
||||
ver = strings.TrimSpace(arrow[1])
|
||||
}
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: name,
|
||||
Version: ver,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -23,17 +23,11 @@ func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Candidate:") {
|
||||
candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
|
||||
if candidate == "(none)" {
|
||||
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
candidate := parsePolicyCandidate(string(out))
|
||||
if candidate == "" {
|
||||
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
@@ -45,38 +39,7 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apt-get --just-print upgrade: %w", err)
|
||||
}
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// Lines starting with "Inst " indicate upgradable packages.
|
||||
// Format: "Inst pkg [old-ver] (new-ver repo [arch])"
|
||||
if !strings.HasPrefix(line, "Inst ") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "Inst ")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
name := fields[0]
|
||||
// Find the new version in parentheses
|
||||
parenStart := strings.Index(line, "(")
|
||||
parenEnd := strings.Index(line, ")")
|
||||
if parenStart < 0 || parenEnd < 0 {
|
||||
continue
|
||||
}
|
||||
verFields := strings.Fields(line[parenStart+1 : parenEnd])
|
||||
if len(verFields) < 1 {
|
||||
continue
|
||||
}
|
||||
p := snack.Package{
|
||||
Name: name,
|
||||
Version: verFields[0],
|
||||
Installed: true,
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs, nil
|
||||
return parseUpgradeSimulation(string(out)), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
@@ -85,19 +48,12 @@ func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
var installed, candidate string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Installed:") {
|
||||
installed = strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
|
||||
} else if strings.HasPrefix(line, "Candidate:") {
|
||||
candidate = strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
|
||||
}
|
||||
}
|
||||
if installed == "(none)" || installed == "" {
|
||||
installed := parsePolicyInstalled(string(out))
|
||||
candidate := parsePolicyCandidate(string(out))
|
||||
if installed == "" {
|
||||
return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
if candidate == "(none)" || candidate == "" || candidate == installed {
|
||||
if candidate == "" || candidate == installed {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
@@ -148,15 +104,7 @@ func listHeld(ctx context.Context) ([]snack.Package, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apt-mark showhold: %w", err)
|
||||
}
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{Name: line, Installed: true})
|
||||
}
|
||||
return pkgs, nil
|
||||
return parseHoldList(string(out)), nil
|
||||
}
|
||||
|
||||
func isHeld(ctx context.Context, pkg string) (bool, error) {
|
||||
@@ -198,14 +146,7 @@ func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg)
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
return parseFileList(string(out)), nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
@@ -216,18 +157,11 @@ func owner(ctx context.Context, path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
// Output format: "package: /path/to/file" or "package1, package2: /path"
|
||||
line := strings.TrimSpace(strings.Split(string(out), "\n")[0])
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx < 0 {
|
||||
pkg := parseOwner(string(out))
|
||||
if pkg == "" {
|
||||
return "", fmt.Errorf("dpkg -S %s: unexpected output", path)
|
||||
}
|
||||
// Return first package if multiple
|
||||
pkgPart := line[:colonIdx]
|
||||
if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 {
|
||||
pkgPart = strings.TrimSpace(pkgPart[:commaIdx])
|
||||
}
|
||||
return strings.TrimSpace(pkgPart), nil
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// --- RepoManager ---
|
||||
@@ -249,52 +183,14 @@ func listRepos(_ context.Context) ([]snack.Repository, error) {
|
||||
}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
enabled := true
|
||||
// deb822 format (.sources files) not fully parsed; treat as single entry
|
||||
if strings.HasPrefix(line, "deb ") || strings.HasPrefix(line, "deb-src ") {
|
||||
repos = append(repos, snack.Repository{
|
||||
ID: line,
|
||||
URL: extractURL(line),
|
||||
Enabled: enabled,
|
||||
Type: strings.Fields(line)[0],
|
||||
})
|
||||
if r := parseSourcesLine(scanner.Text()); r != nil {
|
||||
repos = append(repos, *r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// extractURL pulls the URL from a deb/deb-src line.
|
||||
func extractURL(line string) string {
|
||||
fields := strings.Fields(line)
|
||||
inOptions := false
|
||||
for i, f := range fields {
|
||||
if i == 0 {
|
||||
continue // skip deb/deb-src
|
||||
}
|
||||
if inOptions {
|
||||
if strings.HasSuffix(f, "]") {
|
||||
inOptions = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(f, "[") {
|
||||
if strings.HasSuffix(f, "]") {
|
||||
// Single-token options like [arch=amd64]
|
||||
continue
|
||||
}
|
||||
inOptions = true
|
||||
continue
|
||||
}
|
||||
return f
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func addRepo(ctx context.Context, repo snack.Repository) error {
|
||||
repoLine := repo.URL
|
||||
if repo.Type != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
152
apt/parse.go
152
apt/parse.go
@@ -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{}
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestParseList_EdgeCases(t *testing.T) {
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"whitespace_only", " \n \n ", 0},
|
||||
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
|
||||
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
|
||||
{"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields
|
||||
{"no_description", "bash\t5.2-1", 1}, // 2 fields is OK
|
||||
{"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1},
|
||||
{"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2},
|
||||
{"trailing_newline", "bash\t5.2-1\n", 1},
|
||||
@@ -194,3 +194,445 @@ func TestParseInfo_EdgeCases(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- New parse function tests ---
|
||||
|
||||
func TestParsePolicyCandidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal_policy_output",
|
||||
input: `bash:
|
||||
Installed: 5.2-1
|
||||
Candidate: 5.2-2
|
||||
Version table:
|
||||
5.2-2 500
|
||||
500 http://archive.ubuntu.com/ubuntu jammy/main amd64 Packages
|
||||
*** 5.2-1 100
|
||||
100 /var/lib/dpkg/status`,
|
||||
want: "5.2-2",
|
||||
},
|
||||
{
|
||||
name: "candidate_none",
|
||||
input: `virtual-pkg:
|
||||
Installed: (none)
|
||||
Candidate: (none)
|
||||
Version table:`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_input",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "installed_equals_candidate",
|
||||
input: `curl:
|
||||
Installed: 7.88.1-10+deb12u4
|
||||
Candidate: 7.88.1-10+deb12u4
|
||||
Version table:
|
||||
*** 7.88.1-10+deb12u4 500`,
|
||||
want: "7.88.1-10+deb12u4",
|
||||
},
|
||||
{
|
||||
name: "epoch_version",
|
||||
input: `systemd:
|
||||
Installed: 1:252-2
|
||||
Candidate: 1:252-3
|
||||
Version table:`,
|
||||
want: "1:252-3",
|
||||
},
|
||||
{
|
||||
name: "no_candidate_line",
|
||||
input: "bash:\n Installed: 5.2-1\n",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parsePolicyCandidate(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parsePolicyCandidate() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolicyInstalled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
input: `bash:
|
||||
Installed: 5.2-1
|
||||
Candidate: 5.2-2`,
|
||||
want: "5.2-1",
|
||||
},
|
||||
{
|
||||
name: "not_installed",
|
||||
input: `foo:
|
||||
Installed: (none)
|
||||
Candidate: 1.0`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "epoch_version",
|
||||
input: `systemd:
|
||||
Installed: 1:252-2
|
||||
Candidate: 1:252-3`,
|
||||
want: "1:252-2",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parsePolicyInstalled(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parsePolicyInstalled() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgradeSimulation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single_upgrade",
|
||||
input: `Reading package lists...
|
||||
Building dependency tree...
|
||||
Reading state information...
|
||||
The following packages will be upgraded:
|
||||
bash
|
||||
1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
||||
Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
|
||||
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])`,
|
||||
want: []snack.Package{
|
||||
{Name: "bash", Version: "5.2-2", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple_upgrades",
|
||||
input: `Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64])
|
||||
Inst curl [7.88.0] (7.88.1 Ubuntu:22.04/jammy [amd64])
|
||||
Inst systemd [1:252-1] (1:252-2 Ubuntu:22.04/jammy [amd64])
|
||||
Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])
|
||||
Conf curl (7.88.1 Ubuntu:22.04/jammy [amd64])
|
||||
Conf systemd (1:252-2 Ubuntu:22.04/jammy [amd64])`,
|
||||
want: []snack.Package{
|
||||
{Name: "bash", Version: "5.2-2", Installed: true},
|
||||
{Name: "curl", Version: "7.88.1", Installed: true},
|
||||
{Name: "systemd", Version: "1:252-2", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no_inst_lines",
|
||||
input: "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed.\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "inst_without_parens",
|
||||
input: "Inst bash no-parens-here\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "inst_with_empty_parens",
|
||||
input: "Inst bash [5.2-1] ()\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "conf_lines_ignored",
|
||||
input: "Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])\n",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseUpgradeSimulation(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("parseUpgradeSimulation() returned %d packages, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i, g := range got {
|
||||
w := tt.want[i]
|
||||
if g.Name != w.Name || g.Version != w.Version || g.Installed != w.Installed {
|
||||
t.Errorf("package[%d] = %+v, want %+v", i, g, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHoldList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", nil},
|
||||
{"whitespace_only", " \n \n", nil},
|
||||
{"single_package", "bash\n", []string{"bash"}},
|
||||
{"multiple_packages", "bash\ncurl\nnginx\n", []string{"bash", "curl", "nginx"}},
|
||||
{"blank_lines_mixed", "\nbash\n\ncurl\n\n", []string{"bash", "curl"}},
|
||||
{"trailing_whitespace", " bash \n curl \n", []string{"bash", "curl"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseHoldList(tt.input)
|
||||
var names []string
|
||||
for _, p := range pkgs {
|
||||
names = append(names, p.Name)
|
||||
if !p.Installed {
|
||||
t.Errorf("expected Installed=true for %q", p.Name)
|
||||
}
|
||||
}
|
||||
if len(names) != len(tt.want) {
|
||||
t.Fatalf("got %d packages, want %d", len(names), len(tt.want))
|
||||
}
|
||||
for i, n := range names {
|
||||
if n != tt.want[i] {
|
||||
t.Errorf("package[%d] = %q, want %q", i, n, tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", nil},
|
||||
{"whitespace_only", " \n\n ", nil},
|
||||
{
|
||||
name: "single_file",
|
||||
input: "/usr/bin/bash\n",
|
||||
want: []string{"/usr/bin/bash"},
|
||||
},
|
||||
{
|
||||
name: "multiple_files",
|
||||
input: "/.\n/usr\n/usr/bin\n/usr/bin/bash\n/usr/share/man/man1/bash.1.gz\n",
|
||||
want: []string{"/.", "/usr", "/usr/bin", "/usr/bin/bash", "/usr/share/man/man1/bash.1.gz"},
|
||||
},
|
||||
{
|
||||
name: "blank_lines_mixed",
|
||||
input: "\n/usr/bin/curl\n\n/usr/share/doc/curl\n\n",
|
||||
want: []string{"/usr/bin/curl", "/usr/share/doc/curl"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFileList(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("parseFileList() returned %d files, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i, f := range got {
|
||||
if f != tt.want[i] {
|
||||
t.Errorf("file[%d] = %q, want %q", i, f, tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOwner(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single_package",
|
||||
input: "bash: /usr/bin/bash\n",
|
||||
want: "bash",
|
||||
},
|
||||
{
|
||||
name: "multiple_packages",
|
||||
input: "bash, dash: /usr/bin/sh\n",
|
||||
want: "bash",
|
||||
},
|
||||
{
|
||||
name: "package_with_arch",
|
||||
input: "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n",
|
||||
want: "libc6", // parseOwner splits on first colon, arch suffix is stripped
|
||||
},
|
||||
{
|
||||
name: "multiple_lines",
|
||||
input: "coreutils: /usr/bin/ls\ncoreutils: /usr/bin/cat\n",
|
||||
want: "coreutils",
|
||||
},
|
||||
{
|
||||
name: "no_colon",
|
||||
input: "unexpected output without colon",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace_around_package",
|
||||
input: " nginx : /usr/sbin/nginx\n",
|
||||
want: "nginx",
|
||||
},
|
||||
{
|
||||
name: "three_packages_comma_separated",
|
||||
input: "pkg1, pkg2, pkg3: /some/path\n",
|
||||
want: "pkg1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseOwner(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseOwner() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourcesLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNil bool
|
||||
wantURL string
|
||||
wantTyp string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "comment",
|
||||
input: "# This is a comment",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "non_deb_line",
|
||||
input: "some random text",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "basic_deb",
|
||||
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
|
||||
wantURL: "http://archive.ubuntu.com/ubuntu/",
|
||||
wantTyp: "deb",
|
||||
},
|
||||
{
|
||||
name: "deb_src",
|
||||
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
|
||||
wantURL: "http://archive.ubuntu.com/ubuntu/",
|
||||
wantTyp: "deb-src",
|
||||
},
|
||||
{
|
||||
name: "with_options",
|
||||
input: "deb [arch=amd64] https://repo.example.com/deb stable main",
|
||||
wantURL: "https://repo.example.com/deb",
|
||||
wantTyp: "deb",
|
||||
},
|
||||
{
|
||||
name: "with_signed_by",
|
||||
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com stable main",
|
||||
wantURL: "https://repo.example.com",
|
||||
wantTyp: "deb",
|
||||
},
|
||||
{
|
||||
name: "leading_whitespace",
|
||||
input: " deb http://example.com/repo stable main",
|
||||
wantURL: "http://example.com/repo",
|
||||
wantTyp: "deb",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := parseSourcesLine(tt.input)
|
||||
if tt.wantNil {
|
||||
if r != nil {
|
||||
t.Errorf("parseSourcesLine() = %+v, want nil", r)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("parseSourcesLine() = nil, want non-nil")
|
||||
}
|
||||
if r.URL != tt.wantURL {
|
||||
t.Errorf("URL = %q, want %q", r.URL, tt.wantURL)
|
||||
}
|
||||
if r.Type != tt.wantTyp {
|
||||
t.Errorf("Type = %q, want %q", r.Type, tt.wantTyp)
|
||||
}
|
||||
if !r.Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "basic_deb",
|
||||
input: "deb http://archive.ubuntu.com/ubuntu/ jammy main",
|
||||
want: "http://archive.ubuntu.com/ubuntu/",
|
||||
},
|
||||
{
|
||||
name: "deb_src",
|
||||
input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main",
|
||||
want: "http://archive.ubuntu.com/ubuntu/",
|
||||
},
|
||||
{
|
||||
name: "with_options",
|
||||
input: "deb [arch=amd64] https://apt.example.com/repo stable main",
|
||||
want: "https://apt.example.com/repo",
|
||||
},
|
||||
{
|
||||
name: "with_signed_by",
|
||||
input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main",
|
||||
want: "https://repo.example.com/deb",
|
||||
},
|
||||
{
|
||||
name: "just_type",
|
||||
input: "deb",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_options_bracket",
|
||||
input: "deb [] http://example.com/repo stable",
|
||||
want: "http://example.com/repo",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractURL(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,19 +69,29 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
|
||||
}
|
||||
}
|
||||
|
||||
pkgFile, err := a.buildPackage(ctx, t)
|
||||
pkgFile, cleanupDir, err := a.buildPackage(ctx, t)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
|
||||
}
|
||||
|
||||
if o.DryRun {
|
||||
if cleanupDir != "" {
|
||||
os.RemoveAll(cleanupDir)
|
||||
}
|
||||
installed = append(installed, snack.Package{Name: t.Name, Repository: "aur"})
|
||||
continue
|
||||
}
|
||||
|
||||
args := []string{"-U", "--noconfirm", pkgFile}
|
||||
if _, err := runPacman(ctx, args, o.Sudo); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
|
||||
installErr := func() error {
|
||||
if cleanupDir != "" {
|
||||
defer os.RemoveAll(cleanupDir)
|
||||
}
|
||||
_, err := runPacman(ctx, args, o.Sudo)
|
||||
return err
|
||||
}()
|
||||
if installErr != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, installErr)
|
||||
}
|
||||
|
||||
v, _ := version(ctx, t.Name)
|
||||
@@ -97,23 +107,25 @@ func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Op
|
||||
}
|
||||
|
||||
// buildPackage clones the AUR git repo for a package and runs makepkg.
|
||||
// Returns the path to the built .pkg.tar.zst file.
|
||||
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error) {
|
||||
// Returns the path to the built .pkg.tar.zst file and an optional cleanup
|
||||
// directory (non-empty only when a temp dir was created; caller must remove it).
|
||||
func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (pkgPath string, cleanupDir string, err error) {
|
||||
// Determine build directory
|
||||
buildDir := a.BuildDir
|
||||
if buildDir == "" {
|
||||
tmp, err := os.MkdirTemp("", "snack-aur-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating temp dir: %w", err)
|
||||
return "", "", fmt.Errorf("creating temp dir: %w", err)
|
||||
}
|
||||
buildDir = tmp
|
||||
cleanupDir = tmp
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(buildDir, t.Name)
|
||||
|
||||
// Clone or update the PKGBUILD repo
|
||||
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
|
||||
return "", err
|
||||
return "", cleanupDir, err
|
||||
}
|
||||
|
||||
// Run makepkg
|
||||
@@ -125,15 +137,15 @@ func (a *AUR) buildPackage(ctx context.Context, t snack.Target) (string, error)
|
||||
c.Stderr = &stderr
|
||||
c.Stdout = &stderr // makepkg output goes to stderr anyway
|
||||
if err := c.Run(); err != nil {
|
||||
return "", fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err)
|
||||
return "", cleanupDir, fmt.Errorf("makepkg %s: %s: %w", t.Name, strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
|
||||
// Find the built package file
|
||||
matches, err := filepath.Glob(filepath.Join(pkgDir, "*.pkg.tar*"))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return "", fmt.Errorf("makepkg %s: no package file produced", t.Name)
|
||||
return "", cleanupDir, fmt.Errorf("makepkg %s: no package file produced", t.Name)
|
||||
}
|
||||
return matches[len(matches)-1], nil
|
||||
return matches[len(matches)-1], cleanupDir, nil
|
||||
}
|
||||
|
||||
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.
|
||||
|
||||
156
brew/brew.go
Normal file
156
brew/brew.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package brew provides Go bindings for Homebrew package manager.
|
||||
package brew
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Brew wraps the brew CLI.
|
||||
type Brew struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Brew manager.
|
||||
func New() *Brew {
|
||||
return &Brew{}
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
_ snack.PackageUpgrader = (*Brew)(nil)
|
||||
)
|
||||
|
||||
// Name returns "brew".
|
||||
func (b *Brew) Name() string { return "brew" }
|
||||
|
||||
// Available reports whether brew is present on the system.
|
||||
func (b *Brew) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (b *Brew) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (b *Brew) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including all dependencies.
|
||||
func (b *Brew) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (b *Brew) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the package index.
|
||||
func (b *Brew) Update(ctx context.Context) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (b *Brew) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries Homebrew for packages.
|
||||
func (b *Brew) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (b *Brew) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (b *Brew) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (b *Brew) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (b *Brew) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
// Homebrew does not embed architecture in package names.
|
||||
func (b *Brew) ParseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
|
||||
// LatestVersion returns the latest available version of a package.
|
||||
func (b *Brew) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (b *Brew) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (b *Brew) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (b *Brew) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove removes packages that were installed as dependencies but are no longer needed.
|
||||
func (b *Brew) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return autoremove(ctx, opts...)
|
||||
}
|
||||
|
||||
// Clean removes cached package files.
|
||||
func (b *Brew) Clean(ctx context.Context) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return clean(ctx)
|
||||
}
|
||||
|
||||
// FileList returns all files installed by a package.
|
||||
func (b *Brew) FileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
return fileList(ctx, pkg)
|
||||
}
|
||||
|
||||
// Owner returns the package that owns a given file path.
|
||||
func (b *Brew) Owner(ctx context.Context, path string) (string, error) {
|
||||
return owner(ctx, path)
|
||||
}
|
||||
|
||||
// UpgradePackages upgrades specific installed packages.
|
||||
func (b *Brew) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
87
brew/brew_other.go
Normal file
87
brew/brew_other.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build !darwin && !linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
|
||||
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func fileList(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func owner(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
223
brew/brew_test.go
Normal file
223
brew/brew_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
b := New()
|
||||
if b == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
b := New()
|
||||
if b.Name() != "brew" {
|
||||
t.Errorf("Name() = %q, want %q", b.Name(), "brew")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList(t *testing.T) {
|
||||
input := `git 2.43.0
|
||||
go 1.21.6
|
||||
vim 9.1.0
|
||||
`
|
||||
pkgs := parseBrewList(input)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "git" || pkgs[0].Version != "2.43.0" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList_Empty(t *testing.T) {
|
||||
pkgs := parseBrewList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewList_SinglePackage(t *testing.T) {
|
||||
input := "curl 8.6.0\n"
|
||||
pkgs := parseBrewList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" {
|
||||
t.Errorf("expected name=curl, got %q", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewSearch(t *testing.T) {
|
||||
input := `==> Formulae
|
||||
git
|
||||
git-absorb
|
||||
git-annex
|
||||
==> Casks
|
||||
git-credential-manager
|
||||
`
|
||||
pkgs := parseBrewSearch(input)
|
||||
if len(pkgs) != 4 {
|
||||
t.Fatalf("expected 4 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "git" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewSearch_Empty(t *testing.T) {
|
||||
pkgs := parseBrewSearch("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo(t *testing.T) {
|
||||
input := `{"formulae":[{"name":"git","full_name":"git","desc":"Distributed revision control system","versions":{"stable":"2.43.0"},"installed":[{"version":"2.43.0"}]}],"casks":[]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "git" {
|
||||
t.Errorf("expected name 'git', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "2.43.0" {
|
||||
t.Errorf("unexpected version: %q", pkg.Version)
|
||||
}
|
||||
if !pkg.Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo_NotInstalled(t *testing.T) {
|
||||
input := `{"formulae":[{"name":"wget","full_name":"wget","desc":"Internet file retriever","versions":{"stable":"1.21"},"installed":[]}],"casks":[]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Installed {
|
||||
t.Error("expected Installed=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrewInfo_Cask(t *testing.T) {
|
||||
input := `{"formulae":[],"casks":[{"token":"visual-studio-code","name":["Visual Studio Code"],"desc":"Open-source code editor","version":"1.85.0"}]}`
|
||||
pkg := parseBrewInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "visual-studio-code" {
|
||||
t.Errorf("expected name 'visual-studio-code', got %q", pkg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"git", "git"},
|
||||
{"python@3.12", "python"},
|
||||
{"node@18", "node"},
|
||||
{"go", "go"},
|
||||
{"ruby@3.2", "ruby"},
|
||||
}
|
||||
|
||||
b := New()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := b.NormalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"git", "git", ""},
|
||||
// Homebrew doesn't embed arch in names - @ is version suffix
|
||||
{"python@3.12", "python@3.12", ""},
|
||||
{"node@18", "node@18", ""},
|
||||
}
|
||||
|
||||
b := New()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
gotName, gotArch := b.ParseArch(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("ParseArch(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got bool
|
||||
want bool
|
||||
}{
|
||||
{"VersionQuery", caps.VersionQuery, true},
|
||||
{"Clean", caps.Clean, true},
|
||||
{"FileOwnership", caps.FileOwnership, true},
|
||||
{"NameNormalize", caps.NameNormalize, true},
|
||||
// Homebrew does not support these
|
||||
{"Hold", caps.Hold, false},
|
||||
{"RepoManagement", caps.RepoManagement, false},
|
||||
{"KeyManagement", caps.KeyManagement, false},
|
||||
{"Groups", caps.Groups, false},
|
||||
{"DryRun", caps.DryRun, false},
|
||||
{"PackageUpgrade", caps.PackageUpgrade, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNonCompliance(t *testing.T) {
|
||||
var m snack.Manager = New()
|
||||
if _, ok := m.(snack.Holder); ok {
|
||||
t.Error("Brew should not implement Holder")
|
||||
}
|
||||
if _, ok := m.(snack.RepoManager); ok {
|
||||
t.Error("Brew should not implement RepoManager")
|
||||
}
|
||||
if _, ok := m.(snack.KeyManager); ok {
|
||||
t.Error("Brew should not implement KeyManager")
|
||||
}
|
||||
if _, ok := m.(snack.Grouper); ok {
|
||||
t.Error("Brew should not implement Grouper")
|
||||
}
|
||||
}
|
||||
473
brew/brew_unix.go
Normal file
473
brew/brew_unix.go
Normal file
@@ -0,0 +1,473 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package brew
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("brew")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "brew", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "permission denied") {
|
||||
return "", fmt.Errorf("brew: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("brew: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toInstall []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.Reinstall || t.Version != "" || o.DryRun {
|
||||
toInstall = append(toInstall, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toInstall = append(toInstall, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toInstall {
|
||||
args := []string{"install"}
|
||||
pkg := t.Name
|
||||
if t.Version != "" {
|
||||
pkg = t.Name + "@" + t.Version
|
||||
}
|
||||
args = append(args, pkg)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
}
|
||||
var installed []snack.Package
|
||||
for _, t := range toInstall {
|
||||
v, _ := version(ctx, t.Name)
|
||||
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toRemove []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toRemove = append(toRemove, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toRemove = append(toRemove, t)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
args := append([]string{"uninstall"}, snack.TargetNames(toRemove)...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
}
|
||||
var removed []snack.Package
|
||||
for _, t := range toRemove {
|
||||
removed = append(removed, snack.Package{Name: t.Name})
|
||||
}
|
||||
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"uninstall", "--zap"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"upgrade"})
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"update"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list", "--versions"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brew list: %w", err)
|
||||
}
|
||||
return parseBrewList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"search", query})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No formulae or casks found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("brew search: %w", err)
|
||||
}
|
||||
return parseBrewSearch(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"info", "--json=v2", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No available formula") ||
|
||||
strings.Contains(err.Error(), "No formulae or casks found") {
|
||||
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("brew info: %w", err)
|
||||
}
|
||||
p := parseBrewInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("brew info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "brew", "list", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("brew isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"list", "--versions", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("brew version: %w", err)
|
||||
}
|
||||
pkgs := parseBrewList(out)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("brew version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"info", "--json=v2", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No available formula") {
|
||||
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("brew latestVersion: %w", err)
|
||||
}
|
||||
ver := parseBrewInfoVersion(out)
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("brew latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"outdated", "--json=v2"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brew listUpgrades: %w", err)
|
||||
}
|
||||
return parseBrewOutdated(out), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range upgrades {
|
||||
if u.Name == pkg {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"autoremove"})
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"cleanup"})
|
||||
return err
|
||||
}
|
||||
|
||||
// brewInfoJSON represents the JSON output from `brew info --json=v2`.
|
||||
type brewInfoJSON struct {
|
||||
Formulae []struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Desc string `json:"desc"`
|
||||
Versions struct {
|
||||
Stable string `json:"stable"`
|
||||
} `json:"versions"`
|
||||
Installed []struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"installed"`
|
||||
} `json:"formulae"`
|
||||
Casks []struct {
|
||||
Token string `json:"token"`
|
||||
Name []string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Version string `json:"version"`
|
||||
} `json:"casks"`
|
||||
}
|
||||
|
||||
// brewOutdatedJSON represents the JSON output from `brew outdated --json=v2`.
|
||||
type brewOutdatedJSON struct {
|
||||
Formulae []struct {
|
||||
Name string `json:"name"`
|
||||
InstalledVersions []string `json:"installed_versions"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
} `json:"formulae"`
|
||||
Casks []struct {
|
||||
Name string `json:"name"`
|
||||
InstalledVersions string `json:"installed_versions"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
} `json:"casks"`
|
||||
}
|
||||
|
||||
func parseBrewList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 1 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Installed: true,
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
pkg.Version = fields[1]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func parseBrewSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "==>") {
|
||||
continue
|
||||
}
|
||||
for _, name := range strings.Fields(line) {
|
||||
pkgs = append(pkgs, snack.Package{Name: name})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func parseBrewInfo(output string) *snack.Package {
|
||||
var data brewInfoJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(data.Formulae) > 0 {
|
||||
f := data.Formulae[0]
|
||||
pkg := &snack.Package{
|
||||
Name: f.Name,
|
||||
Version: f.Versions.Stable,
|
||||
Description: f.Desc,
|
||||
}
|
||||
if len(f.Installed) > 0 {
|
||||
pkg.Installed = true
|
||||
pkg.Version = f.Installed[0].Version
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
if len(data.Casks) > 0 {
|
||||
c := data.Casks[0]
|
||||
return &snack.Package{
|
||||
Name: c.Token,
|
||||
Version: c.Version,
|
||||
Description: c.Desc,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBrewInfoVersion(output string) string {
|
||||
var data brewInfoJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(data.Formulae) > 0 {
|
||||
return data.Formulae[0].Versions.Stable
|
||||
}
|
||||
if len(data.Casks) > 0 {
|
||||
return data.Casks[0].Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBrewOutdated(output string) []snack.Package {
|
||||
var data brewOutdatedJSON
|
||||
if err := json.Unmarshal([]byte(output), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
var pkgs []snack.Package
|
||||
for _, f := range data.Formulae {
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: f.Name,
|
||||
Version: f.CurrentVersion,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
for _, c := range data.Casks {
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: c.Name,
|
||||
Version: c.CurrentVersion,
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func semverCmp(a, b string) int {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
|
||||
maxLen := len(partsA)
|
||||
if len(partsB) > maxLen {
|
||||
maxLen = len(partsB)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var numA, numB int
|
||||
if i < len(partsA) {
|
||||
fmt.Sscanf(partsA[i], "%d", &numA)
|
||||
}
|
||||
if i < len(partsB) {
|
||||
fmt.Sscanf(partsB[i], "%d", &numB)
|
||||
}
|
||||
if numA < numB {
|
||||
return -1
|
||||
}
|
||||
if numA > numB {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := run(ctx, []string{"list", "--formula", pkg})
|
||||
if err != nil {
|
||||
// Try cask
|
||||
out, err = run(ctx, []string{"list", "--cask", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("brew fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("brew fileList: %w", err)
|
||||
}
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
// brew doesn't have a direct "which package owns this file" command
|
||||
// We need to iterate through installed packages and check their files
|
||||
out, err := run(ctx, []string{"list", "--formula"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("brew owner: %w", err)
|
||||
}
|
||||
for _, pkg := range strings.Fields(out) {
|
||||
files, err := fileList(ctx, pkg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range files {
|
||||
if f == path || strings.HasSuffix(f, "/"+path) {
|
||||
return pkg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("brew owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
if _, err := run(ctx, []string{"upgrade", t.Name}); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("brew upgrade %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
for _, t := range toUpgrade {
|
||||
v, _ := version(ctx, t.Name)
|
||||
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
|
||||
}
|
||||
12
brew/capabilities.go
Normal file
12
brew/capabilities.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package brew
|
||||
|
||||
import "github.com/gogrlx/snack"
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*Brew)(nil)
|
||||
_ snack.VersionQuerier = (*Brew)(nil)
|
||||
_ snack.Cleaner = (*Brew)(nil)
|
||||
_ snack.FileOwner = (*Brew)(nil)
|
||||
_ snack.NameNormalizer = (*Brew)(nil)
|
||||
)
|
||||
21
brew/normalize.go
Normal file
21
brew/normalize.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package brew
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Homebrew formulae can have version suffixes like `python@3.12`.
|
||||
// This strips the version suffix to get the base formula name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseVersionSuffix(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseVersionSuffix extracts the version suffix from a formula name.
|
||||
// Homebrew uses @ to denote versioned formulae (e.g., "python@3.12").
|
||||
// Returns the name without version and the version string.
|
||||
func parseVersionSuffix(name string) (string, string) {
|
||||
if idx := strings.LastIndex(name, "@"); idx > 0 {
|
||||
return name[:idx], name[idx+1:]
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Capabilities struct {
|
||||
Groups bool
|
||||
NameNormalize bool
|
||||
DryRun bool
|
||||
PackageUpgrade bool
|
||||
}
|
||||
|
||||
// GetCapabilities probes a Manager for all optional interface support.
|
||||
@@ -26,6 +27,7 @@ func GetCapabilities(m Manager) Capabilities {
|
||||
_, g := m.(Grouper)
|
||||
_, nn := m.(NameNormalizer)
|
||||
_, dr := m.(DryRunner)
|
||||
_, pu := m.(PackageUpgrader)
|
||||
return Capabilities{
|
||||
VersionQuery: vq,
|
||||
Hold: h,
|
||||
@@ -36,5 +38,6 @@ func GetCapabilities(m Manager) Capabilities {
|
||||
Groups: g,
|
||||
NameNormalize: nn,
|
||||
DryRun: dr,
|
||||
PackageUpgrade: pu,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@ func (m *mockManager) Remove(context.Context, []snack.Target, ...snack.Option) (
|
||||
return snack.RemoveResult{}, nil
|
||||
}
|
||||
func (m *mockManager) Purge(context.Context, []snack.Target, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Update(context.Context) error { return nil }
|
||||
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
|
||||
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
|
||||
func (m *mockManager) Available() bool { return true }
|
||||
func (m *mockManager) Name() string { return "mock" }
|
||||
func (m *mockManager) Upgrade(context.Context, ...snack.Option) error { return nil }
|
||||
func (m *mockManager) Update(context.Context) error { return nil }
|
||||
func (m *mockManager) List(context.Context) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Search(context.Context, string) ([]snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) Info(context.Context, string) (*snack.Package, error) { return nil, nil }
|
||||
func (m *mockManager) IsInstalled(context.Context, string) (bool, error) { return false, nil }
|
||||
func (m *mockManager) Version(context.Context, string) (string, error) { return "", nil }
|
||||
func (m *mockManager) Available() bool { return true }
|
||||
func (m *mockManager) Name() string { return "mock" }
|
||||
|
||||
// fullMockManager implements Manager plus all optional interfaces.
|
||||
type fullMockManager struct {
|
||||
@@ -77,6 +77,7 @@ func TestGetCapabilities_BaseManager(t *testing.T) {
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.False(t, caps.DryRun)
|
||||
assert.False(t, caps.PackageUpgrade)
|
||||
}
|
||||
|
||||
func TestGetCapabilities_FullManager(t *testing.T) {
|
||||
@@ -90,4 +91,5 @@ func TestGetCapabilities_FullManager(t *testing.T) {
|
||||
assert.True(t, caps.Groups)
|
||||
assert.True(t, caps.NameNormalize)
|
||||
assert.True(t, caps.DryRun)
|
||||
assert.True(t, caps.PackageUpgrade)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ behind a single, consistent interface.`,
|
||||
holdCmd(),
|
||||
unholdCmd(),
|
||||
cleanCmd(),
|
||||
repoCmd(),
|
||||
keyCmd(),
|
||||
groupCmd(),
|
||||
detectCmd(),
|
||||
versionCmd(),
|
||||
)
|
||||
@@ -387,6 +390,9 @@ func detectCmd() *cobra.Command {
|
||||
if caps.NameNormalize {
|
||||
capList = append(capList, "normalize")
|
||||
}
|
||||
if caps.PackageUpgrade {
|
||||
capList = append(capList, "pkg-upgrade")
|
||||
}
|
||||
capStr := ""
|
||||
if len(capList) > 0 {
|
||||
capStr = " [" + strings.Join(capList, ", ") + "]"
|
||||
@@ -409,3 +415,237 @@ func versionCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "repo",
|
||||
Short: "Manage package repositories",
|
||||
}
|
||||
cmd.AddCommand(repoListCmd(), repoAddCmd(), repoRemoveCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func repoListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured repositories",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
repos, err := rm.ListRepos(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range repos {
|
||||
status := "disabled"
|
||||
if r.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
fmt.Printf("%s %s [%s]\n", r.ID, r.URL, status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add <url>",
|
||||
Short: "Add a package repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
repo := snack.Repository{
|
||||
URL: args[0],
|
||||
Enabled: true,
|
||||
}
|
||||
return rm.AddRepo(cmd.Context(), repo)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a package repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rm, ok := m.(snack.RepoManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support repository management", m.Name())
|
||||
}
|
||||
return rm.RemoveRepo(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "key",
|
||||
Short: "Manage GPG signing keys",
|
||||
}
|
||||
cmd.AddCommand(keyListCmd(), keyAddCmd(), keyRemoveCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func keyListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List trusted signing keys",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
keys, err := km.ListKeys(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
fmt.Println(k)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add <key>",
|
||||
Short: "Add a GPG signing key (URL, file path, or key ID)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
return km.AddKey(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <key-id>",
|
||||
Short: "Remove a GPG signing key",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
km, ok := m.(snack.KeyManager)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support key management", m.Name())
|
||||
}
|
||||
return km.RemoveKey(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "Manage package groups",
|
||||
}
|
||||
cmd.AddCommand(groupListCmd(), groupInfoCmd(), groupInstallCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func groupListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available package groups",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
groups, err := g.GroupList(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, grp := range groups {
|
||||
fmt.Println(grp)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupInfoCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "info <group>",
|
||||
Short: "Show packages in a group",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
pkgs, err := g.GroupInfo(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
fmt.Println(p.Name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func groupInstallCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "install <group>",
|
||||
Short: "Install all packages in a group",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, ok := m.(snack.Grouper)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support package groups", m.Name())
|
||||
}
|
||||
return g.GroupInstall(cmd.Context(), args[0], opts()...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
150
cmd/snack/main_test.go
Normal file
150
cmd/snack/main_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
ver string
|
||||
want []snack.Target
|
||||
}{
|
||||
{
|
||||
name: "no_args",
|
||||
args: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single_no_version",
|
||||
args: []string{"curl"},
|
||||
want: []snack.Target{{Name: "curl"}},
|
||||
},
|
||||
{
|
||||
name: "multiple_no_version",
|
||||
args: []string{"curl", "wget"},
|
||||
want: []snack.Target{{Name: "curl"}, {Name: "wget"}},
|
||||
},
|
||||
{
|
||||
name: "single_with_version",
|
||||
args: []string{"curl"},
|
||||
ver: "7.88",
|
||||
want: []snack.Target{{Name: "curl", Version: "7.88"}},
|
||||
},
|
||||
{
|
||||
name: "multiple_with_version",
|
||||
args: []string{"curl", "wget"},
|
||||
ver: "1.0",
|
||||
want: []snack.Target{{Name: "curl", Version: "1.0"}, {Name: "wget", Version: "1.0"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := targets(tt.args, tt.ver)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("targets() returned %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i, g := range got {
|
||||
if g.Name != tt.want[i].Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, g.Name, tt.want[i].Name)
|
||||
}
|
||||
if g.Version != tt.want[i].Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, g.Version, tt.want[i].Version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpts(t *testing.T) {
|
||||
// Reset flags
|
||||
flagSudo = false
|
||||
flagYes = false
|
||||
flagDry = false
|
||||
|
||||
o := opts()
|
||||
if len(o) != 0 {
|
||||
t.Errorf("expected 0 options with no flags, got %d", len(o))
|
||||
}
|
||||
|
||||
flagSudo = true
|
||||
o = opts()
|
||||
if len(o) != 1 {
|
||||
t.Errorf("expected 1 option with sudo, got %d", len(o))
|
||||
}
|
||||
|
||||
flagYes = true
|
||||
flagDry = true
|
||||
o = opts()
|
||||
if len(o) != 3 {
|
||||
t.Errorf("expected 3 options with all flags, got %d", len(o))
|
||||
}
|
||||
|
||||
// Clean up
|
||||
flagSudo = false
|
||||
flagYes = false
|
||||
flagDry = false
|
||||
}
|
||||
|
||||
func TestOptsApply(t *testing.T) {
|
||||
flagSudo = true
|
||||
flagYes = true
|
||||
flagDry = true
|
||||
defer func() {
|
||||
flagSudo = false
|
||||
flagYes = false
|
||||
flagDry = false
|
||||
}()
|
||||
|
||||
applied := snack.ApplyOptions(opts()...)
|
||||
if !applied.Sudo {
|
||||
t.Error("expected Sudo=true")
|
||||
}
|
||||
if !applied.AssumeYes {
|
||||
t.Error("expected AssumeYes=true")
|
||||
}
|
||||
if !applied.DryRun {
|
||||
t.Error("expected DryRun=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetManager(t *testing.T) {
|
||||
// Default detection
|
||||
flagMgr = ""
|
||||
m, err := getManager()
|
||||
if err != nil {
|
||||
t.Skipf("no manager available: %v", err)
|
||||
}
|
||||
if m.Name() == "" {
|
||||
t.Error("expected non-empty manager name")
|
||||
}
|
||||
|
||||
// Explicit override - use the detected manager's name
|
||||
// since not all managers are available on all platforms
|
||||
flagMgr = m.Name()
|
||||
m2, err := getManager()
|
||||
if err != nil {
|
||||
t.Fatalf("getManager() with --manager=%s failed: %v", flagMgr, err)
|
||||
}
|
||||
if m2.Name() != flagMgr {
|
||||
t.Errorf("expected Name()=%s, got %q", flagMgr, m2.Name())
|
||||
}
|
||||
|
||||
// Unknown manager
|
||||
flagMgr = "nonexistent-manager-xyz"
|
||||
_, err = getManager()
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown manager")
|
||||
}
|
||||
|
||||
flagMgr = ""
|
||||
}
|
||||
|
||||
func TestVersionString(t *testing.T) {
|
||||
if version == "" {
|
||||
t.Error("version should not be empty")
|
||||
}
|
||||
}
|
||||
20
detect/detect_darwin.go
Normal file
20
detect/detect_darwin.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build darwin
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/brew"
|
||||
)
|
||||
|
||||
// candidates returns manager factories in probe order for macOS.
|
||||
func candidates() []managerFactory {
|
||||
return []managerFactory{
|
||||
func() snack.Manager { return brew.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
func allManagers() []managerFactory {
|
||||
return candidates()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gogrlx/snack/apk"
|
||||
"github.com/gogrlx/snack/apt"
|
||||
"github.com/gogrlx/snack/aur"
|
||||
"github.com/gogrlx/snack/brew"
|
||||
"github.com/gogrlx/snack/dnf"
|
||||
"github.com/gogrlx/snack/flatpak"
|
||||
"github.com/gogrlx/snack/pacman"
|
||||
@@ -23,11 +24,12 @@ func candidates() []managerFactory {
|
||||
func() snack.Manager { return apk.New() },
|
||||
func() snack.Manager { return flatpak.New() },
|
||||
func() snack.Manager { return snap.New() },
|
||||
func() snack.Manager { return brew.New() },
|
||||
func() snack.Manager { return aur.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
// Includes supplemental managers like AUR that aren't primary candidates.
|
||||
func allManagers() []managerFactory {
|
||||
return append(candidates(), func() snack.Manager { return aur.New() })
|
||||
return candidates()
|
||||
}
|
||||
|
||||
13
detect/detect_other.go
Normal file
13
detect/detect_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux && !freebsd && !openbsd && !darwin && !windows
|
||||
|
||||
package detect
|
||||
|
||||
// candidates returns an empty list on unsupported platforms.
|
||||
func candidates() []managerFactory {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allManagers returns an empty list on unsupported platforms.
|
||||
func allManagers() []managerFactory {
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
//go:build linux
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestByNameUnknown(t *testing.T) {
|
||||
@@ -9,15 +14,62 @@ func TestByNameUnknown(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown manager")
|
||||
}
|
||||
if !errors.Is(err, snack.ErrManagerNotFound) {
|
||||
t.Errorf("expected ErrManagerNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByNameKnown(t *testing.T) {
|
||||
// All known manager names should be resolvable by ByName, even if
|
||||
// unavailable on this system.
|
||||
knownNames := []string{"apt", "dnf", "pacman", "apk", "flatpak", "snap", "aur"}
|
||||
for _, name := range knownNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, err := ByName(name)
|
||||
if err != nil {
|
||||
t.Fatalf("ByName(%q) returned error: %v", name, err)
|
||||
}
|
||||
if m.Name() != name {
|
||||
t.Errorf("ByName(%q).Name() = %q", name, m.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByNameReturnsCorrectType(t *testing.T) {
|
||||
m, err := ByName("apt")
|
||||
if err != nil {
|
||||
t.Skip("apt not in allManagers on this platform")
|
||||
}
|
||||
if m.Name() != "apt" {
|
||||
t.Errorf("expected Name()=apt, got %q", m.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReturnsSlice(t *testing.T) {
|
||||
// Just verify it doesn't panic; actual availability depends on system.
|
||||
_ = All()
|
||||
managers := All()
|
||||
// On Linux with apt installed, we should get at least 1
|
||||
// But don't fail if none — could be a weird CI environment
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range managers {
|
||||
name := m.Name()
|
||||
if seen[name] {
|
||||
t.Errorf("duplicate manager in All(): %s", name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllManagersAreAvailable(t *testing.T) {
|
||||
for _, m := range All() {
|
||||
if !m.Available() {
|
||||
t.Errorf("All() returned unavailable manager: %s", m.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDoesNotPanic(t *testing.T) {
|
||||
// May return error if no managers available; that's fine.
|
||||
Reset()
|
||||
_, _ = Default()
|
||||
}
|
||||
|
||||
@@ -37,9 +89,65 @@ func TestDefaultCachesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReturnsAvailableManager(t *testing.T) {
|
||||
Reset()
|
||||
m, err := Default()
|
||||
if err != nil {
|
||||
t.Skipf("no manager available on this system: %v", err)
|
||||
}
|
||||
if !m.Available() {
|
||||
t.Error("Default() returned unavailable manager")
|
||||
}
|
||||
if m.Name() == "" {
|
||||
t.Error("Default() returned manager with empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAllowsRedetection(t *testing.T) {
|
||||
_, _ = Default()
|
||||
Reset()
|
||||
// After reset, defaultOnce should be fresh; calling Default() again should work.
|
||||
// After reset, calling Default() again should work.
|
||||
_, _ = Default()
|
||||
}
|
||||
|
||||
func TestResetConcurrent(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
Reset()
|
||||
_, _ = Default()
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasBinary(t *testing.T) {
|
||||
// sh should exist on any Unix system
|
||||
if !HasBinary("sh") {
|
||||
t.Error("expected HasBinary(sh) = true")
|
||||
}
|
||||
if HasBinary("this-binary-does-not-exist-anywhere-12345") {
|
||||
t.Error("expected HasBinary(nonexistent) = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesNotEmpty(t *testing.T) {
|
||||
c := candidates()
|
||||
if len(c) == 0 {
|
||||
t.Error("candidates() returned empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllManagersNotEmpty(t *testing.T) {
|
||||
a := allManagers()
|
||||
if len(a) == 0 {
|
||||
t.Error("allManagers() returned empty slice")
|
||||
}
|
||||
// allManagers should be a superset of candidates
|
||||
if len(a) < len(candidates()) {
|
||||
t.Error("allManagers() should include at least all candidates")
|
||||
}
|
||||
}
|
||||
|
||||
20
detect/detect_windows.go
Normal file
20
detect/detect_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package detect
|
||||
|
||||
import (
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
)
|
||||
|
||||
// candidates returns manager factories in probe order for Windows.
|
||||
func candidates() []managerFactory {
|
||||
return []managerFactory{
|
||||
func() snack.Manager { return winget.New() },
|
||||
}
|
||||
}
|
||||
|
||||
// allManagers returns all known manager factories (for ByName).
|
||||
func allManagers() []managerFactory {
|
||||
return candidates()
|
||||
}
|
||||
87
dnf/dnf_test.go
Normal file
87
dnf/dnf_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
74
dpkg/normalize_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,32 @@ import (
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Flatpak)(nil)
|
||||
_ snack.Cleaner = (*Flatpak)(nil)
|
||||
_ snack.RepoManager = (*Flatpak)(nil)
|
||||
_ snack.VersionQuerier = (*Flatpak)(nil)
|
||||
_ snack.NameNormalizer = (*Flatpak)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version of a flatpak.
|
||||
func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns flatpaks that have newer versions available.
|
||||
func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove removes unused runtimes and extensions.
|
||||
func (f *Flatpak) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
@@ -43,25 +64,3 @@ func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error {
|
||||
defer f.Unlock()
|
||||
return removeRepo(ctx, id)
|
||||
}
|
||||
|
||||
// LatestVersion returns the latest available version of a flatpak from
|
||||
// configured remotes.
|
||||
func (f *Flatpak) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns flatpaks that have newer versions available.
|
||||
func (f *Flatpak) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (f *Flatpak) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings using basic semver comparison.
|
||||
// Flatpak has no native version comparison tool.
|
||||
func (f *Flatpak) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@ func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a flatpak app ID.
|
||||
func (f *Flatpak) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a flatpak reference if present.
|
||||
func (f *Flatpak) ParseArch(name string) (string, string) {
|
||||
return parseRef(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Flatpak)(nil)
|
||||
var _ snack.PackageUpgrader = (*Flatpak)(nil)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Flatpak(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.Clean, "flatpak should support Clean")
|
||||
assert.True(t, caps.RepoManagement, "flatpak should support RepoManagement")
|
||||
assert.False(t, caps.VersionQuery)
|
||||
assert.True(t, caps.VersionQuery, "flatpak should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "flatpak should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -220,11 +220,8 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
|
||||
if len(toUpgrade) > 0 {
|
||||
for _, t := range toUpgrade {
|
||||
args := []string{"update", "-y", t.Name}
|
||||
cmd := exec.CommandContext(ctx, "flatpak", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w: %s", t.Name, err, stderr.String())
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("flatpak update %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ func removeRepo(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
@@ -37,6 +37,71 @@ func TestParseListEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEdgeCases(t *testing.T) {
|
||||
t.Run("single entry", func(t *testing.T) {
|
||||
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Firefox" {
|
||||
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two fields only", func(t *testing.T) {
|
||||
pkgs := parseList("Firefox\torg.mozilla.Firefox\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Firefox" {
|
||||
t.Errorf("expected Firefox, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Version != "" {
|
||||
t.Errorf("expected empty version, got %q", pkgs[0].Version)
|
||||
}
|
||||
if pkgs[0].Repository != "" {
|
||||
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field skipped", func(t *testing.T) {
|
||||
pkgs := parseList("Firefox\n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extra fields", func(t *testing.T) {
|
||||
pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\textra\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Repository != "flathub" {
|
||||
t.Errorf("expected flathub, got %q", pkgs[0].Repository)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace only lines", func(t *testing.T) {
|
||||
pkgs := parseList(" \n\t\n \n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("three fields no repo", func(t *testing.T) {
|
||||
pkgs := parseList("GIMP\torg.gimp.GIMP\t2.10.38\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Version != "2.10.38" {
|
||||
t.Errorf("expected version 2.10.38, got %q", pkgs[0].Version)
|
||||
}
|
||||
if pkgs[0].Repository != "" {
|
||||
t.Errorf("expected empty repository, got %q", pkgs[0].Repository)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" +
|
||||
"Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n"
|
||||
@@ -60,6 +125,32 @@ func TestParseSearchNoMatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchEdgeCases(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
pkgs := parseSearch("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field line skipped", func(t *testing.T) {
|
||||
pkgs := parseSearch("JustAName\n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not installed result", func(t *testing.T) {
|
||||
pkgs := parseSearch("VLC\torg.videolan.VLC\t3.0.20\tflathub\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Installed {
|
||||
t.Error("search results should not be marked installed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name: Firefox
|
||||
Description: Fast, private web browser
|
||||
@@ -92,6 +183,46 @@ func TestParseInfoEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEdgeCases(t *testing.T) {
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
pkg := parseInfo("Name: VLC\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Name != "VLC" {
|
||||
t.Errorf("expected VLC, got %q", pkg.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no name returns nil", func(t *testing.T) {
|
||||
pkg := parseInfo("Version: 1.0\nArch: x86_64\n")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Name field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no colon lines ignored", func(t *testing.T) {
|
||||
pkg := parseInfo("Name: Test\nsome random line without colon\nVersion: 2.0\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Version != "2.0" {
|
||||
t.Errorf("expected version 2.0, got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value with colons", func(t *testing.T) {
|
||||
pkg := parseInfo("Name: MyApp\nDescription: A tool: does things: well\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
// parseInfo uses strings.Index for first colon
|
||||
if pkg.Description != "A tool: does things: well" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRemotes(t *testing.T) {
|
||||
input := "flathub\thttps://dl.flathub.org/repo/\t\n" +
|
||||
"gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n"
|
||||
@@ -113,10 +244,165 @@ func TestParseRemotes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemotesEdgeCases(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
repos := parseRemotes("")
|
||||
if len(repos) != 0 {
|
||||
t.Errorf("expected 0 repos, got %d", len(repos))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single enabled remote", func(t *testing.T) {
|
||||
repos := parseRemotes("flathub\thttps://dl.flathub.org/repo/\t\n")
|
||||
if len(repos) != 1 {
|
||||
t.Fatalf("expected 1 repo, got %d", len(repos))
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected enabled")
|
||||
}
|
||||
if repos[0].Name != "flathub" {
|
||||
t.Errorf("expected Name=flathub, got %q", repos[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single disabled remote", func(t *testing.T) {
|
||||
repos := parseRemotes("test-remote\thttps://example.com/\tdisabled\n")
|
||||
if len(repos) != 1 {
|
||||
t.Fatalf("expected 1 repo, got %d", len(repos))
|
||||
}
|
||||
if repos[0].Enabled {
|
||||
t.Error("expected disabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no URL field", func(t *testing.T) {
|
||||
repos := parseRemotes("myremote\n")
|
||||
if len(repos) != 1 {
|
||||
t.Fatalf("expected 1 repo, got %d", len(repos))
|
||||
}
|
||||
if repos[0].ID != "myremote" {
|
||||
t.Errorf("expected myremote, got %q", repos[0].ID)
|
||||
}
|
||||
if repos[0].URL != "" {
|
||||
t.Errorf("expected empty URL, got %q", repos[0].URL)
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected enabled by default")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace lines ignored", func(t *testing.T) {
|
||||
repos := parseRemotes(" \n\n \n")
|
||||
if len(repos) != 0 {
|
||||
t.Errorf("expected 0 repos, got %d", len(repos))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"equal", "1.0.0", "1.0.0", 0},
|
||||
{"less major", "1.0.0", "2.0.0", -1},
|
||||
{"greater major", "2.0.0", "1.0.0", 1},
|
||||
{"less minor", "1.2.3", "1.3.0", -1},
|
||||
{"less patch", "1.2.3", "1.2.4", -1},
|
||||
{"multi-digit", "1.10.0", "1.9.0", 1},
|
||||
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||
{"short vs long less", "1.0", "1.0.1", -1},
|
||||
{"short vs long greater", "1.1", "1.0.9", 1},
|
||||
{"single component", "5", "3", 1},
|
||||
{"single equal", "3", "3", 0},
|
||||
{"empty vs empty", "", "", 0},
|
||||
{"empty vs version", "", "1.0", -1},
|
||||
{"version vs empty", "1.0", "", 1},
|
||||
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
|
||||
{"pre-release stripped", "1.0.0beta2", "1.0.0rc1", 0},
|
||||
{"four components", "1.2.3.4", "1.2.3.5", -1},
|
||||
{"different lengths", "1.0.0.0", "1.0.0", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNonNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"123", "123"},
|
||||
{"123abc", "123"},
|
||||
{"abc", ""},
|
||||
{"0beta", "0"},
|
||||
{"", ""},
|
||||
{"42-rc1", "42"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripNonNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Flatpak)(nil)
|
||||
var _ snack.Cleaner = (*Flatpak)(nil)
|
||||
var _ snack.RepoManager = (*Flatpak)(nil)
|
||||
var _ snack.VersionQuerier = (*Flatpak)(nil)
|
||||
var _ snack.PackageUpgrader = (*Flatpak)(nil)
|
||||
}
|
||||
|
||||
// Compile-time interface checks in test file
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Flatpak)(nil)
|
||||
_ snack.PackageUpgrader = (*Flatpak)(nil)
|
||||
)
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.Clean {
|
||||
t.Error("expected Clean=true")
|
||||
}
|
||||
if !caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=true")
|
||||
}
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=false")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
|
||||
29
flatpak/normalize.go
Normal file
29
flatpak/normalize.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package flatpak
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a flatpak application ID.
|
||||
// Flatpak references can include branch/arch suffixes like:
|
||||
// - org.gnome.Calculator/x86_64/stable
|
||||
// - org.gnome.Calculator//stable (default arch)
|
||||
//
|
||||
// This strips branch and arch to return just the app ID.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseRef(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseRef extracts the architecture from a flatpak reference if present.
|
||||
// Flatpak references can be in the form:
|
||||
// - app-id
|
||||
// - app-id/arch/branch
|
||||
// - app-id//branch (default arch)
|
||||
//
|
||||
// Returns the app-id and architecture (or empty string).
|
||||
func parseRef(name string) (string, string) {
|
||||
parts := strings.SplitN(name, "/", 3)
|
||||
if len(parts) >= 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
39
go.mod
39
go.mod
@@ -1,39 +1,39 @@
|
||||
module github.com/gogrlx/snack
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/go-git/go-git/v5 v5.17.0
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/go-git/go-git/v5 v5.17.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/lipgloss/v2 v2.0.0 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.1 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
@@ -51,12 +51,13 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
@@ -72,15 +73,15 @@ require (
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
@@ -95,10 +96,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
|
||||
82
go.sum
82
go.sum
@@ -1,5 +1,5 @@
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
|
||||
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
@@ -9,28 +9,28 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg6
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
|
||||
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
|
||||
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235 h1:G96IHDV9QdhxyJZN/UBk6RiVsyejQBrKl6XxP5rvydE=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260304213900-0e78e2954235/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4 h1:wQs/I0JSEkcHzobvAgfzeJOKm9A8mkeDOkWQxAo0AZc=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260309091332-e8ca31595cc4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -43,8 +43,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -58,8 +58,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -87,8 +87,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz
|
||||
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
|
||||
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
|
||||
github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk=
|
||||
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -109,10 +109,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -126,8 +128,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
@@ -162,8 +164,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -175,15 +177,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -229,15 +231,15 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -249,11 +251,11 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
|
||||
53
install.sh
Executable file
53
install.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
# Install snack - a unified CLI for system package managers
|
||||
# Usage: curl -sSfL https://raw.githubusercontent.com/gogrlx/snack/main/install.sh | sh
|
||||
set -e
|
||||
|
||||
REPO="gogrlx/snack"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||
|
||||
# Detect OS and arch
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
armv*) ARCH="arm" ;;
|
||||
esac
|
||||
|
||||
# macOS universal binary
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
ARCH="universal"
|
||||
fi
|
||||
|
||||
echo "Detected: ${OS}/${ARCH}"
|
||||
|
||||
# Get latest release tag
|
||||
TAG="$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not determine latest version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing snack ${VERSION}..."
|
||||
|
||||
TARBALL="snack-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||
URL="https://github.com/${REPO}/releases/download/${TAG}/${TARBALL}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
curl -sSfL "$URL" -o "${TMP}/${TARBALL}"
|
||||
tar xzf "${TMP}/${TARBALL}" -C "$TMP"
|
||||
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
mv "${TMP}/snack" "${INSTALL_DIR}/snack"
|
||||
else
|
||||
echo "Installing to ${INSTALL_DIR} (requires sudo)..."
|
||||
sudo mv "${TMP}/snack" "${INSTALL_DIR}/snack"
|
||||
fi
|
||||
|
||||
chmod +x "${INSTALL_DIR}/snack"
|
||||
echo "snack ${VERSION} installed to ${INSTALL_DIR}/snack"
|
||||
65
pacman/buildargs_linux_test.go
Normal file
65
pacman/buildargs_linux_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base []string
|
||||
opts snack.Options
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with sudo",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Sudo: true},
|
||||
wantCmd: "sudo",
|
||||
wantArgs: []string{"pacman", "-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with root and noconfirm",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Root: "/mnt", AssumeYes: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{DryRun: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim", "--print"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, args := buildArgs(tt.base, tt.opts)
|
||||
if cmd != tt.wantCmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
|
||||
}
|
||||
if len(args) != len(tt.wantArgs) {
|
||||
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tt.wantArgs[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var (
|
||||
_ snack.Cleaner = (*Pacman)(nil)
|
||||
_ snack.FileOwner = (*Pacman)(nil)
|
||||
_ snack.Grouper = (*Pacman)(nil)
|
||||
_ snack.NameNormalizer = (*Pacman)(nil)
|
||||
_ snack.DryRunner = (*Pacman)(nil)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux
|
||||
|
||||
package pacman
|
||||
|
||||
import (
|
||||
@@ -79,36 +81,88 @@ func TestBuildArgs_RootBeforeBaseArgs(t *testing.T) {
|
||||
assert.Greater(t, sIdx, rIdx, "root flag should come before base args")
|
||||
}
|
||||
|
||||
func TestParseUpgrades_Empty(t *testing.T) {
|
||||
assert.Empty(t, parseUpgrades(""))
|
||||
}
|
||||
func TestParseUpgrades(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantNames []string
|
||||
wantVers []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \n\n\t\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "standard arrow format",
|
||||
input: "linux 6.7.3.arch1-1 -> 6.7.4.arch1-1\nvim 9.0.2-1 -> 9.1.0-1\n",
|
||||
wantLen: 2,
|
||||
wantNames: []string{"linux", "vim"},
|
||||
wantVers: []string{"6.7.4.arch1-1", "9.1.0-1"},
|
||||
},
|
||||
{
|
||||
name: "single package arrow format",
|
||||
input: "curl 8.6.0-1 -> 8.7.1-1\n",
|
||||
wantLen: 1,
|
||||
wantNames: []string{"curl"},
|
||||
wantVers: []string{"8.7.1-1"},
|
||||
},
|
||||
{
|
||||
name: "fallback two-field format",
|
||||
input: "pkg 2.0\n",
|
||||
wantLen: 1,
|
||||
wantNames: []string{"pkg"},
|
||||
wantVers: []string{"2.0"},
|
||||
},
|
||||
{
|
||||
name: "mixed arrow and fallback",
|
||||
input: "linux 6.7.3 -> 6.7.4\npkg 2.0\n",
|
||||
wantLen: 2,
|
||||
wantNames: []string{"linux", "pkg"},
|
||||
wantVers: []string{"6.7.4", "2.0"},
|
||||
},
|
||||
{
|
||||
name: "whitespace around entries",
|
||||
input: "\n \nlinux 6.7.3 -> 6.7.4\n\n",
|
||||
wantLen: 1,
|
||||
wantNames: []string{"linux"},
|
||||
wantVers: []string{"6.7.4"},
|
||||
},
|
||||
{
|
||||
name: "single field line skipped",
|
||||
input: "orphan\nvalid 1.0 -> 2.0\n",
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "epoch in version",
|
||||
input: "java-runtime 1:21.0.2-1 -> 1:21.0.3-1\n",
|
||||
wantLen: 1,
|
||||
wantNames: []string{"java-runtime"},
|
||||
wantVers: []string{"1:21.0.3-1"},
|
||||
},
|
||||
}
|
||||
|
||||
func TestParseUpgrades_Standard(t *testing.T) {
|
||||
input := `linux 6.7.3.arch1-1 -> 6.7.4.arch1-1
|
||||
vim 9.0.2-1 -> 9.1.0-1
|
||||
`
|
||||
pkgs := parseUpgrades(input)
|
||||
require.Len(t, pkgs, 2)
|
||||
assert.Equal(t, "linux", pkgs[0].Name)
|
||||
assert.Equal(t, "6.7.4.arch1-1", pkgs[0].Version)
|
||||
assert.True(t, pkgs[0].Installed)
|
||||
assert.Equal(t, "vim", pkgs[1].Name)
|
||||
assert.Equal(t, "9.1.0-1", pkgs[1].Version)
|
||||
}
|
||||
|
||||
func TestParseUpgrades_FallbackFormat(t *testing.T) {
|
||||
// Some versions of pacman might output "pkg newver" without the arrow
|
||||
input := "pkg 2.0\n"
|
||||
pkgs := parseUpgrades(input)
|
||||
require.Len(t, pkgs, 1)
|
||||
assert.Equal(t, "pkg", pkgs[0].Name)
|
||||
assert.Equal(t, "2.0", pkgs[0].Version)
|
||||
}
|
||||
|
||||
func TestParseUpgrades_WhitespaceLines(t *testing.T) {
|
||||
input := "\n \nlinux 6.7.3 -> 6.7.4\n\n"
|
||||
pkgs := parseUpgrades(input)
|
||||
require.Len(t, pkgs, 1)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseUpgrades(tt.input)
|
||||
require.Len(t, pkgs, tt.wantLen)
|
||||
for i, p := range pkgs {
|
||||
assert.True(t, p.Installed, "all upgrade entries should have Installed=true")
|
||||
if i < len(tt.wantNames) {
|
||||
assert.Equal(t, tt.wantNames[i], p.Name)
|
||||
}
|
||||
if i < len(tt.wantVers) {
|
||||
assert.Equal(t, tt.wantVers[i], p.Version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupPkgSet_Empty(t *testing.T) {
|
||||
@@ -149,6 +203,31 @@ group pkg2
|
||||
assert.Len(t, set, 2)
|
||||
}
|
||||
|
||||
func TestParseGroupPkgSet_WhitespaceOnly(t *testing.T) {
|
||||
set := parseGroupPkgSet(" \n \n\t\n")
|
||||
assert.Empty(t, set)
|
||||
}
|
||||
|
||||
func TestParseGroupPkgSet_MultipleGroups(t *testing.T) {
|
||||
// Different group names, same package names — set uses pkg name (second field)
|
||||
input := `base-devel gcc
|
||||
xorg xorg-server
|
||||
base-devel gcc
|
||||
`
|
||||
set := parseGroupPkgSet(input)
|
||||
assert.Len(t, set, 2)
|
||||
assert.Contains(t, set, "gcc")
|
||||
assert.Contains(t, set, "xorg-server")
|
||||
}
|
||||
|
||||
func TestParseGroupPkgSet_ExtraFields(t *testing.T) {
|
||||
// Lines with more than 2 fields — should still use second field
|
||||
input := "group pkg extra stuff\n"
|
||||
set := parseGroupPkgSet(input)
|
||||
assert.Len(t, set, 1)
|
||||
assert.Contains(t, set, "pkg")
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
p := New()
|
||||
assert.NotNil(t, p)
|
||||
|
||||
16
pacman/normalize.go
Normal file
16
pacman/normalize.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package pacman
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// Pacman package names do not include architecture suffixes, so this
|
||||
// is essentially a pass-through. The package name is returned as-is.
|
||||
func normalizeName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a package name if present.
|
||||
// Pacman package names do not include architecture suffixes in the name itself
|
||||
// (the arch is separate metadata), so this returns the name unchanged with an
|
||||
// empty architecture string.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
@@ -83,6 +83,16 @@ func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Pacman) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Pacman) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
var _ snack.PackageUpgrader = (*Pacman)(nil)
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestIntegration_Pacman(t *testing.T) {
|
||||
assert.False(t, caps.Hold, "pacman should not support Hold")
|
||||
assert.False(t, caps.RepoManagement, "pacman should not support RepoManagement")
|
||||
assert.False(t, caps.KeyManagement, "pacman should not support KeyManagement")
|
||||
assert.False(t, caps.NameNormalize, "pacman should not support NameNormalize")
|
||||
assert.True(t, caps.NameNormalize, "pacman should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -72,66 +72,63 @@ Architecture : x86_64
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
var _ snack.VersionQuerier = (*Pacman)(nil)
|
||||
var _ snack.Cleaner = (*Pacman)(nil)
|
||||
var _ snack.FileOwner = (*Pacman)(nil)
|
||||
var _ snack.Grouper = (*Pacman)(nil)
|
||||
var _ snack.DryRunner = (*Pacman)(nil)
|
||||
var _ snack.PackageUpgrader = (*Pacman)(nil)
|
||||
}
|
||||
|
||||
func TestInterfaceNonCompliance(t *testing.T) {
|
||||
p := New()
|
||||
var m snack.Manager = p
|
||||
|
||||
if _, ok := m.(snack.Holder); ok {
|
||||
t.Error("Pacman should not implement Holder")
|
||||
}
|
||||
if _, ok := m.(snack.RepoManager); ok {
|
||||
t.Error("Pacman should not implement RepoManager")
|
||||
}
|
||||
if _, ok := m.(snack.KeyManager); ok {
|
||||
t.Error("Pacman should not implement KeyManager")
|
||||
}
|
||||
if _, ok := m.(snack.NameNormalizer); !ok {
|
||||
t.Error("Pacman should implement NameNormalizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
base []string
|
||||
opts snack.Options
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
name string
|
||||
got bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with sudo",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Sudo: true},
|
||||
wantCmd: "sudo",
|
||||
wantArgs: []string{"pacman", "-S", "vim"},
|
||||
},
|
||||
{
|
||||
name: "with root and noconfirm",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{Root: "/mnt", AssumeYes: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-r", "/mnt", "-S", "vim", "--noconfirm"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
base: []string{"-S", "vim"},
|
||||
opts: snack.Options{DryRun: true},
|
||||
wantCmd: "pacman",
|
||||
wantArgs: []string{"-S", "vim", "--print"},
|
||||
},
|
||||
{"VersionQuery", caps.VersionQuery, true},
|
||||
{"Clean", caps.Clean, true},
|
||||
{"FileOwnership", caps.FileOwnership, true},
|
||||
{"Groups", caps.Groups, true},
|
||||
{"DryRun", caps.DryRun, true},
|
||||
{"Hold", caps.Hold, false},
|
||||
{"RepoManagement", caps.RepoManagement, false},
|
||||
{"KeyManagement", caps.KeyManagement, false},
|
||||
{"NameNormalize", caps.NameNormalize, true},
|
||||
{"PackageUpgrade", caps.PackageUpgrade, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, args := buildArgs(tt.base, tt.opts)
|
||||
if cmd != tt.wantCmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.wantCmd)
|
||||
}
|
||||
if len(args) != len(tt.wantArgs) {
|
||||
t.Fatalf("args = %v, want %v", args, tt.wantArgs)
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tt.wantArgs[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i])
|
||||
}
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Pacman)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
p := New()
|
||||
if p.Name() != "pacman" {
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Pkg)(nil)
|
||||
_ snack.Cleaner = (*Pkg)(nil)
|
||||
_ snack.FileOwner = (*Pkg)(nil)
|
||||
_ snack.NameNormalizer = (*Pkg)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version from configured repositories.
|
||||
|
||||
17
pkg/normalize.go
Normal file
17
pkg/normalize.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package pkg
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// FreeBSD pkg package names use "name-version" format. This function
|
||||
// strips the version portion if present, returning just the name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := splitNameVersion(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// FreeBSD pkg package names do not embed architecture in the name itself
|
||||
// (the arch is separate metadata), so this returns the name unchanged with
|
||||
// an empty architecture string.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
10
pkg/pkg.go
10
pkg/pkg.go
@@ -83,6 +83,16 @@ func (p *Pkg) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Pkg) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Pkg) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Pkg)(nil)
|
||||
var _ snack.PackageUpgrader = (*Pkg)(nil)
|
||||
|
||||
530
pkg/pkg_test.go
530
pkg/pkg_test.go
@@ -23,6 +23,94 @@ func TestParseQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueryEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \n\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single entry",
|
||||
input: "vim\t9.0\tVi IMproved\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing description (two fields only)",
|
||||
input: "bash\t5.2\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "bash", Version: "5.2", Description: "", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single field only (no tabs, skipped)",
|
||||
input: "justname\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "description with tabs",
|
||||
input: "pkg\t1.0\tA\ttabbed\tdescription\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "pkg", Version: "1.0", Description: "A\ttabbed\tdescription", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailing and leading whitespace on lines",
|
||||
input: " nginx\t1.24.0\tWeb server \n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "nginx", Version: "1.24.0", Description: "Web server", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple entries with blank lines between",
|
||||
input: "a\t1.0\tAlpha\n\nb\t2.0\tBeta\n",
|
||||
wantLen: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseQuery(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
if got.Description != want.Description {
|
||||
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
|
||||
}
|
||||
if got.Installed != want.Installed {
|
||||
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := `nginx-1.24.0 Robust and small WWW server
|
||||
curl-8.5.0 Command line tool for transferring data
|
||||
@@ -39,6 +127,81 @@ curl-8.5.0 Command line tool for transferring data
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "name with many hyphens",
|
||||
input: "py39-django-rest-framework-3.14.0 RESTful Web APIs for Django\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "py39-django-rest-framework", Version: "3.14.0", Description: "RESTful Web APIs for Django"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no comment (name-version only)",
|
||||
input: "zsh-5.9\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "zsh", Version: "5.9", Description: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "very long output many packages",
|
||||
input: "a-1.0 desc1\nb-2.0 desc2\nc-3.0 desc3\nd-4.0 desc4\ne-5.0 desc5\n",
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "single character name",
|
||||
input: "R-4.3.2 Statistical Computing\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "R", Version: "4.3.2", Description: "Statistical Computing"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version with complex suffix",
|
||||
input: "libressl-3.8.2_1 TLS library\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "libressl", Version: "3.8.2_1", Description: "TLS library"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseSearch(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
if got.Description != want.Description {
|
||||
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name : nginx
|
||||
Version : 1.24.0
|
||||
@@ -63,6 +226,77 @@ Arch : FreeBSD:14:amd64
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNil bool
|
||||
want *snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "no name field returns nil",
|
||||
input: "Version : 1.0\nComment : test\n",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "name only (missing other fields)",
|
||||
input: "Name : bash\n",
|
||||
want: &snack.Package{Name: "bash", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "extra unknown fields are ignored",
|
||||
input: "Name : vim\nVersion : 9.0\nMaintainer : someone@example.com\nWWW : https://vim.org\nComment : Vi IMproved\n",
|
||||
want: &snack.Package{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "colon in value",
|
||||
input: "Name : nginx\nComment : HTTP server: fast and reliable\n",
|
||||
want: &snack.Package{Name: "nginx", Description: "HTTP server: fast and reliable", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "lines without colons are skipped",
|
||||
input: "This is random text\nNo colons here\n",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace around values",
|
||||
input: "Name : curl \nVersion : 8.5.0 \n",
|
||||
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseInfo(tt.input)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if got.Name != tt.want.Name {
|
||||
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
|
||||
}
|
||||
if got.Version != tt.want.Version {
|
||||
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
|
||||
}
|
||||
if got.Description != tt.want.Description {
|
||||
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
|
||||
}
|
||||
if got.Installed != tt.want.Installed {
|
||||
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgrades(t *testing.T) {
|
||||
input := `Updating FreeBSD repository catalogue...
|
||||
The following 2 package(s) will be affected:
|
||||
@@ -84,6 +318,90 @@ Number of packages to be upgraded: 2
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgradesEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "no upgrade lines",
|
||||
input: "Updating FreeBSD repository catalogue...\nAll packages are up to date.\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "mix of Upgrading Installing Reinstalling",
|
||||
input: `Upgrading nginx: 1.24.0 -> 1.26.0
|
||||
Installing newpkg: 0 -> 1.0.0
|
||||
Reinstalling bash: 5.2 -> 5.2
|
||||
`,
|
||||
wantLen: 3,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "nginx", Version: "1.26.0", Installed: true},
|
||||
{Name: "newpkg", Version: "1.0.0", Installed: true},
|
||||
{Name: "bash", Version: "5.2", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line with -> but no recognized prefix is skipped",
|
||||
input: "Something: 1.0 -> 2.0\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "upgrading line without colon is skipped",
|
||||
input: "Upgrading nginx 1.24.0 -> 1.26.0\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "upgrading line with -> but not enough parts after colon",
|
||||
input: "Upgrading nginx: -> \n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "upgrading line with wrong arrow",
|
||||
input: "Upgrading nginx: 1.24.0 => 1.26.0\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single upgrade line",
|
||||
input: "Upgrading zsh: 5.8 -> 5.9\n",
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "zsh", Version: "5.9", Installed: true},
|
||||
},
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseUpgrades(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
if got.Installed != want.Installed {
|
||||
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileList(t *testing.T) {
|
||||
input := `nginx-1.24.0:
|
||||
/usr/local/sbin/nginx
|
||||
@@ -99,6 +417,67 @@ func TestParseFileList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileListEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "header only no files",
|
||||
input: "nginx-1.24.0:\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "paths with spaces",
|
||||
input: "pkg-1.0:\n\t/usr/local/share/my package/file name.txt\n\t/usr/local/share/another dir/test\n",
|
||||
wantLen: 2,
|
||||
want: []string{
|
||||
"/usr/local/share/my package/file name.txt",
|
||||
"/usr/local/share/another dir/test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
input: "bash-5.2:\n\t/usr/local/bin/bash\n",
|
||||
wantLen: 1,
|
||||
want: []string{"/usr/local/bin/bash"},
|
||||
},
|
||||
{
|
||||
name: "no header just file paths",
|
||||
input: "/usr/local/bin/curl\n/usr/local/lib/libcurl.so\n",
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "blank lines between files",
|
||||
input: "pkg-1.0:\n\t/usr/local/bin/a\n\n\t/usr/local/bin/b\n",
|
||||
wantLen: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files := parseFileList(tt.input)
|
||||
if len(files) != tt.wantLen {
|
||||
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
|
||||
}
|
||||
for i, w := range tt.want {
|
||||
if i >= len(files) {
|
||||
break
|
||||
}
|
||||
if files[i] != w {
|
||||
t.Errorf("[%d] got %q, want %q", i, files[i], w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOwner(t *testing.T) {
|
||||
input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n"
|
||||
name := parseOwner(input)
|
||||
@@ -107,6 +486,48 @@ func TestParseOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOwnerEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "standard format",
|
||||
input: "/usr/local/bin/curl was installed by package curl-8.5.0\n",
|
||||
want: "curl",
|
||||
},
|
||||
{
|
||||
name: "package name with hyphens",
|
||||
input: "/usr/local/lib/libpython3.so was installed by package py39-python-3.9.18\n",
|
||||
want: "py39-python",
|
||||
},
|
||||
{
|
||||
name: "no match returns trimmed input",
|
||||
input: "some random output\n",
|
||||
want: "some random output",
|
||||
},
|
||||
{
|
||||
name: "whitespace around",
|
||||
input: " /usr/local/bin/bash was installed by package bash-5.2.21 \n",
|
||||
want: "bash",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseOwner(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseOwner(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitNameVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -126,6 +547,73 @@ func TestSplitNameVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitNameVersionEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantVersion string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantName: "",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "no hyphen",
|
||||
input: "singleword",
|
||||
wantName: "singleword",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "multiple hyphens",
|
||||
input: "py39-django-rest-3.14.0",
|
||||
wantName: "py39-django-rest",
|
||||
wantVersion: "3.14.0",
|
||||
},
|
||||
{
|
||||
name: "leading hyphen",
|
||||
input: "-1.0",
|
||||
wantName: "-1.0",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "trailing hyphen",
|
||||
input: "nginx-",
|
||||
wantName: "nginx",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "only hyphen",
|
||||
input: "-",
|
||||
wantName: "-",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "hyphen at index 1",
|
||||
input: "a-1.0",
|
||||
wantName: "a",
|
||||
wantVersion: "1.0",
|
||||
},
|
||||
{
|
||||
name: "version with underscore suffix",
|
||||
input: "libressl-3.8.2_1",
|
||||
wantName: "libressl",
|
||||
wantVersion: "3.8.2_1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name, ver := splitNameVersion(tt.input)
|
||||
if name != tt.wantName || ver != tt.wantVersion {
|
||||
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, name, ver, tt.wantName, tt.wantVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Pkg)(nil)
|
||||
var _ snack.VersionQuerier = (*Pkg)(nil)
|
||||
@@ -133,9 +621,51 @@ func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.FileOwner = (*Pkg)(nil)
|
||||
}
|
||||
|
||||
func TestPackageUpgraderInterface(t *testing.T) {
|
||||
var _ snack.PackageUpgrader = (*Pkg)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
p := New()
|
||||
if p.Name() != "pkg" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "pkg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
|
||||
// Should be true
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.Clean {
|
||||
t.Error("expected Clean=true")
|
||||
}
|
||||
if !caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=true")
|
||||
}
|
||||
|
||||
// Should be false
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ var (
|
||||
_ snack.VersionQuerier = (*Ports)(nil)
|
||||
_ snack.Cleaner = (*Ports)(nil)
|
||||
_ snack.FileOwner = (*Ports)(nil)
|
||||
_ snack.NameNormalizer = (*Ports)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version from configured repositories.
|
||||
@@ -29,13 +30,11 @@ func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
// OpenBSD has no native version comparison tool, so this uses a simple
|
||||
// lexicographic comparison of the version strings.
|
||||
func (p *Ports) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove removes packages no longer required as dependencies.
|
||||
// Autoremove removes packages that are no longer needed.
|
||||
func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
@@ -78,40 +78,6 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
// OpenBSD does not maintain a package cache like FreeBSD/apt.
|
||||
// Downloaded packages are removed after installation by default.
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("ports fileList: %w", err)
|
||||
}
|
||||
return parseFileListOutput(out), nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("ports owner: %w", err)
|
||||
}
|
||||
return parseOwnerOutput(out), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
|
||||
16
ports/normalize.go
Normal file
16
ports/normalize.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ports
|
||||
|
||||
// normalizeName returns the canonical form of a package name.
|
||||
// OpenBSD packages use "name-version" format. This strips the version
|
||||
// portion if present, returning just the name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := splitNameVersion(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchNormalize extracts the architecture from a package name if present.
|
||||
// OpenBSD package names do not embed architecture in the name itself
|
||||
// (the arch is separate), so this returns the name unchanged.
|
||||
func parseArchNormalize(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
@@ -81,6 +81,16 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (p *Ports) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (p *Ports) ParseArch(name string) (string, string) {
|
||||
return parseArchNormalize(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Ports)(nil)
|
||||
var _ snack.PackageUpgrader = (*Ports)(nil)
|
||||
|
||||
@@ -183,3 +183,55 @@ func version(ctx context.Context, pkg string) (string, error) {
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
// pkg_delete -a removes all packages not required by other packages
|
||||
_, err := runCmd(ctx, "pkg_delete", []string{"-a"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
// OpenBSD doesn't cache packages by default, no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-L", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("ports fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("ports fileList: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Information for") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Files:") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "/") {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
out, err := runCmd(ctx, "pkg_info", []string{"-E", path}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("ports owner: %w", err)
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("ports owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
// Output is the package name
|
||||
return strings.Split(out, "\n")[0], nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,93 @@ python-3.11.7p0 interpreted object-oriented programming language
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \n\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single entry",
|
||||
input: "vim-9.0.2100 Vi IMproved\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "vim", Version: "9.0.2100", Description: "Vi IMproved", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no description",
|
||||
input: "vim-9.0.2100\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "vim", Version: "9.0.2100", Description: "", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty lines between entries",
|
||||
input: "bash-5.2 Shell\n\n\ncurl-8.5.0 Transfer tool\n",
|
||||
wantLen: 2,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "bash", Version: "5.2", Description: "Shell", Installed: true},
|
||||
{Name: "curl", Version: "8.5.0", Description: "Transfer tool", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "package with p-suffix version (OpenBSD style)",
|
||||
input: "python-3.11.7p0 interpreted language\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "python", Version: "3.11.7p0", Description: "interpreted language", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "package name with multiple hyphens",
|
||||
input: "py3-django-rest-3.14.0 REST framework\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "py3-django-rest", Version: "3.14.0", Description: "REST framework", Installed: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseList(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
if got.Description != want.Description {
|
||||
t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description)
|
||||
}
|
||||
if got.Installed != want.Installed {
|
||||
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchResults(t *testing.T) {
|
||||
input := `nginx-1.24.0
|
||||
nginx-1.25.3
|
||||
@@ -42,6 +129,83 @@ nginx-1.25.3
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchResultsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n\n \n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single result",
|
||||
input: "curl-8.5.0\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "curl", Version: "8.5.0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name with many hyphens",
|
||||
input: "py3-django-rest-framework-3.14.0\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "py3-django-rest-framework", Version: "3.14.0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no version (no hyphen)",
|
||||
input: "quirks\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "quirks", Version: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "p-suffix version",
|
||||
input: "python-3.11.7p0\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "python", Version: "3.11.7p0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple results with blank lines",
|
||||
input: "a-1.0\n\nb-2.0\n\nc-3.0\n",
|
||||
wantLen: 3,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseSearchResults(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutput(t *testing.T) {
|
||||
input := `Information for nginx-1.24.0:
|
||||
|
||||
@@ -82,6 +246,131 @@ curl is a tool to transfer data from or to a server.
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutputEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
pkgArg string
|
||||
wantNil bool
|
||||
want *snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input and empty pkg arg",
|
||||
input: "",
|
||||
pkgArg: "",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "empty input with pkg arg fallback",
|
||||
input: "",
|
||||
pkgArg: "curl-8.5.0",
|
||||
want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "no header, falls back to pkg arg",
|
||||
input: "Some random output\nwithout the expected header",
|
||||
pkgArg: "vim-9.0",
|
||||
want: &snack.Package{Name: "vim", Version: "9.0", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "comment on same line as Comment: label",
|
||||
input: `Information for zsh-5.9:
|
||||
|
||||
Comment: Zsh shell
|
||||
`,
|
||||
pkgArg: "zsh-5.9",
|
||||
want: &snack.Package{Name: "zsh", Version: "5.9", Description: "Zsh shell", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "comment on next line after Comment: label (not captured as description)",
|
||||
input: `Information for bash-5.2:
|
||||
|
||||
Comment:
|
||||
GNU Bourne Again Shell
|
||||
`,
|
||||
pkgArg: "bash-5.2",
|
||||
// Comment: with nothing after the colon sets Description="",
|
||||
// and the next line isn't in a Description: block so it's ignored.
|
||||
want: &snack.Package{Name: "bash", Version: "5.2", Description: "", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "description spans multiple lines",
|
||||
input: `Information for git-2.43.0:
|
||||
|
||||
Comment: distributed version control system
|
||||
Description:
|
||||
Git is a fast, scalable, distributed revision control system
|
||||
with an unusually rich command set.
|
||||
`,
|
||||
pkgArg: "git-2.43.0",
|
||||
want: &snack.Package{Name: "git", Version: "2.43.0", Description: "distributed version control system", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "extra fields are ignored",
|
||||
input: `Information for tmux-3.3a:
|
||||
|
||||
Comment: terminal multiplexer
|
||||
Maintainer: someone@openbsd.org
|
||||
WWW: https://tmux.github.io
|
||||
Description:
|
||||
tmux is a terminal multiplexer.
|
||||
`,
|
||||
pkgArg: "tmux-3.3a",
|
||||
want: &snack.Package{Name: "tmux", Version: "3.3a", Description: "terminal multiplexer", Installed: true},
|
||||
},
|
||||
{
|
||||
name: "pkg arg with no version (no hyphen)",
|
||||
input: "",
|
||||
pkgArg: "quirks",
|
||||
want: &snack.Package{Name: "quirks", Version: "", Installed: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseInfoOutput(tt.input, tt.pkgArg)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if got.Name != tt.want.Name {
|
||||
t.Errorf("Name = %q, want %q", got.Name, tt.want.Name)
|
||||
}
|
||||
if got.Version != tt.want.Version {
|
||||
t.Errorf("Version = %q, want %q", got.Version, tt.want.Version)
|
||||
}
|
||||
if tt.want.Description != "" && got.Description != tt.want.Description {
|
||||
t.Errorf("Description = %q, want %q", got.Description, tt.want.Description)
|
||||
}
|
||||
if got.Installed != tt.want.Installed {
|
||||
t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutputEmpty(t *testing.T) {
|
||||
pkg := parseInfoOutput("", "")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input and empty pkg name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutputFallbackToPkgArg(t *testing.T) {
|
||||
input := "Some random output\nwithout the expected header"
|
||||
pkg := parseInfoOutput(input, "curl-8.5.0")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package from fallback")
|
||||
}
|
||||
if pkg.Name != "curl" || pkg.Version != "8.5.0" {
|
||||
t.Errorf("unexpected fallback parse: %+v", pkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitNameVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -101,57 +390,76 @@ func TestSplitNameVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEmpty(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
func TestSplitNameVersionEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantVersion string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantName: "",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "no hyphen",
|
||||
input: "singleword",
|
||||
wantName: "singleword",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "multiple hyphens",
|
||||
input: "py3-django-rest-3.14.0",
|
||||
wantName: "py3-django-rest",
|
||||
wantVersion: "3.14.0",
|
||||
},
|
||||
{
|
||||
name: "leading hyphen (idx=0, returns whole string)",
|
||||
input: "-1.0",
|
||||
wantName: "-1.0",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "trailing hyphen",
|
||||
input: "nginx-",
|
||||
wantName: "nginx",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "only hyphen",
|
||||
input: "-",
|
||||
wantName: "-",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "hyphen at index 1",
|
||||
input: "a-1.0",
|
||||
wantName: "a",
|
||||
wantVersion: "1.0",
|
||||
},
|
||||
{
|
||||
name: "p-suffix version",
|
||||
input: "python-3.11.7p0",
|
||||
wantName: "python",
|
||||
wantVersion: "3.11.7p0",
|
||||
},
|
||||
{
|
||||
name: "version with v prefix",
|
||||
input: "go-v1.21.5",
|
||||
wantName: "go",
|
||||
wantVersion: "v1.21.5",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListWhitespaceOnly(t *testing.T) {
|
||||
pkgs := parseList(" \n \n\n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListNoDescription(t *testing.T) {
|
||||
input := "vim-9.0.2100\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.2100" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkgs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchResultsEmpty(t *testing.T) {
|
||||
pkgs := parseSearchResults("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutputEmpty(t *testing.T) {
|
||||
pkg := parseInfoOutput("", "")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input and empty pkg name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoOutputFallbackToPkgArg(t *testing.T) {
|
||||
// No "Information for" header — should fall back to parsing the pkg argument.
|
||||
input := "Some random output\nwithout the expected header"
|
||||
pkg := parseInfoOutput(input, "curl-8.5.0")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package from fallback")
|
||||
}
|
||||
if pkg.Name != "curl" || pkg.Version != "8.5.0" {
|
||||
t.Errorf("unexpected fallback parse: %+v", pkg)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name, ver := splitNameVersion(tt.input)
|
||||
if name != tt.wantName || ver != tt.wantVersion {
|
||||
t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, name, ver, tt.wantName, tt.wantVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +471,7 @@ func TestSplitNameVersionNoHyphen(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSplitNameVersionLeadingHyphen(t *testing.T) {
|
||||
// A hyphen at position 0 should return the whole string as name.
|
||||
name, ver := splitNameVersion("-1.0")
|
||||
if name != "" || ver != "1.0" {
|
||||
// LastIndex("-1.0", "-") is 0, and idx <= 0 returns (s, "")
|
||||
// Actually idx=0 means the condition idx <= 0 is true
|
||||
}
|
||||
// Re-check: idx=0, condition is idx <= 0, so returns (s, "")
|
||||
name, ver = splitNameVersion("-1.0")
|
||||
if name != "-1.0" || ver != "" {
|
||||
t.Errorf("splitNameVersion(\"-1.0\") = (%q, %q), want (\"-1.0\", \"\")", name, ver)
|
||||
}
|
||||
@@ -196,6 +497,91 @@ python-3.11.7p0 -> python-3.11.8p0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgradeOutputEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantPkgs []snack.Package
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n\n \n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single upgrade",
|
||||
input: "bash-5.2 -> bash-5.3\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "bash", Version: "5.3", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line without -> is skipped",
|
||||
input: "Some info line\nbash-5.2 -> bash-5.3\nAnother line\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "bash", Version: "5.3", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed line (-> but not enough fields)",
|
||||
input: "-> bash-5.3\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "wrong arrow (=> instead of ->)",
|
||||
input: "bash-5.2 => bash-5.3\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "package name with multiple hyphens",
|
||||
input: "py3-django-rest-3.14.0 -> py3-django-rest-3.15.0\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "py3-django-rest", Version: "3.15.0", Installed: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "p-suffix versions",
|
||||
input: "python-3.11.7p0 -> python-3.11.8p1\n",
|
||||
wantLen: 1,
|
||||
wantPkgs: []snack.Package{
|
||||
{Name: "python", Version: "3.11.8p1", Installed: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs := parseUpgradeOutput(tt.input)
|
||||
if len(pkgs) != tt.wantLen {
|
||||
t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs))
|
||||
}
|
||||
for i, want := range tt.wantPkgs {
|
||||
if i >= len(pkgs) {
|
||||
break
|
||||
}
|
||||
got := pkgs[i]
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name)
|
||||
}
|
||||
if got.Version != want.Version {
|
||||
t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version)
|
||||
}
|
||||
if got.Installed != want.Installed {
|
||||
t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpgradeOutputEmpty(t *testing.T) {
|
||||
pkgs := parseUpgradeOutput("")
|
||||
if len(pkgs) != 0 {
|
||||
@@ -221,6 +607,73 @@ Files:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileListOutputEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "header only no files",
|
||||
input: "Information for pkg-1.0:\n\nFiles:\n",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "paths with spaces",
|
||||
input: "Information for pkg-1.0:\n\nFiles:\n/usr/local/share/my dir/file name.txt\n/usr/local/share/another path/test\n",
|
||||
wantLen: 2,
|
||||
want: []string{
|
||||
"/usr/local/share/my dir/file name.txt",
|
||||
"/usr/local/share/another path/test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
input: "Files:\n/usr/local/bin/bash\n",
|
||||
wantLen: 1,
|
||||
want: []string{"/usr/local/bin/bash"},
|
||||
},
|
||||
{
|
||||
name: "no header just paths",
|
||||
input: "/usr/local/bin/a\n/usr/local/bin/b\n",
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "blank lines between files",
|
||||
input: "Files:\n/usr/local/bin/a\n\n/usr/local/bin/b\n",
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "non-path lines are skipped",
|
||||
input: "Information for pkg-1.0:\n\nFiles:\nNot a path\n/usr/local/bin/real\n",
|
||||
wantLen: 1,
|
||||
want: []string{"/usr/local/bin/real"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files := parseFileListOutput(tt.input)
|
||||
if len(files) != tt.wantLen {
|
||||
t.Fatalf("expected %d files, got %d", tt.wantLen, len(files))
|
||||
}
|
||||
for i, w := range tt.want {
|
||||
if i >= len(files) {
|
||||
break
|
||||
}
|
||||
if files[i] != w {
|
||||
t.Errorf("[%d] got %q, want %q", i, files[i], w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileListOutputEmpty(t *testing.T) {
|
||||
files := parseFileListOutput("")
|
||||
if len(files) != 0 {
|
||||
@@ -245,6 +698,58 @@ func TestParseOwnerOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOwnerOutputEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n ",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "name with many hyphens",
|
||||
input: "py3-django-rest-framework-3.14.0",
|
||||
want: "py3-django-rest-framework",
|
||||
},
|
||||
{
|
||||
name: "no version (no hyphen)",
|
||||
input: "quirks",
|
||||
want: "quirks",
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace",
|
||||
input: " curl-8.5.0 ",
|
||||
want: "curl",
|
||||
},
|
||||
{
|
||||
name: "p-suffix version",
|
||||
input: "python-3.11.7p0",
|
||||
want: "python",
|
||||
},
|
||||
{
|
||||
name: "trailing newline",
|
||||
input: "bash-5.2\n",
|
||||
want: "bash",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseOwnerOutput(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Ports)(nil)
|
||||
var _ snack.VersionQuerier = (*Ports)(nil)
|
||||
@@ -253,9 +758,51 @@ func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.PackageUpgrader = (*Ports)(nil)
|
||||
}
|
||||
|
||||
func TestPackageUpgraderInterface(t *testing.T) {
|
||||
var _ snack.PackageUpgrader = (*Ports)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
p := New()
|
||||
if p.Name() != "ports" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
|
||||
// Should be true
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.Clean {
|
||||
t.Error("expected Clean=true")
|
||||
}
|
||||
if !caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=true")
|
||||
}
|
||||
|
||||
// Should be false
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,80 @@ package rpm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"nginx", "nginx"},
|
||||
{"nginx.x86_64", "nginx"},
|
||||
{"curl.noarch", "curl"},
|
||||
{"kernel.aarch64", "kernel"},
|
||||
{"bash.i686", "bash"},
|
||||
{"glibc.i386", "glibc"},
|
||||
{"libfoo.armv7hl", "libfoo"},
|
||||
{"module.ppc64le", "module"},
|
||||
{"app.s390x", "app"},
|
||||
{"source.src", "source"},
|
||||
{"nodot", "nodot"},
|
||||
{"", ""},
|
||||
{"pkg.unknown", "pkg.unknown"},
|
||||
{"multi.dot.x86_64", "multi.dot"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantArch string
|
||||
}{
|
||||
{"x86_64", "nginx.x86_64", "nginx", "x86_64"},
|
||||
{"noarch", "bash.noarch", "bash", "noarch"},
|
||||
{"aarch64", "kernel.aarch64", "kernel", "aarch64"},
|
||||
{"i686", "glibc.i686", "glibc", "i686"},
|
||||
{"i386", "compat.i386", "compat", "i386"},
|
||||
{"armv7hl", "lib.armv7hl", "lib", "armv7hl"},
|
||||
{"ppc64le", "app.ppc64le", "app", "ppc64le"},
|
||||
{"s390x", "z.s390x", "z", "s390x"},
|
||||
{"src", "pkg.src", "pkg", "src"},
|
||||
{"no dot", "curl", "curl", ""},
|
||||
{"unknown arch", "pkg.foobar", "pkg.foobar", ""},
|
||||
{"empty", "", "", ""},
|
||||
{"multiple dots", "a.b.x86_64", "a.b", "x86_64"},
|
||||
{"dot but not arch", "libfoo.so", "libfoo.so", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotArch := parseArchSuffix(tt.input)
|
||||
if gotName != tt.wantName || gotArch != tt.wantArch {
|
||||
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, gotName, gotArch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n"
|
||||
input := "bash\t5.2.15-3.fc38\tThe GNU Bourne Again shell\n" +
|
||||
"curl\t8.0.1-1.fc38\tA utility for getting files from remote servers\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" {
|
||||
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2.15-3.fc38" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
@@ -23,81 +85,151 @@ func TestParseList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace only", func(t *testing.T) {
|
||||
pkgs := parseList(" \n\n \n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single entry no description", func(t *testing.T) {
|
||||
pkgs := parseList("vim\t9.0.1\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.1" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field line skipped", func(t *testing.T) {
|
||||
pkgs := parseList("justname\n")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (need >=2 tab fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("description with tabs", func(t *testing.T) {
|
||||
pkgs := parseList("pkg\t1.0\tA description\twith tabs\n")
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
// SplitN with 3 means the third part includes everything after the second tab
|
||||
if pkgs[0].Description != "A description\twith tabs" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name : bash
|
||||
Version : 5.1.8
|
||||
Release : 6.el9
|
||||
input := `Name : curl
|
||||
Version : 8.0.1
|
||||
Release : 1.fc38
|
||||
Architecture: x86_64
|
||||
Install Date: Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Group : System Environment/Shells
|
||||
Size : 7896043
|
||||
License : GPLv3+
|
||||
Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123
|
||||
Source RPM : bash-5.1.8-6.el9.src.rpm
|
||||
Build Date : Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Build Host : builder.example.com
|
||||
Packager : CentOS Buildsys <bugs@centos.org>
|
||||
Vendor : CentOS
|
||||
URL : https://www.gnu.org/software/bash
|
||||
Summary : The GNU Bourne Again shell
|
||||
Description :
|
||||
The GNU Bourne Again shell (Bash) is a shell or command language
|
||||
interpreter that is compatible with the Bourne shell (sh).
|
||||
Summary : A utility for getting files from remote servers
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p == nil {
|
||||
t.Fatal("expected package, got nil")
|
||||
pkg := parseInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if p.Name != "bash" {
|
||||
t.Errorf("Name = %q, want bash", p.Name)
|
||||
if pkg.Name != "curl" {
|
||||
t.Errorf("expected name 'curl', got %q", pkg.Name)
|
||||
}
|
||||
if p.Version != "5.1.8-6.el9" {
|
||||
t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version)
|
||||
if pkg.Version != "8.0.1-1.fc38" {
|
||||
t.Errorf("expected version '8.0.1-1.fc38', got %q", pkg.Version)
|
||||
}
|
||||
if p.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", p.Arch)
|
||||
if pkg.Arch != "x86_64" {
|
||||
t.Errorf("expected arch 'x86_64', got %q", pkg.Arch)
|
||||
}
|
||||
if p.Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description)
|
||||
if pkg.Description != "A utility for getting files from remote servers" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx"},
|
||||
{"curl.aarch64", "curl"},
|
||||
{"bash.noarch", "bash"},
|
||||
{"python3", "python3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
func TestParseInfoEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkg := parseInfo("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
func TestParseArchSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, wantName, wantArch string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx", "x86_64"},
|
||||
{"bash", "bash", ""},
|
||||
{"glibc.i686", "glibc", "i686"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, arch := parseArchSuffix(tt.input)
|
||||
if name != tt.wantName || arch != tt.wantArch {
|
||||
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : bash\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
if pkg.Name != "bash" {
|
||||
t.Errorf("expected bash, got %q", pkg.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*RPM)(nil)
|
||||
_ snack.FileOwner = (*RPM)(nil)
|
||||
_ snack.NameNormalizer = (*RPM)(nil)
|
||||
)
|
||||
t.Run("no name returns nil", func(t *testing.T) {
|
||||
pkg := parseInfo("Version : 1.0\nArch : x86_64\n")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Name field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version without release", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nVersion : 2.5\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Version != "2.5" {
|
||||
t.Errorf("expected version '2.5', got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("release without version", func(t *testing.T) {
|
||||
// Release only appends if version is non-empty
|
||||
pkg := parseInfo("Name : test\nRelease : 3.el9\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Version != "" {
|
||||
t.Errorf("expected empty version (release alone shouldn't set it), got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("arch key variant", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nArch : aarch64\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Arch != "aarch64" {
|
||||
t.Errorf("expected aarch64, got %q", pkg.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no colon lines ignored", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : test\nrandom line\nSummary : A tool\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Description != "A tool" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value with colons", func(t *testing.T) {
|
||||
pkg := parseInfo("Name : myapp\nSummary : A tool: does things: well\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Description != "A tool: does things: well" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
88
rpm/rpm_test.go
Normal file
88
rpm/rpm_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Snap)(nil)
|
||||
_ snack.Cleaner = (*Snap)(nil)
|
||||
_ snack.NameNormalizer = (*Snap)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest stable version of a snap.
|
||||
@@ -32,13 +33,12 @@ func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Autoremove is a no-op for snap. Snaps are self-contained and do not
|
||||
// have orphan dependencies.
|
||||
func (s *Snap) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
return autoremove(ctx, opts...)
|
||||
// Autoremove is a no-op for snap (snap doesn't have orphan packages).
|
||||
func (s *Snap) Autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean removes old disabled snap revisions to free disk space.
|
||||
// Clean removes old snap revisions to free up space.
|
||||
func (s *Snap) Clean(ctx context.Context) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
15
snap/normalize.go
Normal file
15
snap/normalize.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package snap
|
||||
|
||||
// normalizeName returns the canonical form of a snap name.
|
||||
// Snap package names are simple identifiers without architecture or version
|
||||
// suffixes, so this is essentially a pass-through.
|
||||
func normalizeName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a snap name if present.
|
||||
// Snap package names do not include architecture suffixes,
|
||||
// so this returns the name unchanged with an empty string.
|
||||
func parseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
10
snap/snap.go
10
snap/snap.go
@@ -81,6 +81,16 @@ func (s *Snap) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a snap name.
|
||||
func (s *Snap) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a snap name if present.
|
||||
func (s *Snap) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Snap)(nil)
|
||||
var _ snack.PackageUpgrader = (*Snap)(nil)
|
||||
|
||||
@@ -24,12 +24,12 @@ func TestIntegration_Snap(t *testing.T) {
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "snap should support VersionQuery")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.True(t, caps.Clean, "snap should support Clean")
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.RepoManagement)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.NameNormalize)
|
||||
assert.True(t, caps.NameNormalize, "snap should support NameNormalize")
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
|
||||
@@ -232,43 +232,21 @@ func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
// autoremove is a no-op for snap. Snaps are self-contained and do not
|
||||
// have orphan dependencies.
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clean removes old disabled snap revisions to free disk space.
|
||||
// It runs `snap list --all` to find disabled revisions, then removes
|
||||
// each one with `snap remove --revision=<rev> <name>`.
|
||||
func clean(ctx context.Context) error {
|
||||
// Remove disabled snap revisions to free up space
|
||||
out, err := run(ctx, []string{"list", "--all"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("snap clean: %w", err)
|
||||
return err
|
||||
}
|
||||
// Parse output for disabled revisions
|
||||
// Header: Name Version Rev Tracking Publisher Notes
|
||||
// Disabled snaps have "disabled" in the Notes column
|
||||
lines := strings.Split(out, "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, "disabled") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
name := fields[0]
|
||||
rev := fields[2]
|
||||
if _, err := run(ctx, []string{"remove", "--revision=" + rev, name}); err != nil {
|
||||
return fmt.Errorf("snap clean %s rev %s: %w", name, rev, err)
|
||||
if len(fields) >= 3 {
|
||||
name := fields[0]
|
||||
rev := fields[2]
|
||||
_, _ = run(ctx, []string{"remove", name, "--revision=" + rev})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -293,14 +271,9 @@ func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Opt
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
if len(toUpgrade) > 0 {
|
||||
for _, t := range toUpgrade {
|
||||
cmd := exec.CommandContext(ctx, "snap", "refresh", t.Name)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w: %s", t.Name, err, stderr.String())
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
if _, err := run(ctx, []string{"refresh", t.Name}); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("snap refresh %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
|
||||
@@ -66,10 +66,6 @@ func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
@@ -35,6 +35,56 @@ func TestParseSnapListEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapListEdgeCases(t *testing.T) {
|
||||
t.Run("single entry", func(t *testing.T) {
|
||||
input := `Name Version Rev Tracking Publisher Notes
|
||||
core22 20240111 1122 latest/stable canonical✓ base
|
||||
`
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "core22" {
|
||||
t.Errorf("expected core22, got %q", pkgs[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header only no trailing newline", func(t *testing.T) {
|
||||
input := "Name Version Rev Tracking Publisher Notes"
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field line skipped", func(t *testing.T) {
|
||||
input := "Name Version Rev Tracking Publisher Notes\nsinglefield\n"
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (need >=2 fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extra whitespace lines", func(t *testing.T) {
|
||||
input := "Name Version Rev Tracking Publisher Notes\n \n\n"
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("many columns", func(t *testing.T) {
|
||||
input := "Name Version Rev Tracking Publisher Notes\nsnap1 2.0 100 latest/stable pub note extra more\n"
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "snap1" || pkgs[0].Version != "2.0" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSnapFind(t *testing.T) {
|
||||
input := `Name Version Publisher Notes Summary
|
||||
firefox 131.0 mozilla✓ - Mozilla Firefox web browser
|
||||
@@ -52,6 +102,67 @@ chromium 129.0 nickvdp - Chromium web browser
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapFindEdgeCases(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
input := "Name Version Publisher Notes Summary\nfirefox 131.0 mozilla - Web browser\n"
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "firefox" {
|
||||
t.Errorf("expected firefox, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "Web browser" {
|
||||
t.Errorf("expected 'Web browser', got %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header only", func(t *testing.T) {
|
||||
input := "Name Version Publisher Notes Summary\n"
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too few fields skipped", func(t *testing.T) {
|
||||
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub\n"
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages (need >=4 fields), got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly four fields no summary", func(t *testing.T) {
|
||||
input := "Name Version Publisher Notes Summary\nfoo 1.0 pub note\n"
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Description != "" {
|
||||
t.Errorf("expected empty description, got %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-word summary", func(t *testing.T) {
|
||||
input := "Name Version Publisher Notes Summary\nmysnap 2.0 me - A very long description with many words\n"
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Description != "A very long description with many words" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
pkgs := parseSnapFind("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSnapInfo(t *testing.T) {
|
||||
input := `name: firefox
|
||||
summary: Mozilla Firefox web browser
|
||||
@@ -84,6 +195,57 @@ func TestParseSnapInfoEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfoEdgeCases(t *testing.T) {
|
||||
t.Run("not installed snap", func(t *testing.T) {
|
||||
input := `name: hello-world
|
||||
summary: A simple hello world snap
|
||||
publisher: Canonical✓ (canonical✓)
|
||||
snap-id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ
|
||||
`
|
||||
pkg := parseSnapInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Name != "hello-world" {
|
||||
t.Errorf("expected hello-world, got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Installed {
|
||||
t.Error("expected Installed=false for snap without installed field")
|
||||
}
|
||||
if pkg.Version != "" {
|
||||
t.Errorf("expected empty version, got %q", pkg.Version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
pkg := parseSnapInfo("name: test\n")
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Name != "test" {
|
||||
t.Errorf("expected test, got %q", pkg.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no name returns nil", func(t *testing.T) {
|
||||
pkg := parseSnapInfo("summary: something\ninstalled: 1.0 (1) 10MB\n")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no name field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no colon lines ignored", func(t *testing.T) {
|
||||
input := "name: mysnap\nrandom text without colon\nsummary: A snap\n"
|
||||
pkg := parseSnapInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if pkg.Description != "A snap" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSnapInfoVersion(t *testing.T) {
|
||||
input := `name: firefox
|
||||
channels:
|
||||
@@ -105,6 +267,39 @@ func TestParseSnapInfoVersionMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfoVersionEdgeCases(t *testing.T) {
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
ver := parseSnapInfoVersion("")
|
||||
if ver != "" {
|
||||
t.Errorf("expected empty, got %q", ver)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stable channel with dashes", func(t *testing.T) {
|
||||
input := " latest/stable: 2024.01.15 2024-01-15 (100) 50MB -\n"
|
||||
ver := parseSnapInfoVersion(input)
|
||||
if ver != "2024.01.15" {
|
||||
t.Errorf("expected 2024.01.15, got %q", ver)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("closed channel marked --", func(t *testing.T) {
|
||||
input := " latest/stable: --\n"
|
||||
ver := parseSnapInfoVersion(input)
|
||||
if ver != "" {
|
||||
t.Errorf("expected empty for closed channel, got %q", ver)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("closed channel marked ^", func(t *testing.T) {
|
||||
input := " latest/stable: ^ \n"
|
||||
ver := parseSnapInfoVersion(input)
|
||||
if ver != "" {
|
||||
t.Errorf("expected empty for ^ channel, got %q", ver)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSnapRefreshList(t *testing.T) {
|
||||
input := `Name Version Rev Publisher Notes
|
||||
firefox 132.0 4650 mozilla✓ -
|
||||
@@ -128,30 +323,130 @@ All snaps up to date.
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapRefreshListEdgeCases(t *testing.T) {
|
||||
t.Run("multiple upgrades", func(t *testing.T) {
|
||||
input := "Name Version Rev Publisher Notes\nfoo 2.0 10 pub -\nbar 3.0 20 pub -\n"
|
||||
pkgs := parseSnapRefreshList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "foo" || pkgs[1].Name != "bar" {
|
||||
t.Errorf("unexpected packages: %+v, %+v", pkgs[0], pkgs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header only", func(t *testing.T) {
|
||||
input := "Name Version Rev Publisher Notes\n"
|
||||
pkgs := parseSnapRefreshList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Errorf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"1.0.0", "1.0.0", 0},
|
||||
{"1.0.0", "2.0.0", -1},
|
||||
{"2.0.0", "1.0.0", 1},
|
||||
{"1.2.3", "1.2.4", -1},
|
||||
{"1.10.0", "1.9.0", 1},
|
||||
{"1.0", "1.0.0", 0},
|
||||
{"131.0", "132.0", -1},
|
||||
{"equal", "1.0.0", "1.0.0", 0},
|
||||
{"less major", "1.0.0", "2.0.0", -1},
|
||||
{"greater major", "2.0.0", "1.0.0", 1},
|
||||
{"less patch", "1.2.3", "1.2.4", -1},
|
||||
{"multi-digit minor", "1.10.0", "1.9.0", 1},
|
||||
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||
{"real versions", "131.0", "132.0", -1},
|
||||
// Edge cases
|
||||
{"single component", "5", "3", 1},
|
||||
{"single equal", "3", "3", 0},
|
||||
{"empty vs empty", "", "", 0},
|
||||
{"empty vs version", "", "1.0", -1},
|
||||
{"version vs empty", "1.0", "", 1},
|
||||
{"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0},
|
||||
{"four components", "1.2.3.4", "1.2.3.5", -1},
|
||||
{"different lengths padded", "1.0.0.0", "1.0.0", 0},
|
||||
{"short less", "1.0", "1.0.1", -1},
|
||||
{"short greater", "1.1", "1.0.9", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNonNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"123", "123"},
|
||||
{"123abc", "123"},
|
||||
{"abc", ""},
|
||||
{"0beta", "0"},
|
||||
{"", ""},
|
||||
{"42-rc1", "42"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripNonNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Snap)(nil)
|
||||
var _ snack.VersionQuerier = (*Snap)(nil)
|
||||
var _ snack.Cleaner = (*Snap)(nil)
|
||||
var _ snack.PackageUpgrader = (*Snap)(nil)
|
||||
}
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ snack.Cleaner = (*Snap)(nil)
|
||||
_ snack.PackageUpgrader = (*Snap)(nil)
|
||||
)
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.Clean {
|
||||
t.Error("expected Clean=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=false")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
|
||||
49
winget/capabilities.go
Normal file
49
winget/capabilities.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.VersionQuerier = (*Winget)(nil)
|
||||
_ snack.RepoManager = (*Winget)(nil)
|
||||
_ snack.NameNormalizer = (*Winget)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version of a package.
|
||||
func (w *Winget) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (w *Winget) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (w *Winget) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (w *Winget) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// ListRepos returns configured winget sources.
|
||||
func (w *Winget) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
return sourceList(ctx)
|
||||
}
|
||||
|
||||
// AddRepo adds a new winget source.
|
||||
func (w *Winget) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||
return sourceAdd(ctx, repo)
|
||||
}
|
||||
|
||||
// RemoveRepo removes a configured winget source.
|
||||
func (w *Winget) RemoveRepo(ctx context.Context, id string) error {
|
||||
return sourceRemove(ctx, id)
|
||||
}
|
||||
19
winget/normalize.go
Normal file
19
winget/normalize.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package winget
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName returns the canonical form of a winget package ID.
|
||||
// Winget IDs use dot-separated Publisher.Package format (e.g.
|
||||
// "Microsoft.VisualStudioCode"). This trims whitespace but otherwise
|
||||
// preserves the ID as-is since winget IDs are case-insensitive but
|
||||
// conventionally PascalCase.
|
||||
func normalizeName(name string) string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a winget package name if present.
|
||||
// Winget IDs do not include architecture suffixes, so this returns the
|
||||
// name unchanged with an empty string.
|
||||
func parseArch(name string) (string, string) {
|
||||
return name, ""
|
||||
}
|
||||
333
winget/parse.go
Normal file
333
winget/parse.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseTable parses winget tabular output (list, search, upgrade).
|
||||
//
|
||||
// Winget uses fixed-width columns whose positions are determined by a
|
||||
// header row with dashes (e.g. "---- ------ -------").
|
||||
// The header names vary by locale, so we detect columns positionally.
|
||||
//
|
||||
// Typical `winget list` output:
|
||||
//
|
||||
// Name Id Version Available Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.8.0 17.9.0 winget
|
||||
//
|
||||
// Typical `winget search` output:
|
||||
//
|
||||
// Name Id Version Match Source
|
||||
// --------------------------------------------------------------
|
||||
// Visual Studio Microsoft.VisualStudio 17.9.0 winget
|
||||
//
|
||||
// When installed is true, we mark all parsed packages as Installed.
|
||||
func parseTable(output string, installed bool) []snack.Package {
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
// Find the separator line (all dashes/spaces) to determine column positions.
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if isSeparatorLine(trimmed) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the header line (just above separator) to determine column starts.
|
||||
header := lines[sepIdx-1]
|
||||
cols := detectColumns(header)
|
||||
if len(cols) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pkgs []snack.Package
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
// Skip footer lines like "X upgrades available."
|
||||
if isFooterLine(line) {
|
||||
continue
|
||||
}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Installed: installed,
|
||||
}
|
||||
// Column order: Name, Id, Version, [Available], [Source]
|
||||
// For search: Name, Id, Version, [Match], [Source]
|
||||
if len(fields) >= 3 {
|
||||
pkg.Version = fields[2]
|
||||
}
|
||||
// Use the ID as the package name for consistency (winget uses
|
||||
// Publisher.Package IDs as the canonical identifier).
|
||||
if len(fields) >= 2 && fields[1] != "" {
|
||||
pkg.Description = fields[0] // keep display name as description
|
||||
pkg.Name = fields[1]
|
||||
}
|
||||
// If there's a Source column (typically the last), use it.
|
||||
if len(fields) >= 5 && fields[len(fields)-1] != "" {
|
||||
pkg.Repository = fields[len(fields)-1]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// isSeparatorLine returns true if the line is a column separator
|
||||
// (composed entirely of dashes and spaces, with at least some dashes).
|
||||
func isSeparatorLine(line string) bool {
|
||||
hasDash := false
|
||||
for _, c := range line {
|
||||
switch c {
|
||||
case '-':
|
||||
hasDash = true
|
||||
case ' ', '\t':
|
||||
// allowed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDash
|
||||
}
|
||||
|
||||
// detectColumns returns the starting index of each column based on
|
||||
// the header line. Columns are separated by 2+ spaces.
|
||||
func detectColumns(header string) []int {
|
||||
var cols []int
|
||||
inWord := false
|
||||
for i, c := range header {
|
||||
if c != ' ' && c != '\t' {
|
||||
if !inWord {
|
||||
cols = append(cols, i)
|
||||
inWord = true
|
||||
}
|
||||
} else {
|
||||
// Need at least 2 spaces to end a column
|
||||
if inWord && i+1 < len(header) && (header[i+1] == ' ' || header[i+1] == '\t') {
|
||||
inWord = false
|
||||
} else if inWord && i+1 >= len(header) {
|
||||
inWord = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// extractFields splits a data line according to detected column positions.
|
||||
func extractFields(line string, cols []int) []string {
|
||||
fields := make([]string, len(cols))
|
||||
for i, start := range cols {
|
||||
if start >= len(line) {
|
||||
break
|
||||
}
|
||||
end := len(line)
|
||||
if i+1 < len(cols) {
|
||||
end = cols[i+1]
|
||||
if end > len(line) {
|
||||
end = len(line)
|
||||
}
|
||||
}
|
||||
fields[i] = strings.TrimSpace(line[start:end])
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// isFooterLine returns true for winget output footer lines.
|
||||
func isFooterLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "upgrades available") ||
|
||||
strings.Contains(lower, "package(s)") ||
|
||||
strings.Contains(lower, "installed package") ||
|
||||
strings.HasPrefix(lower, "the following") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseShow parses `winget show` key-value output into a Package.
|
||||
//
|
||||
// Typical output:
|
||||
//
|
||||
// Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
// Version: 1.85.0
|
||||
// Publisher: Microsoft Corporation
|
||||
// Description: Code editing. Redefined.
|
||||
// ...
|
||||
func parseShow(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Parse "Found <Name> [<Id>]" header line.
|
||||
if strings.HasPrefix(line, "Found ") {
|
||||
if idx := strings.LastIndex(line, "["); idx > 0 {
|
||||
endIdx := strings.LastIndex(line, "]")
|
||||
if endIdx > idx {
|
||||
pkg.Name = strings.TrimSpace(line[idx+1 : endIdx])
|
||||
pkg.Description = strings.TrimSpace(line[6:idx])
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
switch strings.ToLower(key) {
|
||||
case "version":
|
||||
pkg.Version = val
|
||||
case "description":
|
||||
if pkg.Description == "" {
|
||||
pkg.Description = val
|
||||
}
|
||||
case "publisher":
|
||||
// informational only
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseSourceList parses `winget source list` output into Repositories.
|
||||
//
|
||||
// Output format:
|
||||
//
|
||||
// Name Argument
|
||||
// ----------------------------------------------
|
||||
// winget https://cdn.winget.microsoft.com/cache
|
||||
// msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
func parseSourceList(output string) []snack.Repository {
|
||||
lines := strings.Split(output, "\n")
|
||||
sepIdx := -1
|
||||
for i, line := range lines {
|
||||
if isSeparatorLine(strings.TrimSpace(line)) {
|
||||
sepIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sepIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
var repos []snack.Repository
|
||||
for _, line := range lines[sepIdx+1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
repos = append(repos, snack.Repository{
|
||||
ID: fields[0],
|
||||
Name: fields[0],
|
||||
URL: fields[1],
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
return repos
|
||||
}
|
||||
|
||||
// stripVT removes ANSI/VT100 escape sequences from a string.
|
||||
func stripVT(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||
// Skip CSI sequence: ESC [ ... final byte
|
||||
j := i + 2
|
||||
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++ // skip final byte
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isProgressLine returns true for lines that are only progress indicators.
|
||||
func isProgressLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
// Lines containing block characters and/or percentage
|
||||
hasBlock := strings.ContainsAny(trimmed, "█▓░")
|
||||
hasPercent := strings.Contains(trimmed, "%")
|
||||
if hasBlock || (hasPercent && len(trimmed) < 40) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// semverCmp does a basic semver-ish comparison.
|
||||
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
func semverCmp(a, b string) int {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
|
||||
maxLen := len(partsA)
|
||||
if len(partsB) > maxLen {
|
||||
maxLen = len(partsB)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var numA, numB int
|
||||
if i < len(partsA) {
|
||||
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
|
||||
}
|
||||
if i < len(partsB) {
|
||||
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
|
||||
}
|
||||
if numA < numB {
|
||||
return -1
|
||||
}
|
||||
if numA > numB {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stripNonNumeric keeps only leading digits from a string.
|
||||
func stripNonNumeric(s string) string {
|
||||
for i, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
103
winget/winget.go
Normal file
103
winget/winget.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package winget provides Go bindings for the Windows Package Manager (winget).
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Winget wraps the winget CLI.
|
||||
type Winget struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Winget manager.
|
||||
func New() *Winget {
|
||||
return &Winget{}
|
||||
}
|
||||
|
||||
// Name returns "winget".
|
||||
func (w *Winget) Name() string { return "winget" }
|
||||
|
||||
// Available reports whether winget is present on the system.
|
||||
func (w *Winget) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (w *Winget) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (w *Winget) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including configuration data.
|
||||
func (w *Winget) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages.
|
||||
func (w *Winget) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the winget source index.
|
||||
func (w *Winget) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (w *Winget) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the winget repository for packages matching the query.
|
||||
func (w *Winget) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (w *Winget) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (w *Winget) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (w *Winget) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a winget package ID.
|
||||
func (w *Winget) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (w *Winget) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
|
||||
// UpgradePackages upgrades specific installed packages.
|
||||
func (w *Winget) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
return upgradePackages(ctx, pkgs, opts...)
|
||||
}
|
||||
140
winget/winget_integration_test.go
Normal file
140
winget/winget_integration_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build integration
|
||||
|
||||
package winget_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
"github.com/gogrlx/snack/winget"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntegration_Winget(t *testing.T) {
|
||||
var mgr snack.Manager = winget.New()
|
||||
if !mgr.Available() {
|
||||
t.Skip("winget not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(t, "winget", mgr.Name())
|
||||
|
||||
caps := snack.GetCapabilities(mgr)
|
||||
assert.True(t, caps.VersionQuery, "winget should support VersionQuery")
|
||||
assert.True(t, caps.RepoManagement, "winget should support RepoManagement")
|
||||
assert.True(t, caps.NameNormalize, "winget should support NameNormalize")
|
||||
assert.True(t, caps.PackageUpgrade, "winget should support PackageUpgrade")
|
||||
assert.False(t, caps.Hold)
|
||||
assert.False(t, caps.Clean)
|
||||
assert.False(t, caps.FileOwnership)
|
||||
assert.False(t, caps.KeyManagement)
|
||||
assert.False(t, caps.Groups)
|
||||
assert.False(t, caps.DryRun)
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
require.NoError(t, mgr.Update(ctx))
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Search_NoResults", func(t *testing.T) {
|
||||
pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pkgs)
|
||||
})
|
||||
|
||||
t.Run("Info", func(t *testing.T) {
|
||||
pkg, err := mgr.Info(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pkg)
|
||||
assert.Equal(t, "Microsoft.PowerToys", pkg.Name)
|
||||
assert.NotEmpty(t, pkg.Version)
|
||||
})
|
||||
|
||||
t.Run("Info_NotFound", func(t *testing.T) {
|
||||
_, err := mgr.Info(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
pkgs, err := mgr.List(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("installed packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
// --- VersionQuerier ---
|
||||
t.Run("VersionQuerier", func(t *testing.T) {
|
||||
vq, ok := mgr.(snack.VersionQuerier)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("LatestVersion", func(t *testing.T) {
|
||||
ver, err := vq.LatestVersion(ctx, "Microsoft.PowerToys")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ver)
|
||||
t.Logf("PowerToys latest: %s", ver)
|
||||
})
|
||||
|
||||
t.Run("LatestVersion_NotFound", func(t *testing.T) {
|
||||
_, err := vq.LatestVersion(ctx, "xyznonexistentpackage999.notreal")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("ListUpgrades", func(t *testing.T) {
|
||||
pkgs, err := vq.ListUpgrades(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("upgradable packages: %d", len(pkgs))
|
||||
})
|
||||
|
||||
t.Run("VersionCmp", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
v1, v2 string
|
||||
want int
|
||||
}{
|
||||
{"1.0", "2.0", -1},
|
||||
{"2.0", "1.0", 1},
|
||||
{"1.0", "1.0", 0},
|
||||
{"1.0.1", "1.0.0", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2)
|
||||
require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- RepoManager ---
|
||||
t.Run("RepoManager", func(t *testing.T) {
|
||||
rm, ok := mgr.(snack.RepoManager)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run("ListRepos", func(t *testing.T) {
|
||||
repos, err := rm.ListRepos(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, repos, "should have at least winget and msstore sources")
|
||||
t.Logf("sources: %d", len(repos))
|
||||
for _, r := range repos {
|
||||
t.Logf(" %s -> %s", r.Name, r.URL)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- NameNormalizer ---
|
||||
t.Run("NameNormalizer", func(t *testing.T) {
|
||||
nn, ok := mgr.(snack.NameNormalizer)
|
||||
require.True(t, ok)
|
||||
|
||||
name := nn.NormalizeName(" Microsoft.VisualStudioCode ")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", name)
|
||||
|
||||
n, arch := nn.ParseArch("Microsoft.VisualStudioCode")
|
||||
assert.Equal(t, "Microsoft.VisualStudioCode", n)
|
||||
assert.Empty(t, arch)
|
||||
})
|
||||
}
|
||||
83
winget/winget_other.go
Normal file
83
winget/winget_other.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build !windows
|
||||
|
||||
package winget
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
|
||||
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceList(_ context.Context) ([]snack.Repository, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceAdd(_ context.Context, _ snack.Repository) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func sourceRemove(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
449
winget/winget_test.go
Normal file
449
winget/winget_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package winget
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseTableList(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Git Git.Git 2.43.0 winget
|
||||
Google Chrome Google.Chrome 120.0.6099.130 winget
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected ID as name, got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "Visual Studio Code" {
|
||||
t.Errorf("expected display name as description, got %q", pkgs[0].Description)
|
||||
}
|
||||
if pkgs[0].Version != "1.85.0" {
|
||||
t.Errorf("expected version '1.85.0', got %q", pkgs[0].Version)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
if pkgs[0].Repository != "winget" {
|
||||
t.Errorf("expected repository 'winget', got %q", pkgs[0].Repository)
|
||||
}
|
||||
if pkgs[1].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableSearch(t *testing.T) {
|
||||
input := `Name Id Version Match Source
|
||||
-------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.86.0 Moniker: vscode winget
|
||||
VSCodium VSCodium.VSCodium 1.85.2 winget
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Installed {
|
||||
t.Error("expected Installed=false for search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableEmpty(t *testing.T) {
|
||||
input := `Name Id Version Source
|
||||
------------------------------
|
||||
`
|
||||
pkgs := parseTable(input, true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableNoSeparator(t *testing.T) {
|
||||
pkgs := parseTable("No installed package found matching input criteria.", true)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableWithFooter(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
--------------------------------------------------------------
|
||||
Git Git.Git 2.43.0 2.44.0 winget
|
||||
3 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Git.Git" {
|
||||
t.Errorf("expected 'Git.Git', got %q", pkgs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableUpgrade(t *testing.T) {
|
||||
input := `Name Id Version Available Source
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||
Node.js OpenJS.NodeJS 20.10.0 21.5.0 winget
|
||||
2 upgrades available.
|
||||
`
|
||||
pkgs := parseTable(input, false)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[1].Name != "OpenJS.NodeJS" {
|
||||
t.Errorf("expected 'OpenJS.NodeJS', got %q", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShow(t *testing.T) {
|
||||
input := `Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||
Version: 1.86.0
|
||||
Publisher: Microsoft Corporation
|
||||
Publisher URL: https://code.visualstudio.com
|
||||
Author: Microsoft Corporation
|
||||
Moniker: vscode
|
||||
Description: Code editing. Redefined.
|
||||
License: MIT
|
||||
Installer Type: inno
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "1.86.0" {
|
||||
t.Errorf("expected version '1.86.0', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Description != "Visual Studio Code" {
|
||||
t.Errorf("expected 'Visual Studio Code', got %q", pkg.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNotFound(t *testing.T) {
|
||||
pkg := parseShow("No package found matching input criteria.")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for not-found output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowEmpty(t *testing.T) {
|
||||
pkg := parseShow("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShowNoID(t *testing.T) {
|
||||
input := `Version: 1.0.0
|
||||
Publisher: Someone
|
||||
`
|
||||
pkg := parseShow(input)
|
||||
if pkg != nil {
|
||||
t.Error("expected nil when no Found header with ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceList(t *testing.T) {
|
||||
input := `Name Argument
|
||||
--------------------------------------------------------------
|
||||
winget https://cdn.winget.microsoft.com/cache
|
||||
msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||
`
|
||||
repos := parseSourceList(input)
|
||||
if len(repos) != 2 {
|
||||
t.Fatalf("expected 2 repos, got %d", len(repos))
|
||||
}
|
||||
if repos[0].Name != "winget" {
|
||||
t.Errorf("expected 'winget', got %q", repos[0].Name)
|
||||
}
|
||||
if repos[0].URL != "https://cdn.winget.microsoft.com/cache" {
|
||||
t.Errorf("unexpected URL: %q", repos[0].URL)
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
if repos[1].Name != "msstore" {
|
||||
t.Errorf("expected 'msstore', got %q", repos[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSourceListEmpty(t *testing.T) {
|
||||
repos := parseSourceList("")
|
||||
if len(repos) != 0 {
|
||||
t.Fatalf("expected 0 repos, got %d", len(repos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSeparatorLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"---", true},
|
||||
{"--------------------------------------------------------------", true},
|
||||
{"--- --- ---", true},
|
||||
{"Name Id Version", false},
|
||||
{"", false},
|
||||
{" ", false},
|
||||
{"--a--", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isSeparatorLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSeparatorLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectColumns(t *testing.T) {
|
||||
header := "Name Id Version Available Source"
|
||||
cols := detectColumns(header)
|
||||
if len(cols) != 5 {
|
||||
t.Fatalf("expected 5 columns, got %d: %v", len(cols), cols)
|
||||
}
|
||||
if cols[0] != 0 {
|
||||
t.Errorf("expected first column at 0, got %d", cols[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFields(t *testing.T) {
|
||||
line := "Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget"
|
||||
cols := []int{0, 34, 67, 82, 93}
|
||||
fields := extractFields(line, cols)
|
||||
if len(fields) != 5 {
|
||||
t.Fatalf("expected 5 fields, got %d", len(fields))
|
||||
}
|
||||
if fields[0] != "Visual Studio Code" {
|
||||
t.Errorf("field[0] = %q, want 'Visual Studio Code'", fields[0])
|
||||
}
|
||||
if fields[1] != "Microsoft.VisualStudioCode" {
|
||||
t.Errorf("field[1] = %q, want 'Microsoft.VisualStudioCode'", fields[1])
|
||||
}
|
||||
if fields[2] != "1.85.0" {
|
||||
t.Errorf("field[2] = %q, want '1.85.0'", fields[2])
|
||||
}
|
||||
if fields[3] != "1.86.0" {
|
||||
t.Errorf("field[3] = %q, want '1.86.0'", fields[3])
|
||||
}
|
||||
if fields[4] != "winget" {
|
||||
t.Errorf("field[4] = %q, want 'winget'", fields[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldsShortLine(t *testing.T) {
|
||||
line := "Git"
|
||||
cols := []int{0, 34, 67}
|
||||
fields := extractFields(line, cols)
|
||||
if fields[0] != "Git" {
|
||||
t.Errorf("field[0] = %q, want 'Git'", fields[0])
|
||||
}
|
||||
if fields[1] != "" {
|
||||
t.Errorf("field[1] = %q, want ''", fields[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFooterLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"3 upgrades available.", true},
|
||||
{"1 package(s) found.", true},
|
||||
{"No installed package found.", true},
|
||||
{"The following packages have upgrades:", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isFooterLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isFooterLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"equal", "1.0.0", "1.0.0", 0},
|
||||
{"less major", "1.0.0", "2.0.0", -1},
|
||||
{"greater major", "2.0.0", "1.0.0", 1},
|
||||
{"less patch", "1.2.3", "1.2.4", -1},
|
||||
{"multi-digit minor", "1.10.0", "1.9.0", 1},
|
||||
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||
{"real versions", "1.85.0", "1.86.0", -1},
|
||||
{"single component", "5", "3", 1},
|
||||
{"empty vs empty", "", "", 0},
|
||||
{"empty vs version", "", "1.0", -1},
|
||||
{"version vs empty", "1.0", "", 1},
|
||||
{"four components", "120.0.6099.130", "120.0.6099.131", -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := semverCmp(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNonNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"123", "123"},
|
||||
{"123abc", "123"},
|
||||
{"abc", ""},
|
||||
{"0beta", "0"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stripNonNumeric(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Winget)(nil)
|
||||
var _ snack.VersionQuerier = (*Winget)(nil)
|
||||
var _ snack.RepoManager = (*Winget)(nil)
|
||||
var _ snack.NameNormalizer = (*Winget)(nil)
|
||||
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
caps := snack.GetCapabilities(New())
|
||||
if !caps.VersionQuery {
|
||||
t.Error("expected VersionQuery=true")
|
||||
}
|
||||
if !caps.RepoManagement {
|
||||
t.Error("expected RepoManagement=true")
|
||||
}
|
||||
if !caps.NameNormalize {
|
||||
t.Error("expected NameNormalize=true")
|
||||
}
|
||||
if !caps.PackageUpgrade {
|
||||
t.Error("expected PackageUpgrade=true")
|
||||
}
|
||||
// Should be false
|
||||
if caps.Clean {
|
||||
t.Error("expected Clean=false")
|
||||
}
|
||||
if caps.FileOwnership {
|
||||
t.Error("expected FileOwnership=false")
|
||||
}
|
||||
if caps.DryRun {
|
||||
t.Error("expected DryRun=false")
|
||||
}
|
||||
if caps.Hold {
|
||||
t.Error("expected Hold=false")
|
||||
}
|
||||
if caps.KeyManagement {
|
||||
t.Error("expected KeyManagement=false")
|
||||
}
|
||||
if caps.Groups {
|
||||
t.Error("expected Groups=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
w := New()
|
||||
if w.Name() != "winget" {
|
||||
t.Errorf("Name() = %q, want %q", w.Name(), "winget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Microsoft.VisualStudioCode", "Microsoft.VisualStudioCode"},
|
||||
{" Git.Git ", "Git.Git"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
name, arch := parseArch("Microsoft.VisualStudioCode")
|
||||
if name != "Microsoft.VisualStudioCode" || arch != "" {
|
||||
t.Errorf("parseArch returned (%q, %q), want (%q, %q)",
|
||||
name, arch, "Microsoft.VisualStudioCode", "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripVT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"no escapes", "hello", "hello"},
|
||||
{"simple CSI", "\x1b[2Khello", "hello"},
|
||||
{"color code", "\x1b[32mgreen\x1b[0m", "green"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripVT(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripVT(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProgressLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"████████ 50%", true},
|
||||
{"100%", true},
|
||||
{"Git Git.Git 2.43.0", false},
|
||||
{"", false},
|
||||
{"Installing...", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isProgressLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isProgressLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
360
winget/winget_windows.go
Normal file
360
winget/winget_windows.go
Normal file
@@ -0,0 +1,360 @@
|
||||
//go:build windows
|
||||
|
||||
package winget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("winget")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// commonArgs returns flags used by all winget commands for non-interactive operation.
|
||||
func commonArgs() []string {
|
||||
return []string{
|
||||
"--accept-source-agreements",
|
||||
"--disable-interactivity",
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "winget", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
out := stdout.String()
|
||||
// winget writes progress and VT sequences to stdout; strip them.
|
||||
out = stripProgress(out)
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "Access is denied") ||
|
||||
strings.Contains(out, "administrator") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
combined := se + out
|
||||
if strings.Contains(combined, "No package found") ||
|
||||
strings.Contains(combined, "No installed package found") {
|
||||
return "", fmt.Errorf("winget: %w", snack.ErrNotFound)
|
||||
}
|
||||
errMsg := strings.TrimSpace(se)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(out)
|
||||
}
|
||||
return "", fmt.Errorf("winget: %s: %w", errMsg, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripProgress removes VT100 escape sequences and progress lines from output.
|
||||
func stripProgress(s string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(s, "\n")
|
||||
for _, line := range lines {
|
||||
clean := stripVT(line)
|
||||
clean = strings.TrimRight(clean, "\r")
|
||||
// Skip pure progress lines (e.g. "██████████████ 50%")
|
||||
if isProgressLine(clean) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(clean)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toInstall []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.Reinstall || t.Version != "" || o.DryRun {
|
||||
toInstall = append(toInstall, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toInstall = append(toInstall, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toInstall {
|
||||
args := []string{"install", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if t.FromRepo != "" {
|
||||
args = append(args, "--source", t.FromRepo)
|
||||
} else if o.FromRepo != "" {
|
||||
args = append(args, "--source", o.FromRepo)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget install %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var installed []snack.Package
|
||||
for _, t := range toInstall {
|
||||
v, _ := version(ctx, t.Name)
|
||||
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toRemove []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toRemove = append(toRemove, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.RemoveResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toRemove = append(toRemove, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toRemove {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.RemoveResult{}, fmt.Errorf("winget uninstall %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var removed []snack.Package
|
||||
for _, t := range toRemove {
|
||||
removed = append(removed, snack.Package{Name: t.Name})
|
||||
}
|
||||
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
for _, t := range pkgs {
|
||||
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent", "--purge"}
|
||||
args = append(args, commonArgs()...)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return fmt.Errorf("winget purge %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
args := []string{"upgrade", "--all", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"source", "update"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget list: %w", err)
|
||||
}
|
||||
return parseTable(out, true), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
args := []string{"search", query}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("winget search: %w", err)
|
||||
}
|
||||
return parseTable(out, false), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("winget info: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
// Check if installed
|
||||
ok, _ := isInstalled(ctx, pkg)
|
||||
p.Installed = ok
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// "No installed package found" is returned as an error by run()
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("winget isInstalled: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
return len(pkgs) > 0, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"list", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("winget version: %w", err)
|
||||
}
|
||||
pkgs := parseTable(out, true)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
args := []string{"show", "--id", pkg, "--exact"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("winget latestVersion: %w", err)
|
||||
}
|
||||
p := parseShow(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
args := []string{"upgrade"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
// No upgrades available may exit non-zero on some versions
|
||||
return nil, nil
|
||||
}
|
||||
return parseTable(out, false), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
upgrades, err := listUpgrades(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range upgrades {
|
||||
if strings.EqualFold(u.Name, pkg) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||
return semverCmp(ver1, ver2), nil
|
||||
}
|
||||
|
||||
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
var toUpgrade []snack.Target
|
||||
var unchanged []string
|
||||
for _, t := range pkgs {
|
||||
if o.DryRun {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
continue
|
||||
}
|
||||
ok, err := isInstalled(ctx, t.Name)
|
||||
if err != nil {
|
||||
return snack.InstallResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
unchanged = append(unchanged, t.Name)
|
||||
} else {
|
||||
toUpgrade = append(toUpgrade, t)
|
||||
}
|
||||
}
|
||||
for _, t := range toUpgrade {
|
||||
args := []string{"upgrade", "--id", t.Name, "--exact", "--silent"}
|
||||
args = append(args, commonArgs()...)
|
||||
args = append(args, "--accept-package-agreements")
|
||||
if t.Version != "" {
|
||||
args = append(args, "--version", t.Version)
|
||||
}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return snack.InstallResult{}, fmt.Errorf("winget upgrade %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
var upgraded []snack.Package
|
||||
for _, t := range toUpgrade {
|
||||
v, _ := version(ctx, t.Name)
|
||||
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||
}
|
||||
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
|
||||
}
|
||||
|
||||
// sourceList returns configured winget sources.
|
||||
func sourceList(ctx context.Context) ([]snack.Repository, error) {
|
||||
args := []string{"source", "list"}
|
||||
args = append(args, commonArgs()...)
|
||||
out, err := run(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winget source list: %w", err)
|
||||
}
|
||||
return parseSourceList(out), nil
|
||||
}
|
||||
|
||||
// sourceAdd adds a new winget source.
|
||||
func sourceAdd(ctx context.Context, repo snack.Repository) error {
|
||||
args := []string{"source", "add", "--name", repo.Name, "--arg", repo.URL}
|
||||
args = append(args, commonArgs()...)
|
||||
if repo.Type != "" {
|
||||
args = append(args, "--type", repo.Type)
|
||||
}
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
// sourceRemove removes a winget source.
|
||||
func sourceRemove(ctx context.Context, name string) error {
|
||||
args := []string{"source", "remove", "--name", name}
|
||||
args = append(args, commonArgs()...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user