Add GroupIsInstalled to Grouper interface with pacman and dnf implementations

Co-authored-by: taigrr <8261498+taigrr@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-28 06:23:21 +00:00
parent da5b1eb5db
commit dec1516387
10 changed files with 190 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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 <group>` output.
// Packages are listed under section headers like "Mandatory Packages:", "Default Packages:", "Optional Packages:".
func parseGroupInfo(output string) []snack.Package {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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