From beb4c51219fc6b8bee4eeab8c047a4585ffef931 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 26 Feb 2026 02:11:27 +0000 Subject: [PATCH] feat(dnf): add dnf5 compatibility Detect dnf5 at startup via 'dnf --version' output and route to version-specific parsers and command arguments. Key changes: - DNF struct caches v5 detection result - New parse_dnf5.go with parsers for all dnf5 output formats - stripPreamble() removes dnf5 repository loading noise - Command arguments adjusted: --installed, --upgrades, --available - CI matrix expanded with fedora:latest (dnf5) alongside fedora:39 (dnf4) - Full backward compatibility with dnf4 preserved --- .github/workflows/integration.yml | 21 ++- dnf/capabilities.go | 12 +- dnf/capabilities_linux.go | 43 ++++-- dnf/capabilities_other.go | 12 +- dnf/dnf.go | 19 ++- dnf/dnf_linux.go | 62 ++++++-- dnf/dnf_other.go | 12 +- dnf/parse_dnf5.go | 231 ++++++++++++++++++++++++++++++ dnf/parse_test.go | 160 +++++++++++++++++++++ 9 files changed, 527 insertions(+), 45 deletions(-) create mode 100644 dnf/parse_dnf5.go diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 50ccdec..59849fe 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -52,10 +52,25 @@ jobs: - name: Integration tests (snap) run: sudo -E go test -v -tags integration -count=1 ./snap/ - fedora: - name: Fedora (dnf) + fedora-dnf4: + name: Fedora 39 (dnf4) runs-on: ubuntu-latest - container: fedora:39 # last release with dnf4; dnf5 (fedora 40+) needs separate parser + container: fedora:39 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Setup + run: | + dnf install -y tree sudo + - name: Integration tests + run: go test -v -tags integration -count=1 ./dnf/ ./rpm/ ./detect/ + + fedora-dnf5: + name: Fedora latest (dnf5) + runs-on: ubuntu-latest + container: fedora:latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/dnf/capabilities.go b/dnf/capabilities.go index 83faa81..1df600d 100644 --- a/dnf/capabilities.go +++ b/dnf/capabilities.go @@ -20,17 +20,17 @@ var ( // LatestVersion returns the latest available version from configured repositories. func (d *DNF) LatestVersion(ctx context.Context, pkg string) (string, error) { - return latestVersion(ctx, pkg) + return latestVersion(ctx, pkg, d.v5) } // ListUpgrades returns packages that have newer versions available. func (d *DNF) ListUpgrades(ctx context.Context) ([]snack.Package, error) { - return listUpgrades(ctx) + return listUpgrades(ctx, d.v5) } // UpgradeAvailable reports whether a newer version is available. func (d *DNF) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { - return upgradeAvailable(ctx, pkg) + return upgradeAvailable(ctx, pkg, d.v5) } // VersionCmp compares two version strings using RPM version comparison. @@ -83,7 +83,7 @@ func (d *DNF) Owner(ctx context.Context, path string) (string, error) { // ListRepos returns all configured package repositories. func (d *DNF) ListRepos(ctx context.Context) ([]snack.Repository, error) { - return listRepos(ctx) + return listRepos(ctx, d.v5) } // AddRepo adds a new package repository. @@ -121,12 +121,12 @@ func (d *DNF) ListKeys(ctx context.Context) ([]string, error) { // GroupList returns all available package groups. func (d *DNF) GroupList(ctx context.Context) ([]string, error) { - return groupList(ctx) + return groupList(ctx, d.v5) } // GroupInfo returns the packages in a group. func (d *DNF) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) { - return groupInfo(ctx, group) + return groupInfo(ctx, group, d.v5) } // GroupInstall installs all packages in a group. diff --git a/dnf/capabilities_linux.go b/dnf/capabilities_linux.go index de9e2ea..64c5aad 100644 --- a/dnf/capabilities_linux.go +++ b/dnf/capabilities_linux.go @@ -14,7 +14,7 @@ import ( "github.com/gogrlx/snack" ) -func latestVersion(ctx context.Context, pkg string) (string, error) { +func latestVersion(ctx context.Context, pkg string, v5 bool) (string, error) { // Try "dnf info " which shows both installed and available out, err := run(ctx, []string{"info", pkg}, snack.Options{}) if err != nil { @@ -23,26 +23,42 @@ func latestVersion(ctx context.Context, pkg string) (string, error) { } return "", fmt.Errorf("dnf latestVersion: %w", err) } - p := parseInfo(out) + var p *snack.Package + if v5 { + p = parseInfoDNF5(out) + } else { + p = parseInfo(out) + } if p == nil || p.Version == "" { return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound) } return p.Version, nil } -func listUpgrades(ctx context.Context) ([]snack.Package, error) { - out, err := run(ctx, []string{"list", "upgrades"}, snack.Options{}) +func listUpgrades(ctx context.Context, v5 bool) ([]snack.Package, error) { + args := []string{"list", "upgrades"} + if v5 { + args = []string{"list", "--upgrades"} + } + out, err := run(ctx, args, snack.Options{}) if err != nil { if strings.Contains(err.Error(), "exit status 1") { return nil, nil } return nil, fmt.Errorf("dnf listUpgrades: %w", err) } + if v5 { + return parseListDNF5(out), nil + } return parseList(out), nil } -func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { - c := exec.CommandContext(ctx, "dnf", "list", "upgrades", pkg) +func upgradeAvailable(ctx context.Context, pkg string, v5 bool) (bool, error) { + args := []string{"list", "upgrades", pkg} + if v5 { + args = []string{"list", "--upgrades", pkg} + } + c := exec.CommandContext(ctx, "dnf", args...) err := c.Run() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { @@ -144,11 +160,14 @@ func owner(ctx context.Context, path string) (string, error) { return strings.TrimSpace(stdout.String()), nil } -func listRepos(ctx context.Context) ([]snack.Repository, error) { +func listRepos(ctx context.Context, v5 bool) ([]snack.Repository, error) { out, err := run(ctx, []string{"repolist", "--all"}, snack.Options{}) if err != nil { return nil, fmt.Errorf("dnf listRepos: %w", err) } + if v5 { + return parseRepoListDNF5(out), nil + } return parseRepoList(out), nil } @@ -206,15 +225,18 @@ func listKeys(ctx context.Context) ([]string, error) { return keys, nil } -func groupList(ctx context.Context) ([]string, error) { +func groupList(ctx context.Context, v5 bool) ([]string, error) { out, err := run(ctx, []string{"group", "list"}, snack.Options{}) if err != nil { return nil, fmt.Errorf("dnf groupList: %w", err) } + if v5 { + return parseGroupListDNF5(out), nil + } return parseGroupList(out), nil } -func groupInfo(ctx context.Context, group string) ([]snack.Package, error) { +func groupInfo(ctx context.Context, group string, v5 bool) ([]snack.Package, error) { out, err := run(ctx, []string{"group", "info", group}, snack.Options{}) if err != nil { if strings.Contains(err.Error(), "exit status 1") { @@ -222,6 +244,9 @@ func groupInfo(ctx context.Context, group string) ([]snack.Package, error) { } return nil, fmt.Errorf("dnf groupInfo: %w", err) } + if v5 { + return parseGroupInfoDNF5(out), nil + } return parseGroupInfo(out), nil } diff --git a/dnf/capabilities_other.go b/dnf/capabilities_other.go index 6cc33a1..17a06ea 100644 --- a/dnf/capabilities_other.go +++ b/dnf/capabilities_other.go @@ -8,15 +8,15 @@ import ( "github.com/gogrlx/snack" ) -func latestVersion(_ context.Context, _ string) (string, error) { +func latestVersion(_ context.Context, _ string, _ bool) (string, error) { return "", snack.ErrUnsupportedPlatform } -func listUpgrades(_ context.Context) ([]snack.Package, error) { +func listUpgrades(_ context.Context, _ bool) ([]snack.Package, error) { return nil, snack.ErrUnsupportedPlatform } -func upgradeAvailable(_ context.Context, _ string) (bool, error) { +func upgradeAvailable(_ context.Context, _ string, _ bool) (bool, error) { return false, snack.ErrUnsupportedPlatform } @@ -52,7 +52,7 @@ func owner(_ context.Context, _ string) (string, error) { return "", snack.ErrUnsupportedPlatform } -func listRepos(_ context.Context) ([]snack.Repository, error) { +func listRepos(_ context.Context, _ bool) ([]snack.Repository, error) { return nil, snack.ErrUnsupportedPlatform } @@ -76,11 +76,11 @@ func listKeys(_ context.Context) ([]string, error) { return nil, snack.ErrUnsupportedPlatform } -func groupList(_ context.Context) ([]string, error) { +func groupList(_ context.Context, _ bool) ([]string, error) { return nil, snack.ErrUnsupportedPlatform } -func groupInfo(_ context.Context, _ string) ([]snack.Package, error) { +func groupInfo(_ context.Context, _ string, _ bool) ([]snack.Package, error) { return nil, snack.ErrUnsupportedPlatform } diff --git a/dnf/dnf.go b/dnf/dnf.go index b864d03..20f2aeb 100644 --- a/dnf/dnf.go +++ b/dnf/dnf.go @@ -10,13 +10,20 @@ import ( // DNF wraps the dnf package manager CLI. type DNF struct { snack.Locker + v5 bool // true when the system dnf is dnf5 + v5Set bool // true after detection has run } // New returns a new DNF manager. func New() *DNF { - return &DNF{} + d := &DNF{} + d.detectVersion() + return d } +// IsDNF5 reports whether the underlying dnf binary is dnf5. +func (d *DNF) IsDNF5() bool { return d.v5 } + // Name returns "dnf". func (d *DNF) Name() string { return "dnf" } @@ -60,27 +67,27 @@ func (d *DNF) Update(ctx context.Context) error { // List returns all installed packages. func (d *DNF) List(ctx context.Context) ([]snack.Package, error) { - return list(ctx) + return list(ctx, d.v5) } // Search queries the repositories for packages matching the query. func (d *DNF) Search(ctx context.Context, query string) ([]snack.Package, error) { - return search(ctx, query) + return search(ctx, query, d.v5) } // Info returns details about a specific package. func (d *DNF) Info(ctx context.Context, pkg string) (*snack.Package, error) { - return info(ctx, pkg) + return info(ctx, pkg, d.v5) } // IsInstalled reports whether a package is currently installed. func (d *DNF) IsInstalled(ctx context.Context, pkg string) (bool, error) { - return isInstalled(ctx, pkg) + return isInstalled(ctx, pkg, d.v5) } // Version returns the installed version of a package. func (d *DNF) Version(ctx context.Context, pkg string) (string, error) { - return version(ctx, pkg) + return version(ctx, pkg, d.v5) } // Verify interface compliance at compile time. diff --git a/dnf/dnf_linux.go b/dnf/dnf_linux.go index 401e8ca..3dab77c 100644 --- a/dnf/dnf_linux.go +++ b/dnf/dnf_linux.go @@ -17,6 +17,20 @@ func available() bool { return err == nil } +// detectVersion checks whether the system dnf is dnf5 by inspecting +// `dnf --version` output. dnf5 prints "dnf5 version 5.x.x". +func (d *DNF) detectVersion() { + if d.v5Set { + return + } + d.v5Set = true + out, err := exec.Command("dnf", "--version").CombinedOutput() + if err != nil { + return + } + d.v5 = strings.Contains(string(out), "dnf5") +} + // 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) { @@ -116,15 +130,25 @@ func update(ctx context.Context) error { return err } -func list(ctx context.Context) ([]snack.Package, error) { - out, err := run(ctx, []string{"list", "installed"}, snack.Options{}) +func listArgs(v5 bool) []string { + if v5 { + return []string{"list", "--installed"} + } + return []string{"list", "installed"} +} + +func list(ctx context.Context, v5 bool) ([]snack.Package, error) { + out, err := run(ctx, listArgs(v5), snack.Options{}) if err != nil { return nil, fmt.Errorf("dnf list: %w", err) } + if v5 { + return parseListDNF5(out), nil + } return parseList(out), nil } -func search(ctx context.Context, query string) ([]snack.Package, error) { +func search(ctx context.Context, query string, v5 bool) ([]snack.Package, error) { out, err := run(ctx, []string{"search", query}, snack.Options{}) if err != nil { if strings.Contains(err.Error(), "exit status 1") { @@ -132,10 +156,13 @@ func search(ctx context.Context, query string) ([]snack.Package, error) { } return nil, fmt.Errorf("dnf search: %w", err) } + if v5 { + return parseSearchDNF5(out), nil + } return parseSearch(out), nil } -func info(ctx context.Context, pkg string) (*snack.Package, error) { +func info(ctx context.Context, pkg string, v5 bool) (*snack.Package, error) { out, err := run(ctx, []string{"info", pkg}, snack.Options{}) if err != nil { if strings.Contains(err.Error(), "exit status 1") { @@ -143,15 +170,24 @@ func info(ctx context.Context, pkg string) (*snack.Package, error) { } return nil, fmt.Errorf("dnf info: %w", err) } - p := parseInfo(out) + var p *snack.Package + if v5 { + p = parseInfoDNF5(out) + } else { + p = parseInfo(out) + } if p == nil { return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound) } return p, nil } -func isInstalled(ctx context.Context, pkg string) (bool, error) { - c := exec.CommandContext(ctx, "dnf", "list", "installed", pkg) +func isInstalled(ctx context.Context, pkg string, v5 bool) (bool, error) { + args := []string{"list", "installed", pkg} + if v5 { + args = []string{"list", "--installed", pkg} + } + c := exec.CommandContext(ctx, "dnf", args...) err := c.Run() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { @@ -162,15 +198,21 @@ func isInstalled(ctx context.Context, pkg string) (bool, error) { return true, nil } -func version(ctx context.Context, pkg string) (string, error) { - out, err := run(ctx, []string{"list", "installed", pkg}, snack.Options{}) +func version(ctx context.Context, pkg string, v5 bool) (string, error) { + args := append(listArgs(v5), pkg) + out, err := run(ctx, args, snack.Options{}) if err != nil { if strings.Contains(err.Error(), "exit status 1") { return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled) } return "", fmt.Errorf("dnf version: %w", err) } - pkgs := parseList(out) + var pkgs []snack.Package + if v5 { + pkgs = parseListDNF5(out) + } else { + pkgs = parseList(out) + } if len(pkgs) == 0 { return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled) } diff --git a/dnf/dnf_other.go b/dnf/dnf_other.go index 2592a26..509aaf9 100644 --- a/dnf/dnf_other.go +++ b/dnf/dnf_other.go @@ -10,6 +10,8 @@ import ( func available() bool { return false } +func (d *DNF) detectVersion() {} + func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } @@ -26,22 +28,22 @@ func update(_ context.Context) error { return snack.ErrUnsupportedPlatform } -func list(_ context.Context) ([]snack.Package, error) { +func list(_ context.Context, _ bool) ([]snack.Package, error) { return nil, snack.ErrUnsupportedPlatform } -func search(_ context.Context, _ string) ([]snack.Package, error) { +func search(_ context.Context, _ string, _ bool) ([]snack.Package, error) { return nil, snack.ErrUnsupportedPlatform } -func info(_ context.Context, _ string) (*snack.Package, error) { +func info(_ context.Context, _ string, _ bool) (*snack.Package, error) { return nil, snack.ErrUnsupportedPlatform } -func isInstalled(_ context.Context, _ string) (bool, error) { +func isInstalled(_ context.Context, _ string, _ bool) (bool, error) { return false, snack.ErrUnsupportedPlatform } -func version(_ context.Context, _ string) (string, error) { +func version(_ context.Context, _ string, _ bool) (string, error) { return "", snack.ErrUnsupportedPlatform } diff --git a/dnf/parse_dnf5.go b/dnf/parse_dnf5.go new file mode 100644 index 0000000..595d54a --- /dev/null +++ b/dnf/parse_dnf5.go @@ -0,0 +1,231 @@ +package dnf + +import ( + "strings" + + "github.com/gogrlx/snack" +) + +// stripPreamble removes dnf5 repository loading output that appears on stdout. +// It strips everything from "Updating and loading repositories:" through +// "Repositories loaded." inclusive. +func stripPreamble(output string) string { + lines := strings.Split(output, "\n") + var result []string + inPreamble := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Updating and loading repositories:") { + inPreamble = true + continue + } + if inPreamble { + if strings.HasPrefix(trimmed, "Repositories loaded.") { + inPreamble = false + continue + } + continue + } + result = append(result, line) + } + return strings.Join(result, "\n") +} + +// parseListDNF5 parses `dnf5 list --installed` / `dnf5 list --upgrades` output. +// Format: +// +// Installed packages +// name.arch version-release repo-hash +func parseListDNF5(output string) []snack.Package { + output = stripPreamble(output) + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // Skip section headers + lower := strings.ToLower(trimmed) + if lower == "installed packages" || lower == "available packages" || + lower == "upgraded packages" || lower == "available upgrades" { + continue + } + parts := strings.Fields(trimmed) + if len(parts) < 2 { + continue + } + nameArch := parts[0] + ver := parts[1] + repo := "" + if len(parts) >= 3 { + repo = parts[2] + } + name, arch := parseArch(nameArch) + pkgs = append(pkgs, snack.Package{ + Name: name, + Version: ver, + Arch: arch, + Repository: repo, + Installed: true, + }) + } + return pkgs +} + +// parseSearchDNF5 parses `dnf5 search` output. +// Format: +// +// Matched fields: name +// name.arch Description text +// Matched fields: summary +// name.arch Description text +func parseSearchDNF5(output string) []snack.Package { + output = stripPreamble(output) + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "Matched fields:") { + continue + } + // Lines are: "name.arch Description" + // Split on first double-space or use Fields + parts := strings.Fields(trimmed) + if len(parts) < 2 { + continue + } + nameArch := parts[0] + if !strings.Contains(nameArch, ".") { + continue + } + desc := strings.Join(parts[1:], " ") + name, arch := parseArch(nameArch) + pkgs = append(pkgs, snack.Package{ + Name: name, + Arch: arch, + Description: desc, + }) + } + return pkgs +} + +// parseInfoDNF5 parses `dnf5 info` output. +// Format: "Key : Value" lines with possible section headers like "Available packages". +func parseInfoDNF5(output string) *snack.Package { + output = stripPreamble(output) + 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+3:]) + switch key { + case "Name": + pkg.Name = val + case "Version": + pkg.Version = val + case "Release": + if pkg.Version != "" { + pkg.Version = pkg.Version + "-" + val + } + case "Architecture", "Arch": + pkg.Arch = val + case "Summary": + pkg.Description = val + case "Repository", "From repo": + pkg.Repository = val + } + } + if pkg.Name == "" { + return nil + } + return pkg +} + +// parseRepoListDNF5 parses `dnf5 repolist --all` output. +// Same tabular format as dnf4, reuses the same logic. +func parseRepoListDNF5(output string) []snack.Repository { + output = stripPreamble(output) + return parseRepoList(output) +} + +// parseGroupListDNF5 parses `dnf5 group list` tabular output. +// Format: +// +// ID Name Installed +// neuron-modelling-simulators Neuron Modelling Simulators no +func parseGroupListDNF5(output string) []string { + output = stripPreamble(output) + var groups []string + inBody := false + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if !inBody { + if strings.HasPrefix(trimmed, "ID") && strings.Contains(trimmed, "Name") { + inBody = true + continue + } + continue + } + // Last field is "yes"/"no", second-to-last through first space group is name. + // Parse: first token is ID, last token is yes/no, middle is name. + parts := strings.Fields(trimmed) + if len(parts) < 3 { + continue + } + // Last field is installed status + status := parts[len(parts)-1] + if status != "yes" && status != "no" { + continue + } + name := strings.Join(parts[1:len(parts)-1], " ") + groups = append(groups, name) + } + return groups +} + +// parseGroupInfoDNF5 parses `dnf5 group info` output. +// Format: +// +// Id : kde-desktop +// Mandatory packages : plasma-desktop +// : plasma-workspace +// Default packages : NetworkManager-config-connectivity-fedora +func parseGroupInfoDNF5(output string) []snack.Package { + output = stripPreamble(output) + var pkgs []snack.Package + inPkgSection := false + 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+3:]) + + if key == "Mandatory packages" || key == "Default packages" || + key == "Optional packages" || key == "Conditional packages" { + inPkgSection = true + if val != "" { + pkgs = append(pkgs, snack.Package{Name: val}) + } + continue + } + + // Continuation line: key is empty + if key == "" && inPkgSection && val != "" { + pkgs = append(pkgs, snack.Package{Name: val}) + continue + } + + // Any other key ends the package section + if key != "" { + inPkgSection = false + } + } + return pkgs +} diff --git a/dnf/parse_test.go b/dnf/parse_test.go index 3aeb7d9..d7f604e 100644 --- a/dnf/parse_test.go +++ b/dnf/parse_test.go @@ -1,6 +1,7 @@ package dnf import ( + "strings" "testing" "github.com/gogrlx/snack" @@ -220,6 +221,165 @@ func TestParseArch(t *testing.T) { } } +// --- dnf5 parser tests --- + +func TestStripPreamble(t *testing.T) { + input := "Updating and loading repositories:\n Fedora 43 - x86_64 100% | 10.2 MiB/s | 20.5 MiB | 00m02s\nRepositories loaded.\nInstalled packages\nbash.x86_64 5.3.0-2.fc43 abc123\n" + got := stripPreamble(input) + if strings.Contains(got, "Updating and loading") { + t.Error("preamble not stripped") + } + if strings.Contains(got, "Repositories loaded") { + t.Error("preamble tail not stripped") + } + if !strings.Contains(got, "bash.x86_64") { + t.Error("content was incorrectly stripped") + } +} + +func TestParseListDNF5(t *testing.T) { + input := `Installed packages +alternatives.x86_64 1.33-3.fc43 a899a9b296804e8ab27411270a04f5e9 +bash.x86_64 5.3.0-2.fc43 3b3d0b7480cd48d19a2c4259e547f2da +` + pkgs := parseListDNF5(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "alternatives" || pkgs[0].Version != "1.33-3.fc43" || pkgs[0].Arch != "x86_64" { + t.Errorf("unexpected pkg[0]: %+v", pkgs[0]) + } + if pkgs[1].Name != "bash" || pkgs[1].Version != "5.3.0-2.fc43" { + t.Errorf("unexpected pkg[1]: %+v", pkgs[1]) + } +} + +func TestParseListDNF5WithPreamble(t *testing.T) { + input := "Updating and loading repositories:\nRepositories loaded.\nInstalled packages\nbash.x86_64 5.3.0-2.fc43 abc\n" + pkgs := parseListDNF5(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "bash" { + t.Errorf("expected bash, got %q", pkgs[0].Name) + } +} + +func TestParseSearchDNF5(t *testing.T) { + input := `Matched fields: name + tree.x86_64 File system tree viewer + treescan.noarch Scan directory trees, list directories/files, stat, sync, grep +Matched fields: summary + baobab.x86_64 A graphical directory tree analyzer +` + pkgs := parseSearchDNF5(input) + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "tree" || pkgs[0].Arch != "x86_64" { + t.Errorf("unexpected pkg[0]: %+v", pkgs[0]) + } + if pkgs[0].Description != "File system tree viewer" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } + if pkgs[2].Name != "baobab" { + t.Errorf("unexpected pkg[2]: %+v", pkgs[2]) + } +} + +func TestParseInfoDNF5(t *testing.T) { + input := `Available packages +Name : tree +Epoch : 0 +Version : 2.2.1 +Release : 2.fc43 +Architecture : x86_64 +Download size : 61.3 KiB +Installed size : 112.2 KiB +Source : tree-pkg-2.2.1-2.fc43.src.rpm +Repository : fedora +Summary : File system tree viewer +` + p := parseInfoDNF5(input) + if p == nil { + t.Fatal("expected package, got nil") + } + if p.Name != "tree" { + t.Errorf("Name = %q, want tree", p.Name) + } + if p.Version != "2.2.1-2.fc43" { + t.Errorf("Version = %q, want 2.2.1-2.fc43", p.Version) + } + if p.Arch != "x86_64" { + t.Errorf("Arch = %q, want x86_64", p.Arch) + } + if p.Repository != "fedora" { + t.Errorf("Repository = %q, want fedora", p.Repository) + } + if p.Description != "File system tree viewer" { + t.Errorf("Description = %q", p.Description) + } +} + +func TestParseGroupListDNF5(t *testing.T) { + input := `ID Name Installed +neuron-modelling-simulators Neuron Modelling Simulators no +kde-desktop KDE no +` + groups := parseGroupListDNF5(input) + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + if groups[0] != "Neuron Modelling Simulators" { + t.Errorf("groups[0] = %q", groups[0]) + } + if groups[1] != "KDE" { + t.Errorf("groups[1] = %q", groups[1]) + } +} + +func TestParseGroupInfoDNF5(t *testing.T) { + input := `Id : kde-desktop +Name : KDE +Description : The KDE Plasma Workspaces... +Installed : no +Mandatory packages : plasma-desktop + : plasma-workspace +Default packages : NetworkManager-config-connectivity-fedora +` + pkgs := parseGroupInfoDNF5(input) + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } + names := map[string]bool{} + for _, p := range pkgs { + names[p.Name] = true + } + for _, want := range []string{"plasma-desktop", "plasma-workspace", "NetworkManager-config-connectivity-fedora"} { + if !names[want] { + t.Errorf("missing package %q", want) + } + } +} + +func TestParseRepoListDNF5(t *testing.T) { + input := `repo id repo name status +fedora Fedora 43 - x86_64 enabled +updates Fedora 43 - x86_64 - Updates enabled +updates-testing Fedora 43 - x86_64 - Test Updates disabled +` + repos := parseRepoListDNF5(input) + if len(repos) != 3 { + t.Fatalf("expected 3 repos, got %d", len(repos)) + } + if repos[0].ID != "fedora" || !repos[0].Enabled { + t.Errorf("unexpected repo[0]: %+v", repos[0]) + } + if repos[2].ID != "updates-testing" || repos[2].Enabled { + t.Errorf("unexpected repo[2]: %+v", repos[2]) + } +} + // Ensure interface checks from capabilities.go are satisfied. var ( _ snack.Manager = (*DNF)(nil)