From dec1516387f6fd9e998ffa08a93febf9e3677be8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:23:21 +0000 Subject: [PATCH] Add GroupIsInstalled to Grouper interface with pacman and dnf implementations Co-authored-by: taigrr <8261498+taigrr@users.noreply.github.com> --- dnf/capabilities.go | 5 ++++ dnf/capabilities_linux.go | 11 +++++++ dnf/capabilities_other.go | 4 +++ dnf/parse.go | 20 +++++++++++++ dnf/parse_dnf5.go | 33 ++++++++++++++++++++ dnf/parse_test.go | 58 ++++++++++++++++++++++++++++++++++++ pacman/capabilities.go | 5 ++++ pacman/capabilities_linux.go | 47 +++++++++++++++++++++++++++++ pacman/capabilities_other.go | 4 +++ snack.go | 3 ++ 10 files changed, 190 insertions(+) diff --git a/dnf/capabilities.go b/dnf/capabilities.go index 2693bf8..1279004 100644 --- a/dnf/capabilities.go +++ b/dnf/capabilities.go @@ -136,6 +136,11 @@ func (d *DNF) GroupInstall(ctx context.Context, group string, opts ...snack.Opti return groupInstall(ctx, group, opts...) } +// GroupIsInstalled reports whether all packages in the group are installed. +func (d *DNF) GroupIsInstalled(ctx context.Context, group string) (bool, error) { + return groupIsInstalled(ctx, group, d.v5) +} + // NormalizeName returns the canonical form of a package name. func (d *DNF) NormalizeName(name string) string { return normalizeName(name) diff --git a/dnf/capabilities_linux.go b/dnf/capabilities_linux.go index 6d2b6ec..aa7866f 100644 --- a/dnf/capabilities_linux.go +++ b/dnf/capabilities_linux.go @@ -264,3 +264,14 @@ func groupInstall(ctx context.Context, group string, opts ...snack.Option) error _, err := run(ctx, []string{"group", "install", "-y", group}, o) return err } + +func groupIsInstalled(ctx context.Context, group string, v5 bool) (bool, error) { + out, err := run(ctx, []string{"group", "list"}, snack.Options{}) + if err != nil { + return false, fmt.Errorf("dnf groupIsInstalled: %w", err) + } + if v5 { + return parseGroupIsInstalledDNF5(out, group), nil + } + return parseGroupIsInstalled(out, group), nil +} diff --git a/dnf/capabilities_other.go b/dnf/capabilities_other.go index 0e76482..57eab34 100644 --- a/dnf/capabilities_other.go +++ b/dnf/capabilities_other.go @@ -87,3 +87,7 @@ func groupInfo(_ context.Context, _ string, _ bool) ([]snack.Package, error) { func groupInstall(_ context.Context, _ string, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } + +func groupIsInstalled(_ context.Context, _ string, _ bool) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} diff --git a/dnf/parse.go b/dnf/parse.go index 9d66378..00bbf1a 100644 --- a/dnf/parse.go +++ b/dnf/parse.go @@ -265,6 +265,26 @@ func parseGroupList(output string) []string { return groups } +// parseGroupIsInstalled checks whether a named group appears under "Installed Groups:" +// in `dnf group list` output. +func parseGroupIsInstalled(output, group string) bool { + inInstalled := false + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasSuffix(trimmed, "Groups:") || strings.HasSuffix(trimmed, "groups:") { + inInstalled = strings.HasPrefix(strings.ToLower(trimmed), "installed") + continue + } + if !inInstalled || trimmed == "" { + continue + } + if strings.EqualFold(trimmed, group) { + return true + } + } + return false +} + // parseGroupInfo parses `dnf group info ` output. // Packages are listed under section headers like "Mandatory Packages:", "Default Packages:", "Optional Packages:". func parseGroupInfo(output string) []snack.Package { diff --git a/dnf/parse_dnf5.go b/dnf/parse_dnf5.go index 35c9f53..5c0eab0 100644 --- a/dnf/parse_dnf5.go +++ b/dnf/parse_dnf5.go @@ -188,6 +188,39 @@ func parseGroupListDNF5(output string) []string { return groups } +// parseGroupIsInstalledDNF5 checks whether a named group is installed from +// `dnf5 group list` tabular output. A group is installed when its status field is "yes". +func parseGroupIsInstalledDNF5(output, group string) bool { + output = stripPreamble(output) + 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 + } + parts := strings.Fields(trimmed) + if len(parts) < 3 { + continue + } + status := parts[len(parts)-1] + if status != "yes" && status != "no" { + continue + } + name := strings.Join(parts[1:len(parts)-1], " ") + if strings.EqualFold(name, group) { + return status == "yes" + } + } + return false +} + // parseVersionLockDNF5 parses `dnf5 versionlock list` output. // Format: // diff --git a/dnf/parse_test.go b/dnf/parse_test.go index c9dcfa3..8e9dfa6 100644 --- a/dnf/parse_test.go +++ b/dnf/parse_test.go @@ -186,6 +186,43 @@ func TestParseGroupInfo(t *testing.T) { } } +func TestParseGroupIsInstalled(t *testing.T) { + input := `Available Groups: + Container Management + Development Tools + Headless Management +Installed Groups: + Minimal Install + Server +` + tests := []struct { + group string + want bool + }{ + {"Minimal Install", true}, + {"Server", true}, + {"Development Tools", false}, + {"Container Management", false}, + {"Nonexistent Group", false}, + } + for _, tt := range tests { + got := parseGroupIsInstalled(input, tt.group) + if got != tt.want { + t.Errorf("parseGroupIsInstalled(%q) = %v, want %v", tt.group, got, tt.want) + } + } + + // Verify that empty lines within the Installed section don't stop parsing. + inputWithBlankLines := `Installed Groups: + First Group + + Second Group +` + if !parseGroupIsInstalled(inputWithBlankLines, "Second Group") { + t.Error("parseGroupIsInstalled: should find group after blank line in installed section") + } +} + func TestNormalizeName(t *testing.T) { tests := []struct { input, want string @@ -338,6 +375,27 @@ kde-desktop KDE n } } +func TestParseGroupIsInstalledDNF5(t *testing.T) { + input := `ID Name Installed +neuron-modelling-simulators Neuron Modelling Simulators no +kde-desktop KDE yes +` + tests := []struct { + group string + want bool + }{ + {"KDE", true}, + {"Neuron Modelling Simulators", false}, + {"Nonexistent Group", false}, + } + for _, tt := range tests { + got := parseGroupIsInstalledDNF5(input, tt.group) + if got != tt.want { + t.Errorf("parseGroupIsInstalledDNF5(%q) = %v, want %v", tt.group, got, tt.want) + } + } +} + func TestParseGroupInfoDNF5(t *testing.T) { input := `Id : kde-desktop Name : KDE diff --git a/pacman/capabilities.go b/pacman/capabilities.go index cd71012..213c0bd 100644 --- a/pacman/capabilities.go +++ b/pacman/capabilities.go @@ -78,3 +78,8 @@ func (p *Pacman) GroupInstall(ctx context.Context, group string, opts ...snack.O defer p.Unlock() return groupInstall(ctx, group, opts...) } + +// GroupIsInstalled reports whether all packages in the group are installed. +func (p *Pacman) GroupIsInstalled(ctx context.Context, group string) (bool, error) { + return groupIsInstalled(ctx, group) +} diff --git a/pacman/capabilities_linux.go b/pacman/capabilities_linux.go index 3a35c89..b8cdedb 100644 --- a/pacman/capabilities_linux.go +++ b/pacman/capabilities_linux.go @@ -219,3 +219,50 @@ func groupInstall(ctx context.Context, group string, opts ...snack.Option) error _, err := run(ctx, []string{"-S", "--noconfirm", group}, o) return err } + +// parseGroupPkgSet parses "group pkg" lines and returns the set of package names. +func parseGroupPkgSet(output string) map[string]struct{} { + set := make(map[string]struct{}) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) >= 2 { + set[parts[1]] = struct{}{} + } + } + return set +} + +func groupIsInstalled(ctx context.Context, group string) (bool, error) { + // Get all packages in the group from the sync database. + availOut, err := run(ctx, []string{"-Sg", group}, snack.Options{}) + if err != nil { + if strings.Contains(err.Error(), "exit status 1") { + return false, fmt.Errorf("pacman groupIsInstalled %s: %w", group, snack.ErrNotFound) + } + return false, fmt.Errorf("pacman groupIsInstalled: %w", err) + } + avail := parseGroupPkgSet(availOut) + if len(avail) == 0 { + return false, fmt.Errorf("pacman groupIsInstalled %s: %w", group, snack.ErrNotFound) + } + // Get packages from the group that are installed (local database). + instOut, err := run(ctx, []string{"-Qg", group}, snack.Options{}) + if err != nil { + // exit status 1 means nothing from this group is installed. + if strings.Contains(err.Error(), "exit status 1") { + return false, nil + } + return false, fmt.Errorf("pacman groupIsInstalled: %w", err) + } + inst := parseGroupPkgSet(instOut) + for pkg := range avail { + if _, ok := inst[pkg]; !ok { + return false, nil + } + } + return true, nil +} diff --git a/pacman/capabilities_other.go b/pacman/capabilities_other.go index 08831a2..8a61c16 100644 --- a/pacman/capabilities_other.go +++ b/pacman/capabilities_other.go @@ -51,3 +51,7 @@ func groupInfo(_ context.Context, _ string) ([]snack.Package, error) { func groupInstall(_ context.Context, _ string, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } + +func groupIsInstalled(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} diff --git a/snack.go b/snack.go index 9dae405..10ba124 100644 --- a/snack.go +++ b/snack.go @@ -186,6 +186,9 @@ type Grouper interface { // GroupInstall installs all packages in a group. GroupInstall(ctx context.Context, group string, opts ...Option) error + + // GroupIsInstalled reports whether all packages in the group are installed. + GroupIsInstalled(ctx context.Context, group string) (bool, error) } // NormalizeName provides package name normalization.