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
This commit is contained in:
2026-02-26 02:11:27 +00:00
parent 61e9c99ac0
commit beb4c51219
9 changed files with 527 additions and 45 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 <pkg>" 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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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
}

231
dnf/parse_dnf5.go Normal file
View File

@@ -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
}

View File

@@ -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)