diff --git a/apt/apt.go b/apt/apt.go index 02a6446..7bb6620 100644 --- a/apt/apt.go +++ b/apt/apt.go @@ -1,2 +1,74 @@ // Package apt provides Go bindings for APT (Advanced Packaging Tool) on Debian/Ubuntu. package apt + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Apt implements the snack.Manager interface using apt-get and apt-cache. +type Apt struct{} + +// New returns a new Apt manager. +func New() *Apt { + return &Apt{} +} + +// Name returns "apt". +func (a *Apt) Name() string { return "apt" } + +// Install one or more packages. +func (a *Apt) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (a *Apt) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return remove(ctx, pkgs, opts...) +} + +// Purge one or more packages including config files. +func (a *Apt) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return purge(ctx, pkgs, opts...) +} + +// Upgrade all installed packages. +func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error { + return upgrade(ctx, opts...) +} + +// Update refreshes the package index. +func (a *Apt) Update(ctx context.Context) error { + return update(ctx) +} + +// List returns all installed packages. +func (a *Apt) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries the package index. +func (a *Apt) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (a *Apt) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (a *Apt) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (a *Apt) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Available reports whether apt-get is present on the system. +func (a *Apt) Available() bool { + return available() +} diff --git a/apt/apt_linux.go b/apt/apt_linux.go new file mode 100644 index 0000000..35e8229 --- /dev/null +++ b/apt/apt_linux.go @@ -0,0 +1,138 @@ +//go:build linux + +package apt + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("apt-get") + return err == nil +} + +func buildArgs(command string, pkgs []string, opts ...snack.Option) []string { + o := snack.ApplyOptions(opts...) + var args []string + if o.Sudo { + args = append(args, "sudo") + } + args = append(args, "apt-get", command) + if o.AssumeYes { + args = append(args, "-y") + } + if o.DryRun { + args = append(args, "--dry-run") + } + args = append(args, pkgs...) + return args +} + +func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack.Option) error { + args := buildArgs(command, pkgs, opts...) + var cmd *exec.Cmd + if args[0] == "sudo" { + cmd = exec.CommandContext(ctx, args[0], args[1:]...) + } else { + cmd = exec.CommandContext(ctx, args[0], args[1:]...) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") { + return fmt.Errorf("apt-get %s: %w", command, snack.ErrPermissionDenied) + } + if strings.Contains(errMsg, "Unable to locate package") { + return fmt.Errorf("apt-get %s: %w", command, snack.ErrNotFound) + } + return fmt.Errorf("apt-get %s: %w: %s", command, err, errMsg) + } + return nil +} + +func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return runAptGet(ctx, "install", pkgs, opts...) +} + +func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return runAptGet(ctx, "remove", pkgs, opts...) +} + +func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return runAptGet(ctx, "purge", pkgs, opts...) +} + +func upgrade(ctx context.Context, opts ...snack.Option) error { + return runAptGet(ctx, "upgrade", nil, opts...) +} + +func update(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "apt-get", "update") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-get update: %w: %s", err, stderr.String()) + } + return nil +} + +func list(ctx context.Context) ([]snack.Package, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Description}\\n") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("dpkg-query list: %w", err) + } + return parseList(string(out)), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + cmd := exec.CommandContext(ctx, "apt-cache", "search", query) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("apt-cache search: %w", err) + } + return parseSearch(string(out)), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + cmd := exec.CommandContext(ctx, "apt-cache", "show", pkg) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if strings.Contains(stderr.String(), "No packages found") { + return nil, fmt.Errorf("apt-cache show %s: %w", pkg, snack.ErrNotFound) + } + return nil, fmt.Errorf("apt-cache show %s: %w", pkg, err) + } + return parseInfo(string(out)) +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg) + out, err := cmd.Output() + if err != nil { + return false, nil + } + return strings.TrimSpace(string(out)) == "install ok installed", nil +} + +func version(ctx context.Context, pkg string) (string, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled) + } + v := strings.TrimSpace(string(out)) + if v == "" { + return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled) + } + return v, nil +} diff --git a/apt/apt_other.go b/apt/apt_other.go new file mode 100644 index 0000000..55fa9d5 --- /dev/null +++ b/apt/apt_other.go @@ -0,0 +1,51 @@ +//go:build !linux + +package apt + +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/apt/apt_test.go b/apt/apt_test.go new file mode 100644 index 0000000..254f1f9 --- /dev/null +++ b/apt/apt_test.go @@ -0,0 +1,71 @@ +package apt + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList(t *testing.T) { + input := "bash\t5.2-1\tGNU Bourne Again SHell\ncoreutils\t9.1-1\tGNU core utilities\n" + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "bash" || pkgs[0].Version != "5.2-1" { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseSearch(t *testing.T) { + input := "vim - Vi IMproved\nnano - small text editor\n" + pkgs := parseSearch(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "vim" || pkgs[0].Description != "Vi IMproved" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } +} + +func TestParseInfo(t *testing.T) { + input := `Package: bash +Version: 5.2-1 +Architecture: amd64 +Description: GNU Bourne Again SHell +` + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "bash" || p.Version != "5.2-1" || p.Arch != "amd64" { + t.Errorf("unexpected package: %+v", p) + } +} + +func TestParseInfoEmpty(t *testing.T) { + _, err := parseInfo("") + if err != snack.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestParseListEmpty(t *testing.T) { + pkgs := parseList("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } +} + +func TestNew(t *testing.T) { + a := New() + if a.Name() != "apt" { + t.Errorf("expected 'apt', got %q", a.Name()) + } +} + +// Verify Apt implements snack.Manager at compile time. +var _ snack.Manager = (*Apt)(nil) diff --git a/apt/parse.go b/apt/parse.go new file mode 100644 index 0000000..090f8f0 --- /dev/null +++ b/apt/parse.go @@ -0,0 +1,77 @@ +package apt + +import ( + "strings" + + "github.com/gogrlx/snack" +) + +// parseList parses dpkg-query -W output into packages. +func parseList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + p := snack.Package{ + Name: parts[0], + Version: parts[1], + Installed: true, + } + if len(parts) == 3 { + p.Description = parts[2] + } + pkgs = append(pkgs, p) + } + return pkgs +} + +// parseSearch parses apt-cache search output. +func parseSearch(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line == "" { + continue + } + // Format: "package - description" + parts := strings.SplitN(line, " - ", 2) + if len(parts) < 1 { + continue + } + p := snack.Package{Name: strings.TrimSpace(parts[0])} + if len(parts) == 2 { + p.Description = strings.TrimSpace(parts[1]) + } + pkgs = append(pkgs, p) + } + return pkgs +} + +// parseInfo parses apt-cache show output into a Package. +func parseInfo(output string) (*snack.Package, error) { + p := &snack.Package{} + for _, line := range strings.Split(output, "\n") { + key, val, ok := strings.Cut(line, ": ") + if !ok { + continue + } + switch key { + case "Package": + p.Name = val + case "Version": + p.Version = val + case "Description": + p.Description = val + case "Architecture": + p.Arch = val + } + } + if p.Name == "" { + return nil, snack.ErrNotFound + } + return p, nil +} diff --git a/dpkg/dpkg.go b/dpkg/dpkg.go index 3802ba3..ae13949 100644 --- a/dpkg/dpkg.go +++ b/dpkg/dpkg.go @@ -1,2 +1,74 @@ // Package dpkg provides Go bindings for dpkg (low-level Debian package tool). package dpkg + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Dpkg implements the snack.Manager interface using dpkg and dpkg-query. +type Dpkg struct{} + +// New returns a new Dpkg manager. +func New() *Dpkg { + return &Dpkg{} +} + +// Name returns "dpkg". +func (d *Dpkg) Name() string { return "dpkg" } + +// Install one or more .deb files. +func (d *Dpkg) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (d *Dpkg) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return remove(ctx, pkgs, opts...) +} + +// Purge one or more packages including config files. +func (d *Dpkg) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + return purge(ctx, pkgs, opts...) +} + +// Upgrade is not supported by dpkg. +func (d *Dpkg) Upgrade(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +// Update is not supported by dpkg. +func (d *Dpkg) Update(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +// List returns all installed packages. +func (d *Dpkg) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries installed packages matching the pattern. +func (d *Dpkg) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (d *Dpkg) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (d *Dpkg) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (d *Dpkg) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Available reports whether dpkg is present on the system. +func (d *Dpkg) Available() bool { + return available() +} diff --git a/dpkg/dpkg_linux.go b/dpkg/dpkg_linux.go new file mode 100644 index 0000000..a7cecaa --- /dev/null +++ b/dpkg/dpkg_linux.go @@ -0,0 +1,138 @@ +//go:build linux + +package dpkg + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("dpkg") + return err == nil +} + +func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + var args []string + if o.Sudo { + args = append(args, "sudo") + } + args = append(args, "dpkg", "-i") + if o.DryRun { + args = append(args, "--simulate") + } + args = append(args, pkgs...) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "Permission denied") || strings.Contains(errMsg, "are you root") { + return fmt.Errorf("dpkg -i: %w", snack.ErrPermissionDenied) + } + return fmt.Errorf("dpkg -i: %w: %s", err, errMsg) + } + return nil +} + +func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + var args []string + if o.Sudo { + args = append(args, "sudo") + } + args = append(args, "dpkg", "-r") + if o.DryRun { + args = append(args, "--simulate") + } + args = append(args, pkgs...) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("dpkg -r: %w: %s", err, stderr.String()) + } + return nil +} + +func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + var args []string + if o.Sudo { + args = append(args, "sudo") + } + args = append(args, "dpkg", "-P") + if o.DryRun { + args = append(args, "--simulate") + } + args = append(args, pkgs...) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("dpkg -P: %w: %s", err, stderr.String()) + } + return nil +} + +func list(ctx context.Context) ([]snack.Package, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Status}\\n") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("dpkg-query list: %w", err) + } + return parseList(string(out)), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + pattern := fmt.Sprintf("*%s*", query) + cmd := exec.CommandContext(ctx, "dpkg-query", "-l", pattern) + out, err := cmd.Output() + if err != nil { + // dpkg-query -l returns exit 1 when no packages match + return nil, nil + } + return parseDpkgList(string(out)), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-s", pkg) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if strings.Contains(stderr.String(), "is not installed") || strings.Contains(stderr.String(), "not found") { + return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("dpkg-query -s %s: %w", pkg, err) + } + return parseInfo(string(out)) +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", pkg) + out, err := cmd.Output() + if err != nil { + return false, nil + } + return strings.TrimSpace(string(out)) == "install ok installed", nil +} + +func version(ctx context.Context, pkg string) (string, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Version}", pkg) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled) + } + v := strings.TrimSpace(string(out)) + if v == "" { + return "", fmt.Errorf("version %s: %w", pkg, snack.ErrNotInstalled) + } + return v, nil +} diff --git a/dpkg/dpkg_other.go b/dpkg/dpkg_other.go new file mode 100644 index 0000000..a3e54d9 --- /dev/null +++ b/dpkg/dpkg_other.go @@ -0,0 +1,43 @@ +//go:build !linux + +package dpkg + +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 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/dpkg/dpkg_test.go b/dpkg/dpkg_test.go new file mode 100644 index 0000000..9b14913 --- /dev/null +++ b/dpkg/dpkg_test.go @@ -0,0 +1,89 @@ +package dpkg + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList(t *testing.T) { + input := "bash\t5.2-1\tinstall ok installed\ncoreutils\t9.1-1\tdeinstall ok config-files\n" + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "bash" || !pkgs[0].Installed { + t.Errorf("unexpected first package: %+v", pkgs[0]) + } + if pkgs[1].Installed { + t.Errorf("expected second package not installed: %+v", pkgs[1]) + } +} + +func TestParseDpkgList(t *testing.T) { + input := `Desired=Unknown/Install/Remove/Purge/Hold +| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend +|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) +||/ Name Version Architecture Description ++++-==============-============-============-================================= +ii bash 5.2-1 amd64 GNU Bourne Again SHell +rc oldpkg 1.0-1 amd64 Some old package +` + pkgs := parseDpkgList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "bash" || !pkgs[0].Installed { + t.Errorf("unexpected: %+v", pkgs[0]) + } + if pkgs[1].Installed { + t.Errorf("expected oldpkg not installed: %+v", pkgs[1]) + } +} + +func TestParseInfo(t *testing.T) { + input := `Package: bash +Status: install ok installed +Version: 5.2-1 +Architecture: amd64 +Description: GNU Bourne Again SHell +` + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "bash" || p.Version != "5.2-1" || !p.Installed { + t.Errorf("unexpected: %+v", p) + } +} + +func TestParseInfoEmpty(t *testing.T) { + _, err := parseInfo("") + if err != snack.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestNew(t *testing.T) { + d := New() + if d.Name() != "dpkg" { + t.Errorf("expected 'dpkg', got %q", d.Name()) + } +} + +func TestUpgradeUnsupported(t *testing.T) { + d := New() + if err := d.Upgrade(nil); err != snack.ErrUnsupportedPlatform { + t.Errorf("expected ErrUnsupportedPlatform, got %v", err) + } +} + +func TestUpdateUnsupported(t *testing.T) { + d := New() + if err := d.Update(nil); err != snack.ErrUnsupportedPlatform { + t.Errorf("expected ErrUnsupportedPlatform, got %v", err) + } +} + +// Verify Dpkg implements snack.Manager at compile time. +var _ snack.Manager = (*Dpkg)(nil) diff --git a/dpkg/parse.go b/dpkg/parse.go new file mode 100644 index 0000000..37f822e --- /dev/null +++ b/dpkg/parse.go @@ -0,0 +1,87 @@ +package dpkg + +import ( + "strings" + + "github.com/gogrlx/snack" +) + +// parseList parses dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' output. +func parseList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + p := snack.Package{ + Name: parts[0], + Version: parts[1], + } + if len(parts) == 3 && strings.Contains(parts[2], "install ok installed") { + p.Installed = true + } + pkgs = append(pkgs, p) + } + return pkgs +} + +// parseDpkgList parses dpkg-query -l output (table format with header). +func parseDpkgList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + // Data lines start with status flags like "ii", "rc", etc. + if len(line) < 4 || line[2] != ' ' { + continue + } + // Skip lines where flags aren't letters (e.g. "+++-..." separator) + if line[0] < 'a' || line[0] > 'z' { + continue + } + status := line[:2] + fields := strings.Fields(line[3:]) + if len(fields) < 2 { + continue + } + p := snack.Package{ + Name: fields[0], + Version: fields[1], + Installed: status == "ii", + } + if len(fields) > 3 { + p.Description = strings.Join(fields[3:], " ") + } + pkgs = append(pkgs, p) + } + return pkgs +} + +// parseInfo parses dpkg-query -s output into a Package. +func parseInfo(output string) (*snack.Package, error) { + p := &snack.Package{} + for _, line := range strings.Split(output, "\n") { + key, val, ok := strings.Cut(line, ": ") + if !ok { + continue + } + switch key { + case "Package": + p.Name = val + case "Version": + p.Version = val + case "Description": + p.Description = val + case "Architecture": + p.Arch = val + case "Status": + p.Installed = val == "install ok installed" + } + } + if p.Name == "" { + return nil, snack.ErrNotFound + } + return p, nil +}