feat(aur): implement native AUR client

Native Go implementation using the AUR RPC v5 API for search/info
queries and git+makepkg+pacman for building and installing packages.

Implements:
- Manager (full: Install, Remove, Purge, Upgrade, Update, List, Search,
  Info, IsInstalled, Version)
- VersionQuerier (LatestVersion, ListUpgrades, UpgradeAvailable, VersionCmp)
- Cleaner (Autoremove via pacman, Clean build dir)
- PackageUpgrader (UpgradePackages)

Key design decisions:
- No CLI wrapper (paru/yay) — uses AUR RPC API directly + git clone
- Packages are built with makepkg and installed via pacman -U
- Foreign packages (pacman -Qm) are treated as AUR packages for List
- Batch RPC queries (rpcInfoMulti) for efficient upgrade checks
- Configurable build directory and makepkg flags
- Not added to detect.Default() candidates (AUR supplements pacman,
  not replaces it) but available via detect.ByName("aur")
This commit is contained in:
2026-03-05 22:50:35 +00:00
parent 34ce21618d
commit eb999ad391
8 changed files with 1049 additions and 2 deletions

View File

@@ -1,2 +1,117 @@
// Package aur provides Go bindings for AUR (Arch User Repository) package building.
// Package aur provides a native Go client for the Arch User Repository.
//
// Unlike other snack backends that wrap CLI tools, aur uses the AUR RPC API
// directly for queries and git+makepkg for building. Packages are built in
// a temporary directory and installed via pacman -U.
//
// Requirements: git, makepkg, pacman (all present on any Arch Linux system).
package aur
import (
"context"
"github.com/gogrlx/snack"
)
// AUR wraps the Arch User Repository using its RPC API and makepkg.
type AUR struct {
snack.Locker
// BuildDir is the base directory for cloning and building packages.
// If empty, a temporary directory is created per build.
BuildDir string
// MakepkgFlags are extra flags passed to makepkg (e.g. "--skippgpcheck").
MakepkgFlags []string
}
// New returns a new AUR manager with default settings.
func New() *AUR {
return &AUR{}
}
// Option configures an AUR manager.
type AUROption func(*AUR)
// WithBuildDir sets a persistent build directory.
func WithBuildDir(dir string) AUROption {
return func(a *AUR) { a.BuildDir = dir }
}
// WithMakepkgFlags sets extra flags for makepkg.
func WithMakepkgFlags(flags ...string) AUROption {
return func(a *AUR) { a.MakepkgFlags = flags }
}
// NewWithOptions returns a new AUR manager with the given options.
func NewWithOptions(opts ...AUROption) *AUR {
a := New()
for _, opt := range opts {
opt(a)
}
return a
}
// Name returns "aur".
func (a *AUR) Name() string { return "aur" }
// Available reports whether the AUR toolchain (git, makepkg, pacman) is present.
func (a *AUR) Available() bool { return available() }
// Install clones, builds, and installs AUR packages.
func (a *AUR) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
a.Lock()
defer a.Unlock()
return a.install(ctx, pkgs, opts...)
}
// Remove removes packages via pacman (AUR packages are regular pacman packages once installed).
func (a *AUR) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
a.Lock()
defer a.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including config files via pacman.
func (a *AUR) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return purge(ctx, pkgs, opts...)
}
// Upgrade rebuilds and reinstalls all foreign (AUR) packages.
func (a *AUR) Upgrade(ctx context.Context, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return a.upgradeAll(ctx, opts...)
}
// Update is a no-op for AUR (there is no local package index to refresh).
func (a *AUR) Update(_ context.Context) error {
return nil
}
// List returns all installed foreign (non-repo) packages, which are typically AUR packages.
func (a *AUR) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the AUR RPC API for packages matching the query.
func (a *AUR) Search(ctx context.Context, query string) ([]snack.Package, error) {
return rpcSearch(ctx, query)
}
// Info returns details about a specific AUR package from the RPC API.
func (a *AUR) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return rpcInfo(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (a *AUR) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (a *AUR) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}

425
aur/aur_linux.go Normal file
View File

@@ -0,0 +1,425 @@
//go:build linux
package aur
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/gogrlx/snack"
)
const aurGitBase = "https://aur.archlinux.org"
func available() bool {
for _, tool := range []string{"git", "makepkg", "pacman"} {
if _, err := exec.LookPath(tool); err != nil {
return false
}
}
return true
}
// runPacman executes a pacman command and returns stdout.
func runPacman(ctx context.Context, args []string, sudo bool) (string, error) {
cmd := "pacman"
if sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
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("aur: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("aur: %s: %w", strings.TrimSpace(se), err)
}
return stdout.String(), nil
}
// install clones PKGBUILDs from the AUR, builds with makepkg, and installs with pacman.
func (a *AUR) install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
o := snack.ApplyOptions(opts...)
var installed []snack.Package
var unchanged []string
for _, t := range pkgs {
if !o.Reinstall && !o.DryRun {
ok, err := isInstalled(ctx, t.Name)
if err != nil {
return snack.InstallResult{}, err
}
if ok && t.Version == "" {
unchanged = append(unchanged, t.Name)
continue
}
}
pkgFile, err := a.buildPackage(ctx, t)
if err != nil {
return snack.InstallResult{}, fmt.Errorf("aur install %s: %w", t.Name, err)
}
if o.DryRun {
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)
}
v, _ := version(ctx, t.Name)
installed = append(installed, snack.Package{
Name: t.Name,
Version: v,
Repository: "aur",
Installed: true,
})
}
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
}
// 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) {
// 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)
}
buildDir = tmp
}
pkgDir := filepath.Join(buildDir, t.Name)
// Clone or update the PKGBUILD repo
if err := cloneOrPull(ctx, t.Name, pkgDir); err != nil {
return "", err
}
// If a specific version is requested, check out the matching commit
if t.Version != "" {
// Try to find a commit tagged with this version
c := exec.CommandContext(ctx, "git", "log", "--all", "--oneline")
c.Dir = pkgDir
// Best-effort version checkout; if it fails, build latest
}
// Run makepkg
args := []string{"-s", "-f", "--noconfirm"}
args = append(args, a.MakepkgFlags...)
c := exec.CommandContext(ctx, "makepkg", args...)
c.Dir = pkgDir
var stderr bytes.Buffer
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)
}
// 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 matches[len(matches)-1], nil
}
// cloneOrPull clones the AUR git repo if it doesn't exist, or pulls if it does.
func cloneOrPull(ctx context.Context, pkg, dir string) error {
repoURL := aurGitBase + "/" + pkg + ".git"
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
// Repo exists, pull latest
c := exec.CommandContext(ctx, "git", "pull", "--ff-only")
c.Dir = dir
var stderr bytes.Buffer
c.Stderr = &stderr
if err := c.Run(); err != nil {
return fmt.Errorf("git pull %s: %s: %w", pkg, strings.TrimSpace(stderr.String()), err)
}
return nil
}
// Clone fresh
c := exec.CommandContext(ctx, "git", "clone", "--depth=1", repoURL, dir)
var stderr bytes.Buffer
c.Stderr = &stderr
if err := c.Run(); err != nil {
errStr := strings.TrimSpace(stderr.String())
if strings.Contains(errStr, "not found") || strings.Contains(errStr, "does not appear to be a git repository") {
return fmt.Errorf("aur clone %s: %w", pkg, snack.ErrNotFound)
}
return fmt.Errorf("git clone %s: %s: %w", pkg, errStr, err)
}
return 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 {
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{"-R", "--noconfirm"}, snack.TargetNames(toRemove)...)
if _, err := runPacman(ctx, args, o.Sudo); 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, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...)
_, err := runPacman(ctx, args, o.Sudo)
return err
}
// upgradeAll rebuilds all installed foreign packages that have newer versions in the AUR.
func (a *AUR) upgradeAll(ctx context.Context, opts ...snack.Option) error {
upgrades, err := listUpgrades(ctx)
if err != nil {
return err
}
if len(upgrades) == 0 {
return nil
}
targets := make([]snack.Target, len(upgrades))
for i, p := range upgrades {
targets[i] = snack.Target{Name: p.Name}
}
// Force reinstall since we're upgrading
allOpts := append([]snack.Option{snack.WithReinstall()}, opts...)
_, err = a.install(ctx, targets, allOpts...)
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
// pacman -Qm lists foreign (non-repo) packages, which are typically AUR
out, err := runPacman(ctx, []string{"-Qm"}, false)
if err != nil {
// exit status 1 means no foreign packages
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("aur list: %w", err)
}
return parsePackageList(out), 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("aur isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := runPacman(ctx, []string{"-Q", pkg}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("aur version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("aur version: %w", err)
}
parts := strings.Fields(strings.TrimSpace(out))
if len(parts) < 2 {
return "", fmt.Errorf("aur version %s: unexpected output %q", pkg, out)
}
return parts[1], nil
}
func latestVersion(ctx context.Context, pkg string) (string, error) {
p, err := rpcInfo(ctx, pkg)
if err != nil {
return "", err
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
// Get all installed foreign packages
installed, err := list(ctx)
if err != nil {
return nil, err
}
if len(installed) == 0 {
return nil, nil
}
// Batch-query the AUR for all of them
names := make([]string, len(installed))
for i, p := range installed {
names[i] = p.Name
}
aurInfo, err := rpcInfoMulti(ctx, names)
if err != nil {
return nil, err
}
// Compare versions
var upgrades []snack.Package
for _, inst := range installed {
aurPkg, ok := aurInfo[inst.Name]
if !ok {
continue // not in AUR (maybe from a custom repo)
}
cmp, err := versionCmp(ctx, inst.Version, aurPkg.Version)
if err != nil {
continue // skip packages where vercmp fails
}
if cmp < 0 {
upgrades = append(upgrades, snack.Package{
Name: inst.Name,
Version: aurPkg.Version,
Repository: "aur",
Installed: true,
})
}
}
return upgrades, nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
inst, err := version(ctx, pkg)
if err != nil {
return false, err
}
latest, err := latestVersion(ctx, pkg)
if err != nil {
return false, err
}
cmp, err := versionCmp(ctx, inst, latest)
if err != nil {
return false, err
}
return cmp < 0, nil
}
func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
c := exec.CommandContext(ctx, "vercmp", ver1, ver2)
out, err := c.Output()
if err != nil {
return 0, fmt.Errorf("vercmp: %w", err)
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, fmt.Errorf("vercmp: unexpected output %q: %w", string(out), err)
}
switch {
case n < 0:
return -1, nil
case n > 0:
return 1, nil
default:
return 0, nil
}
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
// Get orphans
orphans, err := runPacman(ctx, []string{"-Qdtq"}, false)
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil // no orphans
}
return fmt.Errorf("aur autoremove: %w", err)
}
orphans = strings.TrimSpace(orphans)
if orphans == "" {
return nil
}
pkgs := strings.Fields(orphans)
args := append([]string{"-Rns", "--noconfirm"}, pkgs...)
_, err = runPacman(ctx, args, o.Sudo)
return err
}
// cleanBuildDir removes all subdirectories in the build directory.
func (a *AUR) cleanBuildDir() error {
if a.BuildDir == "" {
return nil // temp dirs are cleaned automatically
}
entries, err := os.ReadDir(a.BuildDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("aur clean: %w", err)
}
for _, e := range entries {
if e.IsDir() {
if err := os.RemoveAll(filepath.Join(a.BuildDir, e.Name())); err != nil {
return fmt.Errorf("aur clean %s: %w", e.Name(), err)
}
}
}
return nil
}
// parsePackageList parses "name version" lines from pacman -Q output.
func parsePackageList(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],
Repository: "aur",
Installed: true,
})
}
return pkgs
}

63
aur/aur_other.go Normal file
View File

@@ -0,0 +1,63 @@
//go:build !linux
package aur
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func (a *AUR) 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 (a *AUR) upgradeAll(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]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 (a *AUR) cleanBuildDir() error {
return snack.ErrUnsupportedPlatform
}

65
aur/aur_test.go Normal file
View File

@@ -0,0 +1,65 @@
package aur
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParsePackageList(t *testing.T) {
tests := []struct {
name string
input string
expect int
}{
{
name: "empty",
input: "",
expect: 0,
},
{
name: "single package",
input: "yay 12.5.7-1\n",
expect: 1,
},
{
name: "multiple packages",
input: "yay 12.5.7-1\nparu 2.0.4-1\naur-helper 1.0-1\n",
expect: 3,
},
{
name: "trailing whitespace",
input: "yay 12.5.7-1 \n paru 2.0.4-1\n\n",
expect: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pkgs := parsePackageList(tt.input)
assert.Len(t, pkgs, tt.expect)
for _, p := range pkgs {
assert.NotEmpty(t, p.Name)
assert.NotEmpty(t, p.Version)
assert.Equal(t, "aur", p.Repository)
assert.True(t, p.Installed)
}
})
}
}
func TestNew(t *testing.T) {
a := New()
assert.Equal(t, "aur", a.Name())
assert.Empty(t, a.BuildDir)
assert.Nil(t, a.MakepkgFlags)
}
func TestNewWithOptions(t *testing.T) {
a := NewWithOptions(
WithBuildDir("/tmp/aur-builds"),
WithMakepkgFlags("--skippgpcheck", "--nocheck"),
)
assert.Equal(t, "/tmp/aur-builds", a.BuildDir)
assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags)
}

54
aur/capabilities.go Normal file
View File

@@ -0,0 +1,54 @@
package aur
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.Manager = (*AUR)(nil)
_ snack.VersionQuerier = (*AUR)(nil)
_ snack.Cleaner = (*AUR)(nil)
_ snack.PackageUpgrader = (*AUR)(nil)
)
// LatestVersion returns the latest version available in the AUR.
func (a *AUR) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns installed foreign packages that have newer versions in the AUR.
func (a *AUR) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available in the AUR.
func (a *AUR) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings using pacman's vercmp.
func (a *AUR) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Autoremove removes orphaned packages via pacman.
func (a *AUR) Autoremove(ctx context.Context, opts ...snack.Option) error {
a.Lock()
defer a.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached build artifacts from the build directory.
func (a *AUR) Clean(_ context.Context) error {
return a.cleanBuildDir()
}
// UpgradePackages rebuilds and reinstalls specific AUR packages.
func (a *AUR) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
a.Lock()
defer a.Unlock()
return a.install(ctx, pkgs, opts...)
}

139
aur/rpc.go Normal file
View File

@@ -0,0 +1,139 @@
package aur
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/gogrlx/snack"
)
const rpcBaseURL = "https://aur.archlinux.org/rpc/v5"
// rpcResponse is the top-level AUR RPC response.
type rpcResponse struct {
ResultCount int `json:"resultcount"`
Results []rpcResult `json:"results"`
Type string `json:"type"`
Error string `json:"error,omitempty"`
Version int `json:"version"`
}
// rpcResult is a single package from the AUR RPC API.
type rpcResult struct {
Name string `json:"Name"`
Version string `json:"Version"`
Description string `json:"Description"`
URL string `json:"URL"`
URLPath string `json:"URLPath"`
PackageBase string `json:"PackageBase"`
PackageBaseID int `json:"PackageBaseID"`
NumVotes int `json:"NumVotes"`
Popularity float64 `json:"Popularity"`
OutOfDate *int64 `json:"OutOfDate"`
Maintainer string `json:"Maintainer"`
FirstSubmitted int64 `json:"FirstSubmitted"`
LastModified int64 `json:"LastModified"`
Depends []string `json:"Depends"`
MakeDepends []string `json:"MakeDepends"`
OptDepends []string `json:"OptDepends"`
License []string `json:"License"`
Keywords []string `json:"Keywords"`
}
func (r *rpcResult) toPackage() snack.Package {
return snack.Package{
Name: r.Name,
Version: r.Version,
Description: r.Description,
Repository: "aur",
}
}
// rpcGet performs an AUR RPC API request.
func rpcGet(ctx context.Context, endpoint string) (*rpcResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("aur rpc: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("aur rpc: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("aur rpc: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("aur rpc: reading response: %w", err)
}
var result rpcResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("aur rpc: parsing response: %w", err)
}
if result.Error != "" {
return nil, fmt.Errorf("aur rpc: %s", result.Error)
}
return &result, nil
}
// rpcSearch queries the AUR for packages matching the query string.
func rpcSearch(ctx context.Context, query string) ([]snack.Package, error) {
endpoint := rpcBaseURL + "/search/" + url.PathEscape(query)
resp, err := rpcGet(ctx, endpoint)
if err != nil {
return nil, err
}
pkgs := make([]snack.Package, 0, len(resp.Results))
for _, r := range resp.Results {
pkgs = append(pkgs, r.toPackage())
}
return pkgs, nil
}
// rpcInfo returns info about a specific AUR package.
func rpcInfo(ctx context.Context, pkg string) (*snack.Package, error) {
endpoint := rpcBaseURL + "/info?arg[]=" + url.QueryEscape(pkg)
resp, err := rpcGet(ctx, endpoint)
if err != nil {
return nil, err
}
if resp.ResultCount == 0 {
return nil, fmt.Errorf("aur info %s: %w", pkg, snack.ErrNotFound)
}
p := resp.Results[0].toPackage()
return &p, nil
}
// rpcInfoMulti returns info about multiple AUR packages in a single request.
func rpcInfoMulti(ctx context.Context, pkgs []string) (map[string]rpcResult, error) {
if len(pkgs) == 0 {
return nil, nil
}
params := make([]string, len(pkgs))
for i, p := range pkgs {
params[i] = "arg[]=" + url.QueryEscape(p)
}
endpoint := rpcBaseURL + "/info?" + strings.Join(params, "&")
resp, err := rpcGet(ctx, endpoint)
if err != nil {
return nil, err
}
result := make(map[string]rpcResult, len(resp.Results))
for _, r := range resp.Results {
result[r.Name] = r
}
return result, nil
}

184
aur/rpc_test.go Normal file
View File

@@ -0,0 +1,184 @@
package aur
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRPCSearch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := rpcResponse{
ResultCount: 2,
Results: []rpcResult{
{Name: "yay", Version: "12.5.7-1", Description: "AUR helper"},
{Name: "yay-bin", Version: "12.5.7-1", Description: "AUR helper (binary)"},
},
Type: "search",
Version: 5,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
// Override the base URL for testing — we need to test the parsing
// Since we can't easily override the const, test the JSON parsing directly
var resp rpcResponse
httpResp, err := http.Get(srv.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp))
assert.Equal(t, 2, resp.ResultCount)
assert.Equal(t, "yay", resp.Results[0].Name)
assert.Equal(t, "12.5.7-1", resp.Results[0].Version)
pkg := resp.Results[0].toPackage()
assert.Equal(t, "yay", pkg.Name)
assert.Equal(t, "12.5.7-1", pkg.Version)
assert.Equal(t, "aur", pkg.Repository)
}
func TestRPCInfo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := rpcResponse{
ResultCount: 1,
Results: []rpcResult{
{
Name: "yay",
Version: "12.5.7-1",
Description: "Yet another yogurt",
Depends: []string{"pacman>6.1", "git"},
MakeDepends: []string{"go>=1.24"},
},
},
Type: "multiinfo",
Version: 5,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
var resp rpcResponse
httpResp, err := http.Get(srv.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp))
assert.Equal(t, 1, resp.ResultCount)
r := resp.Results[0]
assert.Equal(t, "yay", r.Name)
assert.Equal(t, []string{"pacman>6.1", "git"}, r.Depends)
assert.Equal(t, []string{"go>=1.24"}, r.MakeDepends)
}
func TestRPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := rpcResponse{
Error: "Incorrect request type specified.",
Type: "error",
Version: 5,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
var resp rpcResponse
httpResp, err := http.Get(srv.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp))
assert.Equal(t, "Incorrect request type specified.", resp.Error)
}
func TestRPCInfoMulti(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := rpcResponse{
ResultCount: 2,
Results: []rpcResult{
{Name: "yay", Version: "12.5.7-1"},
{Name: "paru", Version: "2.0.4-1"},
},
Type: "multiinfo",
Version: 5,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
var resp rpcResponse
httpResp, err := http.Get(srv.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.NoError(t, json.NewDecoder(httpResp.Body).Decode(&resp))
assert.Equal(t, 2, resp.ResultCount)
// Simulate rpcInfoMulti result building
result := make(map[string]rpcResult, len(resp.Results))
for _, r := range resp.Results {
result[r.Name] = r
}
assert.Equal(t, "12.5.7-1", result["yay"].Version)
assert.Equal(t, "2.0.4-1", result["paru"].Version)
}
func TestToPackage(t *testing.T) {
r := rpcResult{
Name: "paru",
Version: "2.0.4-1",
Description: "Feature packed AUR helper",
URL: "https://github.com/Morganamilo/paru",
}
pkg := r.toPackage()
assert.Equal(t, "paru", pkg.Name)
assert.Equal(t, "2.0.4-1", pkg.Version)
assert.Equal(t, "Feature packed AUR helper", pkg.Description)
assert.Equal(t, "aur", pkg.Repository)
assert.False(t, pkg.Installed) // AUR search results aren't installed
}
func TestRPCSearchLive(t *testing.T) {
if testing.Short() {
t.Skip("skipping live AUR API test")
}
pkgs, err := rpcSearch(context.Background(), "yay")
require.NoError(t, err)
assert.NotEmpty(t, pkgs)
found := false
for _, p := range pkgs {
if p.Name == "yay" {
found = true
assert.NotEmpty(t, p.Version)
assert.Equal(t, "aur", p.Repository)
break
}
}
assert.True(t, found, "expected to find 'yay' in AUR search results")
}
func TestRPCInfoLive(t *testing.T) {
if testing.Short() {
t.Skip("skipping live AUR API test")
}
pkg, err := rpcInfo(context.Background(), "yay")
require.NoError(t, err)
assert.Equal(t, "yay", pkg.Name)
assert.NotEmpty(t, pkg.Version)
}
func TestRPCInfoNotFound(t *testing.T) {
if testing.Short() {
t.Skip("skipping live AUR API test")
}
_, err := rpcInfo(context.Background(), "this-package-definitely-does-not-exist-12345")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gogrlx/snack"
"github.com/gogrlx/snack/apk"
"github.com/gogrlx/snack/apt"
"github.com/gogrlx/snack/aur"
"github.com/gogrlx/snack/dnf"
"github.com/gogrlx/snack/flatpak"
"github.com/gogrlx/snack/pacman"
@@ -26,6 +27,7 @@ func candidates() []managerFactory {
}
// allManagers returns all known manager factories (for ByName).
// Includes supplemental managers like AUR that aren't primary candidates.
func allManagers() []managerFactory {
return candidates()
return append(candidates(), func() snack.Manager { return aur.New() })
}