diff --git a/pacman/pacman.go b/pacman/pacman.go index 50f2156..cb09899 100644 --- a/pacman/pacman.go +++ b/pacman/pacman.go @@ -1,2 +1,75 @@ // Package pacman provides Go bindings for the pacman package manager (Arch Linux). package pacman + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Pacman wraps the pacman package manager CLI. +type Pacman struct{} + +// New returns a new Pacman manager. +func New() *Pacman { + return &Pacman{} +} + +// Name returns "pacman". +func (p *Pacman) Name() string { return "pacman" } + +// Available reports whether pacman is present on the system. +func (p *Pacman) Available() bool { return available() } + +// Install one or more packages. +func (p *Pacman) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (p *Pacman) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages including configuration files. +func (p *Pacman) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return purge(ctx, pkgs, opts...) +} + +// Upgrade all installed packages to their latest versions. +func (p *Pacman) Upgrade(ctx context.Context, opts ...snack.Option) error { + return upgrade(ctx, opts...) +} + +// Update refreshes the package database. +func (p *Pacman) Update(ctx context.Context) error { + return update(ctx) +} + +// List returns all installed packages. +func (p *Pacman) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries the repositories for packages matching the query. +func (p *Pacman) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (p *Pacman) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (p *Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (p *Pacman) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Verify interface compliance at compile time. +var _ snack.Manager = (*Pacman)(nil) diff --git a/pacman/pacman_linux.go b/pacman/pacman_linux.go new file mode 100644 index 0000000..62a9a02 --- /dev/null +++ b/pacman/pacman_linux.go @@ -0,0 +1,152 @@ +//go:build linux + +package pacman + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("pacman") + return err == nil +} + +// buildArgs constructs the command name and argument list from the base args +// and the provided options. +func buildArgs(baseArgs []string, opts snack.Options) (string, []string) { + cmd := "pacman" + args := make([]string, 0, len(baseArgs)+4) + + if opts.Root != "" { + args = append(args, "-r", opts.Root) + } + args = append(args, baseArgs...) + if opts.AssumeYes { + args = append(args, "--noconfirm") + } + if opts.DryRun { + args = append(args, "--print") + } + + if opts.Sudo { + args = append([]string{cmd}, args...) + cmd = "sudo" + } + return cmd, args +} + +func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, error) { + cmd, args := buildArgs(baseArgs, opts) + c := exec.CommandContext(ctx, cmd, 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("pacman: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("pacman: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + args := append([]string{"-S", "--noconfirm"}, pkgs...) + _, err := run(ctx, args, o) + return err +} + +func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + args := append([]string{"-R", "--noconfirm"}, pkgs...) + _, err := run(ctx, args, o) + return err +} + +func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + args := append([]string{"-Rns", "--noconfirm"}, pkgs...) + _, err := run(ctx, args, o) + return err +} + +func upgrade(ctx context.Context, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + _, err := run(ctx, []string{"-Syu", "--noconfirm"}, o) + return err +} + +func update(ctx context.Context) error { + _, err := run(ctx, []string{"-Sy"}, snack.Options{}) + return err +} + +func list(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"-Q"}, snack.Options{}) + if err != nil { + return nil, fmt.Errorf("pacman list: %w", err) + } + return parseList(out), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + out, err := run(ctx, []string{"-Ss", query}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, nil + } + return nil, fmt.Errorf("pacman search: %w", err) + } + return parseSearch(out), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + out, err := run(ctx, []string{"-Si", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("pacman info %s: %w", pkg, snack.ErrNotFound) + } + return nil, fmt.Errorf("pacman info: %w", err) + } + p := parseInfo(out) + if p == nil { + return nil, fmt.Errorf("pacman info %s: %w", pkg, snack.ErrNotFound) + } + return p, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "pacman", "-Q", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("pacman isInstalled: %w", err) + } + return true, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + out, err := run(ctx, []string{"-Q", pkg}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("pacman version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("pacman version: %w", err) + } + parts := strings.Fields(strings.TrimSpace(out)) + if len(parts) < 2 { + return "", fmt.Errorf("pacman version %s: unexpected output %q", pkg, out) + } + return parts[1], nil +} diff --git a/pacman/pacman_other.go b/pacman/pacman_other.go new file mode 100644 index 0000000..585d2a3 --- /dev/null +++ b/pacman/pacman_other.go @@ -0,0 +1,51 @@ +//go:build !linux + +package pacman + +import ( + "context" + + "github.com/gogrlx/snack" +) + +func available() bool { return false } + +func install(_ context.Context, _ []string, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func remove(_ context.Context, _ []string, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func purge(_ context.Context, _ []string, _ ...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 +} diff --git a/pacman/pacman_test.go b/pacman/pacman_test.go new file mode 100644 index 0000000..86d83a5 --- /dev/null +++ b/pacman/pacman_test.go @@ -0,0 +1,140 @@ +package pacman + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList(t *testing.T) { + input := `linux 6.7.4.arch1-1 +glibc 2.39-1 +bash 5.2.026-2 +` + pkgs := parseList(input) + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "linux" || pkgs[0].Version != "6.7.4.arch1-1" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseSearch(t *testing.T) { + input := `core/linux 6.7.4.arch1-1 [installed] + The Linux kernel and modules +extra/linux-lts 6.6.14-1 + The LTS Linux kernel and modules +` + pkgs := parseSearch(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Repository != "core" || pkgs[0].Name != "linux" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if !pkgs[0].Installed { + t.Error("expected first package to be installed") + } + if pkgs[1].Installed { + t.Error("expected second package to not be installed") + } + if pkgs[1].Description != "The LTS Linux kernel and modules" { + t.Errorf("unexpected description: %q", pkgs[1].Description) + } +} + +func TestParseInfo(t *testing.T) { + input := `Repository : core +Name : linux +Version : 6.7.4.arch1-1 +Description : The Linux kernel and modules +Architecture : x86_64 +` + pkg := parseInfo(input) + if pkg == nil { + t.Fatal("expected non-nil package") + } + if pkg.Name != "linux" { + t.Errorf("expected name 'linux', got %q", pkg.Name) + } + if pkg.Version != "6.7.4.arch1-1" { + t.Errorf("unexpected version: %q", pkg.Version) + } + if pkg.Arch != "x86_64" { + t.Errorf("unexpected arch: %q", pkg.Arch) + } + if pkg.Repository != "core" { + t.Errorf("unexpected repo: %q", pkg.Repository) + } +} + +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]) + } + } + }) + } +} + +func TestInterfaceCompliance(t *testing.T) { + var _ snack.Manager = (*Pacman)(nil) +} + +func TestName(t *testing.T) { + p := New() + if p.Name() != "pacman" { + t.Errorf("Name() = %q, want %q", p.Name(), "pacman") + } +} diff --git a/pacman/parse.go b/pacman/parse.go new file mode 100644 index 0000000..c7c2b62 --- /dev/null +++ b/pacman/parse.go @@ -0,0 +1,105 @@ +package pacman + +import ( + "strings" + + "github.com/gogrlx/snack" +) + +// parseList parses the output of `pacman -Q` into packages. +// Each line is "name version". +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.Fields(line) + if len(parts) < 2 { + continue + } + pkgs = append(pkgs, snack.Package{ + Name: parts[0], + Version: parts[1], + Installed: true, + }) + } + return pkgs +} + +// parseSearch parses the output of `pacman -Ss` into packages. +// Format: +// +// repo/name version [installed] +// Description text +func parseSearch(output string) []snack.Package { + var pkgs []snack.Package + lines := strings.Split(output, "\n") + for i := 0; i < len(lines); i++ { + line := lines[i] + if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + // repo/name version [installed] + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + repoName := strings.SplitN(parts[0], "/", 2) + pkg := snack.Package{ + Version: parts[1], + } + if len(repoName) == 2 { + pkg.Repository = repoName[0] + pkg.Name = repoName[1] + } else { + pkg.Name = repoName[0] + } + for _, p := range parts[2:] { + if strings.Contains(p, "installed") { + pkg.Installed = true + } + } + // Next line is description + if i+1 < len(lines) { + desc := strings.TrimSpace(lines[i+1]) + if desc != "" { + pkg.Description = desc + i++ + } + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseInfo parses the output of `pacman -Si` into a Package. +// Format is "Key : Value" lines. +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 "Architecture": + pkg.Arch = val + case "Repository": + pkg.Repository = val + } + } + if pkg.Name == "" { + return nil + } + return pkg +}