mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Merge pull request #8 from gogrlx/cd/flatpak-snap
feat: add flatpak and snap package manager implementations
This commit is contained in:
44
flatpak/capabilities.go
Normal file
44
flatpak/capabilities.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Cleaner = (*Flatpak)(nil)
|
||||
_ snack.RepoManager = (*Flatpak)(nil)
|
||||
)
|
||||
|
||||
// Autoremove removes unused runtimes and extensions.
|
||||
func (f *Flatpak) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return autoremove(ctx, opts...)
|
||||
}
|
||||
|
||||
// Clean is a no-op for flatpak (no direct cache clean equivalent).
|
||||
func (f *Flatpak) Clean(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRepos returns all configured remotes.
|
||||
func (f *Flatpak) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
return listRepos(ctx)
|
||||
}
|
||||
|
||||
// AddRepo adds a new remote.
|
||||
func (f *Flatpak) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return addRepo(ctx, repo)
|
||||
}
|
||||
|
||||
// RemoveRepo removes a configured remote.
|
||||
func (f *Flatpak) RemoveRepo(ctx context.Context, id string) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return removeRepo(ctx, id)
|
||||
}
|
||||
@@ -1,2 +1,85 @@
|
||||
// Package flatpak provides Go bindings for Flatpak (cross-distribution application packaging).
|
||||
// Package flatpak provides Go bindings for the Flatpak package manager.
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Flatpak wraps the flatpak CLI.
|
||||
type Flatpak struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Flatpak manager.
|
||||
func New() *Flatpak {
|
||||
return &Flatpak{}
|
||||
}
|
||||
|
||||
// Name returns "flatpak".
|
||||
func (f *Flatpak) Name() string { return "flatpak" }
|
||||
|
||||
// Available reports whether flatpak is present on the system.
|
||||
func (f *Flatpak) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (f *Flatpak) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (f *Flatpak) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including their data.
|
||||
func (f *Flatpak) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages to their latest versions.
|
||||
func (f *Flatpak) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update is a no-op for flatpak (auto-refreshes).
|
||||
func (f *Flatpak) Update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (f *Flatpak) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries for packages matching the query.
|
||||
func (f *Flatpak) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (f *Flatpak) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (f *Flatpak) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (f *Flatpak) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Flatpak)(nil)
|
||||
|
||||
151
flatpak/flatpak_linux.go
Normal file
151
flatpak/flatpak_linux.go
Normal file
@@ -0,0 +1,151 @@
|
||||
//go:build linux
|
||||
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("flatpak")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "flatpak", 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") || strings.Contains(se, "requires root") {
|
||||
return "", fmt.Errorf("flatpak: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("flatpak: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
_ = snack.ApplyOptions(opts...)
|
||||
for _, t := range pkgs {
|
||||
remote := t.FromRepo
|
||||
if remote == "" {
|
||||
remote = "flathub"
|
||||
}
|
||||
args := []string{"install", "-y", remote, t.Name}
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"uninstall", "-y"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"uninstall", "-y", "--delete-data"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"update", "-y"})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list", "--columns=name,application,version,origin"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flatpak list: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"search", query, "--columns=name,application,version,remotes"})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No matches found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("flatpak search: %w", err)
|
||||
}
|
||||
return parseSearch(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"info", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("flatpak info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("flatpak info: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("flatpak info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
p.Installed = true
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "flatpak", "info", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("flatpak isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"info", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("flatpak version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("flatpak version: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("flatpak version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"uninstall", "--unused", "-y"})
|
||||
return err
|
||||
}
|
||||
|
||||
func listRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
out, err := run(ctx, []string{"remotes", "--columns=name,url,options"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flatpak listRepos: %w", err)
|
||||
}
|
||||
return parseRemotes(out), nil
|
||||
}
|
||||
|
||||
func addRepo(ctx context.Context, repo snack.Repository) error {
|
||||
_, err := run(ctx, []string{"remote-add", "--if-not-exists", repo.Name, repo.URL})
|
||||
return err
|
||||
}
|
||||
|
||||
func removeRepo(ctx context.Context, id string) error {
|
||||
_, err := run(ctx, []string{"remote-delete", id})
|
||||
return err
|
||||
}
|
||||
63
flatpak/flatpak_other.go
Normal file
63
flatpak/flatpak_other.go
Normal file
@@ -0,0 +1,63 @@
|
||||
//go:build !linux
|
||||
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return 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 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 autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listRepos(_ context.Context) ([]snack.Repository, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func addRepo(_ context.Context, _ snack.Repository) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func removeRepo(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
127
flatpak/flatpak_test.go
Normal file
127
flatpak/flatpak_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" +
|
||||
"GIMP\torg.gimp.GIMP\t2.10.38\tflathub\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Firefox" {
|
||||
t.Errorf("expected name 'Firefox', got %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[0].Description != "org.mozilla.Firefox" {
|
||||
t.Errorf("expected description 'org.mozilla.Firefox', got %q", pkgs[0].Description)
|
||||
}
|
||||
if pkgs[0].Version != "131.0" {
|
||||
t.Errorf("expected version '131.0', got %q", pkgs[0].Version)
|
||||
}
|
||||
if pkgs[0].Repository != "flathub" {
|
||||
t.Errorf("expected repository 'flathub', got %q", pkgs[0].Repository)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListEmpty(t *testing.T) {
|
||||
pkgs := parseList("")
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" +
|
||||
"Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n"
|
||||
pkgs := parseSearch(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "Firefox" {
|
||||
t.Errorf("unexpected name: %q", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[1].Version != "128.3" {
|
||||
t.Errorf("unexpected version: %q", pkgs[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchNoMatches(t *testing.T) {
|
||||
input := "No matches found\n"
|
||||
pkgs := parseSearch(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name: Firefox
|
||||
Description: Fast, private web browser
|
||||
Version: 131.0
|
||||
Arch: x86_64
|
||||
Origin: flathub
|
||||
`
|
||||
pkg := parseInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "Firefox" {
|
||||
t.Errorf("expected name 'Firefox', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "131.0" {
|
||||
t.Errorf("expected version '131.0', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Arch != "x86_64" {
|
||||
t.Errorf("expected arch 'x86_64', got %q", pkg.Arch)
|
||||
}
|
||||
if pkg.Repository != "flathub" {
|
||||
t.Errorf("expected repository 'flathub', got %q", pkg.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoEmpty(t *testing.T) {
|
||||
pkg := parseInfo("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemotes(t *testing.T) {
|
||||
input := "flathub\thttps://dl.flathub.org/repo/\t\n" +
|
||||
"gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n"
|
||||
repos := parseRemotes(input)
|
||||
if len(repos) != 2 {
|
||||
t.Fatalf("expected 2 repos, got %d", len(repos))
|
||||
}
|
||||
if repos[0].ID != "flathub" {
|
||||
t.Errorf("expected ID 'flathub', got %q", repos[0].ID)
|
||||
}
|
||||
if repos[0].URL != "https://dl.flathub.org/repo/" {
|
||||
t.Errorf("unexpected URL: %q", repos[0].URL)
|
||||
}
|
||||
if !repos[0].Enabled {
|
||||
t.Error("expected first repo to be enabled")
|
||||
}
|
||||
if repos[1].Enabled {
|
||||
t.Error("expected second repo to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Flatpak)(nil)
|
||||
var _ snack.Cleaner = (*Flatpak)(nil)
|
||||
var _ snack.RepoManager = (*Flatpak)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
f := New()
|
||||
if f.Name() != "flatpak" {
|
||||
t.Errorf("Name() = %q, want %q", f.Name(), "flatpak")
|
||||
}
|
||||
}
|
||||
129
flatpak/parse.go
Normal file
129
flatpak/parse.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package flatpak
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseList parses `flatpak list --columns=name,application,version,origin`.
|
||||
// Format: "Name\tApplication ID\tVersion\tOrigin"
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
Installed: true,
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
pkg.Description = strings.TrimSpace(parts[1]) // application ID
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
pkg.Version = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
pkg.Repository = strings.TrimSpace(parts[3])
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseSearch parses `flatpak search <query> --columns=name,application,version,remotes`.
|
||||
// Format: "Name\tApplication ID\tVersion\tRemotes"
|
||||
func parseSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "No matches found") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
pkg.Description = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
pkg.Version = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
pkg.Repository = strings.TrimSpace(parts[3])
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses `flatpak info <pkg>` output (key: value format).
|
||||
func parseInfo(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
switch key {
|
||||
case "Name":
|
||||
pkg.Name = val
|
||||
case "Version":
|
||||
pkg.Version = val
|
||||
case "Description":
|
||||
pkg.Description = val
|
||||
case "Arch":
|
||||
pkg.Arch = val
|
||||
case "Origin":
|
||||
pkg.Repository = val
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseRemotes parses `flatpak remotes --columns=name,url,options`.
|
||||
// Format: "Name\tURL\tOptions"
|
||||
func parseRemotes(output string) []snack.Repository {
|
||||
var repos []snack.Repository
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
repo := snack.Repository{
|
||||
ID: strings.TrimSpace(parts[0]),
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
Enabled: true,
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
repo.URL = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
opts := strings.TrimSpace(parts[2])
|
||||
if strings.Contains(opts, "disabled") {
|
||||
repo.Enabled = false
|
||||
}
|
||||
}
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
return repos
|
||||
}
|
||||
30
snap/capabilities.go
Normal file
30
snap/capabilities.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package snap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var _ snack.VersionQuerier = (*Snap)(nil)
|
||||
|
||||
// LatestVersion returns the latest stable version of a snap.
|
||||
func (s *Snap) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns snaps that have newer versions available.
|
||||
func (s *Snap) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (s *Snap) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings.
|
||||
func (s *Snap) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
183
snap/parse.go
Normal file
183
snap/parse.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package snap
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// parseSnapList parses `snap list` tabular output.
|
||||
// Header: Name Version Rev Tracking Publisher Notes
|
||||
func parseSnapList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
lines := strings.Split(output, "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Version: fields[1],
|
||||
Installed: true,
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseSnapFind parses `snap find <query>` tabular output.
|
||||
// Header: Name Version Publisher Notes Summary
|
||||
func parseSnapFind(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
lines := strings.Split(output, "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: fields[0],
|
||||
Version: fields[1],
|
||||
}
|
||||
// Summary is everything after the 4th field
|
||||
if len(fields) > 4 {
|
||||
pkg.Description = strings.Join(fields[4:], " ")
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseSnapInfo parses `snap info <pkg>` key:value output.
|
||||
func parseSnapInfo(output string) *snack.Package {
|
||||
pkg := &snack.Package{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
switch key {
|
||||
case "name":
|
||||
pkg.Name = val
|
||||
case "summary":
|
||||
pkg.Description = val
|
||||
case "installed":
|
||||
// "installed: 1.2.3 (rev) 100MB ..."
|
||||
parts := strings.Fields(val)
|
||||
if len(parts) >= 1 {
|
||||
pkg.Version = parts[0]
|
||||
pkg.Installed = true
|
||||
}
|
||||
case "snap-id":
|
||||
// presence indicates it exists
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseSnapInfoVersion extracts the latest/stable version from `snap info` output.
|
||||
func parseSnapInfoVersion(output string) string {
|
||||
// Look for "latest/stable:" line
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "latest/stable:") {
|
||||
val := strings.TrimPrefix(line, "latest/stable:")
|
||||
val = strings.TrimSpace(val)
|
||||
fields := strings.Fields(val)
|
||||
if len(fields) >= 1 && fields[0] != "--" && fields[0] != "^" {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseSnapRefreshList parses `snap refresh --list` tabular output.
|
||||
// Header: Name Version Rev Publisher Notes
|
||||
func parseSnapRefreshList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
lines := strings.Split(output, "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// "All snaps up to date." means no upgrades
|
||||
if strings.Contains(line, "All snaps up to date") {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: fields[0],
|
||||
Version: fields[1],
|
||||
Installed: true,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
85
snap/snap.go
85
snap/snap.go
@@ -1,2 +1,85 @@
|
||||
// Package snap provides Go bindings for snapd (Canonical's cross-distribution package manager).
|
||||
// Package snap provides Go bindings for the snap package manager.
|
||||
package snap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Snap wraps the snap CLI.
|
||||
type Snap struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new Snap manager.
|
||||
func New() *Snap {
|
||||
return &Snap{}
|
||||
}
|
||||
|
||||
// Name returns "snap".
|
||||
func (s *Snap) Name() string { return "snap" }
|
||||
|
||||
// Available reports whether snap is present on the system.
|
||||
func (s *Snap) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (s *Snap) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (s *Snap) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including all data.
|
||||
func (s *Snap) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
return purge(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed snaps.
|
||||
func (s *Snap) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update checks for available updates (snap auto-refreshes).
|
||||
func (s *Snap) Update(ctx context.Context) error {
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed snaps.
|
||||
func (s *Snap) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the snap store.
|
||||
func (s *Snap) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific snap.
|
||||
func (s *Snap) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a snap is currently installed.
|
||||
func (s *Snap) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a snap.
|
||||
func (s *Snap) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*Snap)(nil)
|
||||
|
||||
182
snap/snap_linux.go
Normal file
182
snap/snap_linux.go
Normal file
@@ -0,0 +1,182 @@
|
||||
//go:build linux
|
||||
|
||||
package snap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("snap")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "snap", 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") || strings.Contains(se, "requires root") {
|
||||
return "", fmt.Errorf("snap: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("snap: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
_ = snack.ApplyOptions(opts...)
|
||||
for _, t := range pkgs {
|
||||
args := []string{"install"}
|
||||
// Handle --classic or --channel via FromRepo
|
||||
if t.FromRepo != "" {
|
||||
if t.FromRepo == "classic" {
|
||||
args = append(args, "--classic")
|
||||
} else {
|
||||
args = append(args, "--channel="+t.FromRepo)
|
||||
}
|
||||
}
|
||||
args = append(args, t.Name)
|
||||
if _, err := run(ctx, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"remove"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func purge(ctx context.Context, pkgs []snack.Target, _ ...snack.Option) error {
|
||||
args := append([]string{"remove", "--purge"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||
_, err := run(ctx, []string{"refresh"})
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
// snap auto-refreshes; just check for available updates
|
||||
_, _ = run(ctx, []string{"refresh", "--list"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snap list: %w", err)
|
||||
}
|
||||
return parseSnapList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"find", query})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "No matching snaps") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("snap search: %w", err)
|
||||
}
|
||||
return parseSnapFind(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"info", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("snap info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("snap info: %w", err)
|
||||
}
|
||||
p := parseSnapInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("snap info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "snap", "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("snap isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"list", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("snap version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("snap version: %w", err)
|
||||
}
|
||||
pkgs := parseSnapList(out)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("snap 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", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("snap latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("snap latestVersion: %w", err)
|
||||
}
|
||||
ver := parseSnapInfoVersion(out)
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("snap latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"refresh", "--list"})
|
||||
if err != nil {
|
||||
// "All snaps up to date" exits with code 0 on some versions, 1 on others
|
||||
if strings.Contains(err.Error(), "All snaps up to date") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("snap listUpgrades: %w", err)
|
||||
}
|
||||
return parseSnapRefreshList(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
|
||||
}
|
||||
67
snap/snap_other.go
Normal file
67
snap/snap_other.go
Normal file
@@ -0,0 +1,67 @@
|
||||
//go:build !linux
|
||||
|
||||
package snap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return 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
|
||||
}
|
||||
162
snap/snap_test.go
Normal file
162
snap/snap_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package snap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseSnapList(t *testing.T) {
|
||||
input := `Name Version Rev Tracking Publisher Notes
|
||||
core22 20240111 1122 latest/stable canonical✓ base
|
||||
firefox 131.0 4647 latest/stable mozilla✓ -
|
||||
`
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "core22" || pkgs[0].Version != "20240111" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[1].Name != "firefox" || pkgs[1].Version != "131.0" {
|
||||
t.Errorf("unexpected second package: %+v", pkgs[1])
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapListEmpty(t *testing.T) {
|
||||
input := `Name Version Rev Tracking Publisher Notes
|
||||
`
|
||||
pkgs := parseSnapList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapFind(t *testing.T) {
|
||||
input := `Name Version Publisher Notes Summary
|
||||
firefox 131.0 mozilla✓ - Mozilla Firefox web browser
|
||||
chromium 129.0 nickvdp - Chromium web browser
|
||||
`
|
||||
pkgs := parseSnapFind(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "firefox" || pkgs[0].Version != "131.0" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "Mozilla Firefox web browser" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfo(t *testing.T) {
|
||||
input := `name: firefox
|
||||
summary: Mozilla Firefox web browser
|
||||
publisher: Mozilla✓ (mozilla✓)
|
||||
snap-id: 3wdHCAVyZEmYsCMFDE9qt92UV8rC8Wdk
|
||||
installed: 131.0 (4647) 283MB -
|
||||
`
|
||||
pkg := parseSnapInfo(input)
|
||||
if pkg == nil {
|
||||
t.Fatal("expected non-nil package")
|
||||
}
|
||||
if pkg.Name != "firefox" {
|
||||
t.Errorf("expected name 'firefox', got %q", pkg.Name)
|
||||
}
|
||||
if pkg.Version != "131.0" {
|
||||
t.Errorf("expected version '131.0', got %q", pkg.Version)
|
||||
}
|
||||
if pkg.Description != "Mozilla Firefox web browser" {
|
||||
t.Errorf("unexpected description: %q", pkg.Description)
|
||||
}
|
||||
if !pkg.Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfoEmpty(t *testing.T) {
|
||||
pkg := parseSnapInfo("")
|
||||
if pkg != nil {
|
||||
t.Error("expected nil for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfoVersion(t *testing.T) {
|
||||
input := `name: firefox
|
||||
channels:
|
||||
latest/stable: 131.0 2024-10-01 (4647) 283MB -
|
||||
latest/candidate: 132.0b5 2024-10-05 (4650) 285MB -
|
||||
latest/beta: 132.0b5 2024-10-05 (4650) 285MB -
|
||||
latest/edge: 133.0a1 2024-10-06 (4655) 290MB -
|
||||
`
|
||||
ver := parseSnapInfoVersion(input)
|
||||
if ver != "131.0" {
|
||||
t.Errorf("expected '131.0', got %q", ver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapInfoVersionMissing(t *testing.T) {
|
||||
ver := parseSnapInfoVersion("name: test\n")
|
||||
if ver != "" {
|
||||
t.Errorf("expected empty, got %q", ver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapRefreshList(t *testing.T) {
|
||||
input := `Name Version Rev Publisher Notes
|
||||
firefox 132.0 4650 mozilla✓ -
|
||||
`
|
||||
pkgs := parseSnapRefreshList(input)
|
||||
if len(pkgs) != 1 {
|
||||
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "firefox" || pkgs[0].Version != "132.0" {
|
||||
t.Errorf("unexpected package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSnapRefreshListUpToDate(t *testing.T) {
|
||||
input := `Name Version Rev Publisher Notes
|
||||
All snaps up to date.
|
||||
`
|
||||
pkgs := parseSnapRefreshList(input)
|
||||
if len(pkgs) != 0 {
|
||||
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
tests := []struct {
|
||||
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},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceCompliance(t *testing.T) {
|
||||
var _ snack.Manager = (*Snap)(nil)
|
||||
var _ snack.VersionQuerier = (*Snap)(nil)
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
s := New()
|
||||
if s.Name() != "snap" {
|
||||
t.Errorf("Name() = %q, want %q", s.Name(), "snap")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user