diff --git a/ports/capabilities.go b/ports/capabilities.go new file mode 100644 index 0000000..4f3f628 --- /dev/null +++ b/ports/capabilities.go @@ -0,0 +1,60 @@ +package ports + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.VersionQuerier = (*Ports)(nil) + _ snack.Cleaner = (*Ports)(nil) + _ snack.FileOwner = (*Ports)(nil) +) + +// LatestVersion returns the latest available version from configured repositories. +func (p *Ports) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns packages that have newer versions available. +func (p *Ports) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version is available. +func (p *Ports) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings. +// 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. +func (p *Ports) Autoremove(ctx context.Context, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() + return autoremove(ctx, opts...) +} + +// Clean removes cached package files. +func (p *Ports) Clean(ctx context.Context) error { + p.Lock() + defer p.Unlock() + return clean(ctx) +} + +// FileList returns all files installed by a package. +func (p *Ports) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (p *Ports) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} diff --git a/ports/capabilities_openbsd.go b/ports/capabilities_openbsd.go new file mode 100644 index 0000000..adef83d --- /dev/null +++ b/ports/capabilities_openbsd.go @@ -0,0 +1,146 @@ +//go:build openbsd + +package ports + +import ( + "context" + "fmt" + "strings" + + "github.com/gogrlx/snack" +) + +func latestVersion(ctx context.Context, pkg string) (string, error) { + // pkg_info -Q returns available packages matching the query. + out, err := runCmd(ctx, "pkg_info", []string{"-Q", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return "", fmt.Errorf("ports latestVersion: %w", err) + } + // Find the best match: exact name match with highest version. + var best string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, ver := splitNameVersion(line) + if name == pkg && ver != "" { + if best == "" || ver > best { + best = ver + } + } + } + if best == "" { + return "", fmt.Errorf("ports latestVersion %s: %w", pkg, snack.ErrNotFound) + } + return best, nil +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + // pkg_add -u -n simulates upgrading all packages. + out, err := runCmd(ctx, "pkg_add", []string{"-u", "-n"}, snack.Options{}) + if err != nil { + // Exit status 1 with no output means nothing to upgrade. + if strings.Contains(err.Error(), "exit status 1") { + return nil, nil + } + return nil, fmt.Errorf("ports listUpgrades: %w", err) + } + return parseUpgradeOutput(out), nil +} + +func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { + upgrades, err := listUpgrades(ctx) + if err != nil { + return false, err + } + for _, u := range upgrades { + if u.Name == pkg { + return true, nil + } + } + return false, nil +} + +func versionCmp(_ context.Context, ver1, ver2 string) (int, error) { + // OpenBSD has no native version comparison tool. + // Use simple string comparison. + switch { + case ver1 < ver2: + return -1, nil + case ver1 > ver2: + return 1, nil + default: + return 0, nil + } +} + +func 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 + var unchanged []string + for _, t := range pkgs { + if o.DryRun { + toUpgrade = append(toUpgrade, t) + continue + } + ok, err := isInstalled(ctx, t.Name) + if err != nil { + return snack.InstallResult{}, err + } + if !ok { + unchanged = append(unchanged, t.Name) + } else { + toUpgrade = append(toUpgrade, t) + } + } + if len(toUpgrade) > 0 { + args := append([]string{"-u"}, snack.TargetNames(toUpgrade)...) + if _, err := runCmd(ctx, "pkg_add", args, o); err != nil { + return snack.InstallResult{}, err + } + } + var upgraded []snack.Package + for _, t := range toUpgrade { + v, _ := version(ctx, t.Name) + upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true}) + } + return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil +} diff --git a/ports/capabilities_other.go b/ports/capabilities_other.go new file mode 100644 index 0000000..5441940 --- /dev/null +++ b/ports/capabilities_other.go @@ -0,0 +1,45 @@ +//go:build !openbsd + +package ports + +import ( + "context" + + "github.com/gogrlx/snack" +) + +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 +} diff --git a/ports/parse.go b/ports/parse.go index e568d79..a804eb0 100644 --- a/ports/parse.go +++ b/ports/parse.go @@ -105,6 +105,55 @@ func parseInfoOutput(output string, pkg string) *snack.Package { return p } +// parseUpgradeOutput parses the output of `pkg_add -u -n`. +// Lines like "name-oldver -> name-newver" indicate available upgrades. +func parseUpgradeOutput(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, "->") { + continue + } + parts := strings.Fields(line) + // Expect: "name-oldver -> name-newver" + if len(parts) < 3 || parts[1] != "->" { + continue + } + name, _ := splitNameVersion(parts[0]) + _, newVer := splitNameVersion(parts[2]) + if name != "" { + pkgs = append(pkgs, snack.Package{ + Name: name, + Version: newVer, + Installed: true, + }) + } + } + return pkgs +} + +// parseFileListOutput parses `pkg_info -L ` output. +// Lines starting with "/" after the header are file paths. +func parseFileListOutput(output string) []string { + var files []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "/") { + files = append(files, line) + } + } + return files +} + +// parseOwnerOutput parses `pkg_info -E ` output. +// Returns the package name that owns the file. +func parseOwnerOutput(output string) string { + output = strings.TrimSpace(output) + // pkg_info -E returns the package name-version + name, _ := splitNameVersion(output) + return name +} + // splitNameVersion splits "name-version" at the last hyphen. // OpenBSD packages use the last hyphen before a version number as separator. func splitNameVersion(s string) (string, string) { diff --git a/ports/ports.go b/ports/ports.go index 67446a6..f67a94c 100644 --- a/ports/ports.go +++ b/ports/ports.go @@ -83,3 +83,11 @@ func (p *Ports) Version(ctx context.Context, pkg string) (string, error) { // Verify interface compliance at compile time. var _ snack.Manager = (*Ports)(nil) +var _ snack.PackageUpgrader = (*Ports)(nil) + +// UpgradePackages upgrades specific installed packages. +func (p *Ports) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) { + p.Lock() + defer p.Unlock() + return upgradePackages(ctx, pkgs, opts...) +} diff --git a/ports/ports_test.go b/ports/ports_test.go index 6f2a91e..238c17c 100644 --- a/ports/ports_test.go +++ b/ports/ports_test.go @@ -176,8 +176,81 @@ func TestSplitNameVersionLeadingHyphen(t *testing.T) { } } +func TestParseUpgradeOutput(t *testing.T) { + input := `quirks-7.14 -> quirks-7.18 +curl-8.5.0 -> curl-8.6.0 +python-3.11.7p0 -> python-3.11.8p0 +` + pkgs := parseUpgradeOutput(input) + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "quirks" || pkgs[0].Version != "7.18" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if pkgs[1].Name != "curl" || pkgs[1].Version != "8.6.0" { + t.Errorf("unexpected second package: %+v", pkgs[1]) + } + if pkgs[2].Name != "python" || pkgs[2].Version != "3.11.8p0" { + t.Errorf("unexpected third package: %+v", pkgs[2]) + } +} + +func TestParseUpgradeOutputEmpty(t *testing.T) { + pkgs := parseUpgradeOutput("") + if len(pkgs) != 0 { + t.Fatalf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestParseFileListOutput(t *testing.T) { + input := `Information for curl-8.5.0: + +Files: +/usr/local/bin/curl +/usr/local/include/curl/curl.h +/usr/local/lib/libcurl.so.26.0 +/usr/local/man/man1/curl.1 +` + files := parseFileListOutput(input) + if len(files) != 4 { + t.Fatalf("expected 4 files, got %d", len(files)) + } + if files[0] != "/usr/local/bin/curl" { + t.Errorf("unexpected first file: %q", files[0]) + } +} + +func TestParseFileListOutputEmpty(t *testing.T) { + files := parseFileListOutput("") + if len(files) != 0 { + t.Fatalf("expected 0 files, got %d", len(files)) + } +} + +func TestParseOwnerOutput(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"curl-8.5.0", "curl"}, + {"python-3.11.7p0", "python"}, + {"", ""}, + } + for _, tt := range tests { + got := parseOwnerOutput(tt.input) + if got != tt.want { + t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Ports)(nil) + var _ snack.VersionQuerier = (*Ports)(nil) + var _ snack.Cleaner = (*Ports)(nil) + var _ snack.FileOwner = (*Ports)(nil) + var _ snack.PackageUpgrader = (*Ports)(nil) } func TestName(t *testing.T) {