diff --git a/internal/platform/pacman.go b/internal/platform/pacman.go new file mode 100644 index 0000000..2161d1e --- /dev/null +++ b/internal/platform/pacman.go @@ -0,0 +1,74 @@ +package platform + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// ExecRunner runs commands via os/exec. This is the production Runner. +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() +} + +func (ExecRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, stderr.String()) + } + return out, nil +} + +// Pacman implements PackageManager via `pacman` on Arch. +type Pacman struct { + Runner Runner +} + +func NewPacman(r Runner) *Pacman { + if r == nil { + r = ExecRunner{} + } + return &Pacman{Runner: r} +} + +func (Pacman) Name() string { return "pacman" } + +// IsInstalled returns true when `pacman -Q ` succeeds. +func (p Pacman) IsInstalled(ctx context.Context, pkg string) (bool, error) { + if pkg == "" { + return false, errors.New("empty package name") + } + _, err := p.Runner.Output(ctx, "pacman", "-Q", pkg) + if err == nil { + return true, nil + } + // pacman -Q exits 1 when not installed; we treat any error as "not installed" + // because false-positives on transient errors here are harmless (Install + // is idempotent via --needed). + return false, nil +} + +// Install runs `sudo pacman -S --needed --noconfirm `. +func (p Pacman) Install(ctx context.Context, pkgs []string) error { + if len(pkgs) == 0 { + return nil + } + args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, pkgs...) + return p.Runner.Run(ctx, "sudo", args...) +} + +// Update runs `sudo pacman -Sy`. +func (p Pacman) Update(ctx context.Context) error { + return p.Runner.Run(ctx, "sudo", "pacman", "-Sy") +} diff --git a/internal/platform/pacman_test.go b/internal/platform/pacman_test.go new file mode 100644 index 0000000..a555f51 --- /dev/null +++ b/internal/platform/pacman_test.go @@ -0,0 +1,87 @@ +package platform + +import ( + "context" + "errors" + "reflect" + "testing" +) + +type fakeRunner struct { + runCalls [][]string + outputCalls [][]string + outputErr error + output []byte +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} + +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outputCalls = append(f.outputCalls, append([]string{name}, args...)) + return f.output, f.outputErr +} + +func TestPacmanInstallShellsOutCorrectly(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Install(context.Background(), []string{"tio", "git"}); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 1 { + t.Fatalf("run calls = %d", len(r.runCalls)) + } + want := []string{"sudo", "pacman", "-S", "--needed", "--noconfirm", "tio", "git"} + if !reflect.DeepEqual(r.runCalls[0], want) { + t.Fatalf("args = %v, want %v", r.runCalls[0], want) + } +} + +func TestPacmanInstallEmptyIsNoop(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Install(context.Background(), nil); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 0 { + t.Fatalf("expected no calls, got %v", r.runCalls) + } +} + +func TestPacmanIsInstalledTrue(t *testing.T) { + r := &fakeRunner{output: []byte("tio 3.2-1")} + p := NewPacman(r) + ok, err := p.IsInstalled(context.Background(), "tio") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("want installed=true") + } +} + +func TestPacmanIsInstalledFalseOnError(t *testing.T) { + r := &fakeRunner{outputErr: errors.New("not found")} + p := NewPacman(r) + ok, err := p.IsInstalled(context.Background(), "ghost") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("want installed=false") + } +} + +func TestPacmanUpdate(t *testing.T) { + r := &fakeRunner{} + p := NewPacman(r) + if err := p.Update(context.Background()); err != nil { + t.Fatal(err) + } + want := []string{"sudo", "pacman", "-Sy"} + if !reflect.DeepEqual(r.runCalls[0], want) { + t.Fatalf("args = %v, want %v", r.runCalls[0], want) + } +} diff --git a/internal/platform/pkgmgr.go b/internal/platform/pkgmgr.go new file mode 100644 index 0000000..f7016cd --- /dev/null +++ b/internal/platform/pkgmgr.go @@ -0,0 +1,19 @@ +package platform + +import "context" + +// PackageManager abstracts OS-level package operations so later plans +// can add Debian/Fedora without touching modules. +type PackageManager interface { + Name() string + IsInstalled(ctx context.Context, pkg string) (bool, error) + Install(ctx context.Context, pkgs []string) error + Update(ctx context.Context) error +} + +// Runner executes a command with arguments. Injected into package managers +// so tests can stub shell-outs. +type Runner interface { + Run(ctx context.Context, name string, args ...string) error + Output(ctx context.Context, name string, args ...string) ([]byte, error) +}