diff --git a/apk/apk_test.go b/apk/apk_test.go index 33b2e81..384fbf8 100644 --- a/apk/apk_test.go +++ b/apk/apk_test.go @@ -17,13 +17,23 @@ func TestSplitNameVersion(t *testing.T) { {"libcurl-doc-8.5.0-r0", "libcurl-doc", "8.5.0-r0"}, {"go-1.21.5-r0", "go", "1.21.5-r0"}, {"noversion", "noversion", ""}, + // Edge cases + {"", "", ""}, + {"a-1", "a", "1"}, + {"my-pkg-name-0.1-r0", "my-pkg-name", "0.1-r0"}, + {"a-b-c-3.0", "a-b-c", "3.0"}, + {"single", "single", ""}, + {"-1.0", "-1.0", ""}, // no digit follows last hyphen at position > 0 + {"pkg-0", "pkg", "0"}, } for _, tt := range tests { - name, ver := splitNameVersion(tt.input) - if name != tt.name || ver != tt.version { - t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)", - tt.input, name, ver, tt.name, tt.version) - } + t.Run(tt.input, func(t *testing.T) { + name, ver := splitNameVersion(tt.input) + if name != tt.name || ver != tt.version { + t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)", + tt.input, name, ver, tt.name, tt.version) + } + }) } } @@ -46,6 +56,111 @@ musl-1.2.4-r2 x86_64 {musl} (MIT) [installed] } } +func TestParseListInstalledEdgeCases(t *testing.T) { + t.Run("empty input", func(t *testing.T) { + pkgs := parseListInstalled("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("whitespace only", func(t *testing.T) { + pkgs := parseListInstalled(" \n \n ") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("single package", func(t *testing.T) { + pkgs := parseListInstalled("busybox-1.36.1-r5 x86_64 {busybox} (GPL-2.0-only) [installed]\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "busybox" || pkgs[0].Version != "1.36.1-r5" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } + }) + + t.Run("not installed", func(t *testing.T) { + pkgs := parseListInstalled("curl-8.5.0-r0 x86_64 {curl} (MIT)\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Installed { + t.Error("expected Installed=false") + } + }) +} + +func TestParseListLine(t *testing.T) { + tests := []struct { + name string + line string + wantName string + wantVer string + wantArch string + installed bool + }{ + { + name: "full line", + line: "curl-8.5.0-r0 x86_64 {curl} (MIT) [installed]", + wantName: "curl", + wantVer: "8.5.0-r0", + wantArch: "x86_64", + installed: true, + }, + { + name: "no installed marker", + line: "vim-9.0-r0 x86_64 {vim} (Vim)", + wantName: "vim", + wantVer: "9.0-r0", + wantArch: "x86_64", + installed: false, + }, + { + name: "name only", + line: "curl-8.5.0-r0", + wantName: "curl", + wantVer: "8.5.0-r0", + wantArch: "", + installed: false, + }, + { + name: "empty line", + line: "", + wantName: "", + wantVer: "", + wantArch: "", + installed: false, + }, + { + name: "aarch64 arch", + line: "openssl-3.1.4-r0 aarch64 {openssl} (Apache-2.0) [installed]", + wantName: "openssl", + wantVer: "3.1.4-r0", + wantArch: "aarch64", + installed: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := parseListLine(tt.line) + if pkg.Name != tt.wantName { + t.Errorf("Name = %q, want %q", pkg.Name, tt.wantName) + } + if pkg.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", pkg.Version, tt.wantVer) + } + if pkg.Arch != tt.wantArch { + t.Errorf("Arch = %q, want %q", pkg.Arch, tt.wantArch) + } + if pkg.Installed != tt.installed { + t.Errorf("Installed = %v, want %v", pkg.Installed, tt.installed) + } + }) + } +} + func TestParseSearch(t *testing.T) { // verbose output output := `curl-8.5.0-r0 - URL retrieval utility and library @@ -76,6 +191,51 @@ curl-doc-8.5.0-r0 } } +func TestParseSearchEdgeCases(t *testing.T) { + t.Run("empty input", func(t *testing.T) { + pkgs := parseSearch("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("single result verbose", func(t *testing.T) { + pkgs := parseSearch("nginx-1.24.0-r0 - HTTP and reverse proxy server\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "nginx" { + t.Errorf("expected nginx, got %q", pkgs[0].Name) + } + if pkgs[0].Description != "HTTP and reverse proxy server" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } + }) + + t.Run("single result plain", func(t *testing.T) { + pkgs := parseSearch("nginx-1.24.0-r0\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "nginx" || pkgs[0].Version != "1.24.0-r0" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } + if pkgs[0].Description != "" { + t.Errorf("expected empty description, got %q", pkgs[0].Description) + } + }) + + t.Run("description with hyphens", func(t *testing.T) { + pkgs := parseSearch("git-2.43.0-r0 - Distributed version control system - fast\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "Distributed version control system - fast" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } + }) +} + func TestParseInfo(t *testing.T) { output := `curl-8.5.0-r0 installed size: description: URL retrieval utility and library @@ -94,11 +254,72 @@ webpage: https://curl.se/ } } +func TestParseInfoEdgeCases(t *testing.T) { + t.Run("empty input", func(t *testing.T) { + pkg := parseInfo("") + if pkg == nil { + t.Fatal("expected non-nil (parseInfo always returns a pkg)") + } + }) + + t.Run("no description", func(t *testing.T) { + pkg := parseInfo("arch: aarch64\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Arch != "aarch64" { + t.Errorf("expected aarch64, got %q", pkg.Arch) + } + if pkg.Description != "" { + t.Errorf("expected empty description, got %q", pkg.Description) + } + }) + + t.Run("multiple colons in value", func(t *testing.T) { + pkg := parseInfo("description: A tool: does things: really well\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + // Note: strings.Cut splits on first colon only + if pkg.Description != "A tool: does things: really well" { + t.Errorf("unexpected description: %q", pkg.Description) + } + }) +} + func TestParseInfoNameVersion(t *testing.T) { - output := "curl-8.5.0-r0 description:\nsome stuff" - name, ver := parseInfoNameVersion(output) - if name != "curl" || ver != "8.5.0-r0" { - t.Errorf("got (%q, %q), want (curl, 8.5.0-r0)", name, ver) + tests := []struct { + name string + input string + wantN string + wantV string + }{ + { + name: "standard", + input: "curl-8.5.0-r0 description:\nsome stuff", + wantN: "curl", + wantV: "8.5.0-r0", + }, + { + name: "single line no version", + input: "noversion", + wantN: "noversion", + wantV: "", + }, + { + name: "multi-hyphen name", + input: "lib-ssl-dev-3.0.0-r0 some text", + wantN: "lib-ssl-dev", + wantV: "3.0.0-r0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, ver := parseInfoNameVersion(tt.input) + if name != tt.wantN || ver != tt.wantV { + t.Errorf("got (%q, %q), want (%q, %q)", name, ver, tt.wantN, tt.wantV) + } + }) } } @@ -112,3 +333,148 @@ func TestName(t *testing.T) { t.Errorf("expected apk, got %q", a.Name()) } } + +// Compile-time interface compliance checks +var ( + _ snack.VersionQuerier = (*Apk)(nil) + _ snack.Cleaner = (*Apk)(nil) + _ snack.FileOwner = (*Apk)(nil) + _ snack.DryRunner = (*Apk)(nil) + _ snack.PackageUpgrader = (*Apk)(nil) +) + +func TestInterfaceCompliance(t *testing.T) { + // Verify at test time as well + var m snack.Manager = New() + if _, ok := m.(snack.VersionQuerier); !ok { + t.Error("Apk should implement VersionQuerier") + } + if _, ok := m.(snack.Cleaner); !ok { + t.Error("Apk should implement Cleaner") + } + if _, ok := m.(snack.FileOwner); !ok { + t.Error("Apk should implement FileOwner") + } + if _, ok := m.(snack.DryRunner); !ok { + t.Error("Apk should implement DryRunner") + } + if _, ok := m.(snack.PackageUpgrader); !ok { + t.Error("Apk should implement PackageUpgrader") + } +} + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + if !caps.VersionQuery { + t.Error("expected VersionQuery=true") + } + if !caps.Clean { + t.Error("expected Clean=true") + } + if !caps.FileOwnership { + t.Error("expected FileOwnership=true") + } + if !caps.DryRun { + t.Error("expected DryRun=true") + } + // Should be false + if caps.Hold { + t.Error("expected Hold=false") + } + if caps.RepoManagement { + t.Error("expected RepoManagement=false") + } + if caps.KeyManagement { + t.Error("expected KeyManagement=false") + } + if caps.Groups { + t.Error("expected Groups=false") + } + if caps.NameNormalize { + t.Error("expected NameNormalize=false") + } +} + +func TestSupportsDryRun(t *testing.T) { + a := New() + if !a.SupportsDryRun() { + t.Error("SupportsDryRun() should return true") + } +} + +func TestParseUpgradeSimulation(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty", + input: "", + wantLen: 0, + }, + { + name: "OK only", + input: "OK: 123 MiB in 45 packages\n", + wantLen: 0, + }, + { + name: "single upgrade", + input: "(1/1) Upgrading curl (8.5.0-r0 -> 8.6.0-r0)\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "curl", Version: "8.6.0-r0", Installed: true}, + }, + }, + { + name: "multiple upgrades", + input: `(1/3) Upgrading musl (1.2.4-r2 -> 1.2.5-r0) +(2/3) Upgrading openssl (3.1.4-r0 -> 3.2.0-r0) +(3/3) Upgrading curl (8.5.0-r0 -> 8.6.0-r0) +OK: 123 MiB in 45 packages +`, + wantLen: 3, + wantPkgs: []snack.Package{ + {Name: "musl", Version: "1.2.5-r0", Installed: true}, + {Name: "openssl", Version: "3.2.0-r0", Installed: true}, + {Name: "curl", Version: "8.6.0-r0", Installed: true}, + }, + }, + { + name: "non-upgrade lines only", + input: "Purging old package\nInstalling new-pkg\n", + wantLen: 0, + }, + { + name: "upgrade without version parens", + input: "(1/1) Upgrading busybox\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "busybox", Version: "", Installed: true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseUpgradeSimulation(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + if pkgs[i].Name != want.Name { + t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, want.Name) + } + if pkgs[i].Version != want.Version { + t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, want.Version) + } + if pkgs[i].Installed != want.Installed { + t.Errorf("pkg[%d].Installed = %v, want %v", i, pkgs[i].Installed, want.Installed) + } + } + }) + } +} diff --git a/apt/apt_test.go b/apt/apt_test.go index 254f1f9..0a84f7b 100644 --- a/apt/apt_test.go +++ b/apt/apt_test.go @@ -67,5 +67,49 @@ func TestNew(t *testing.T) { } } -// Verify Apt implements snack.Manager at compile time. -var _ snack.Manager = (*Apt)(nil) +func TestSupportsDryRun(t *testing.T) { + a := New() + if !a.SupportsDryRun() { + t.Error("expected SupportsDryRun() = true") + } +} + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + checks := []struct { + name string + got bool + want bool + }{ + {"VersionQuery", caps.VersionQuery, true}, + {"Hold", caps.Hold, true}, + {"Clean", caps.Clean, true}, + {"FileOwnership", caps.FileOwnership, true}, + {"RepoManagement", caps.RepoManagement, true}, + {"KeyManagement", caps.KeyManagement, true}, + {"Groups", caps.Groups, false}, + {"NameNormalize", caps.NameNormalize, true}, + {"DryRun", caps.DryRun, true}, + } + for _, c := range checks { + t.Run(c.name, func(t *testing.T) { + if c.got != c.want { + t.Errorf("Capabilities.%s = %v, want %v", c.name, c.got, c.want) + } + }) + } +} + +// Compile-time interface checks. +var ( + _ snack.Manager = (*Apt)(nil) + _ snack.VersionQuerier = (*Apt)(nil) + _ snack.Holder = (*Apt)(nil) + _ snack.Cleaner = (*Apt)(nil) + _ snack.FileOwner = (*Apt)(nil) + _ snack.RepoManager = (*Apt)(nil) + _ snack.KeyManager = (*Apt)(nil) + _ snack.NameNormalizer = (*Apt)(nil) + _ snack.DryRunner = (*Apt)(nil) + _ snack.PackageUpgrader = (*Apt)(nil) +) diff --git a/apt/capabilities_linux.go b/apt/capabilities_linux.go index ae73d44..9ff59c5 100644 --- a/apt/capabilities_linux.go +++ b/apt/capabilities_linux.go @@ -23,17 +23,11 @@ func latestVersion(ctx context.Context, pkg string) (string, error) { if err != nil { return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) } - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Candidate:") { - candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:")) - if candidate == "(none)" { - return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) - } - return candidate, nil - } + candidate := parsePolicyCandidate(string(out)) + if candidate == "" { + return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) } - return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) + return candidate, nil } func listUpgrades(ctx context.Context) ([]snack.Package, error) { @@ -45,38 +39,7 @@ func listUpgrades(ctx context.Context) ([]snack.Package, error) { if err != nil { return nil, fmt.Errorf("apt-get --just-print upgrade: %w", err) } - var pkgs []snack.Package - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - // Lines starting with "Inst " indicate upgradable packages. - // Format: "Inst pkg [old-ver] (new-ver repo [arch])" - if !strings.HasPrefix(line, "Inst ") { - continue - } - line = strings.TrimPrefix(line, "Inst ") - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - name := fields[0] - // Find the new version in parentheses - parenStart := strings.Index(line, "(") - parenEnd := strings.Index(line, ")") - if parenStart < 0 || parenEnd < 0 { - continue - } - verFields := strings.Fields(line[parenStart+1 : parenEnd]) - if len(verFields) < 1 { - continue - } - p := snack.Package{ - Name: name, - Version: verFields[0], - Installed: true, - } - pkgs = append(pkgs, p) - } - return pkgs, nil + return parseUpgradeSimulation(string(out)), nil } func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { @@ -85,19 +48,12 @@ func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { if err != nil { return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) } - var installed, candidate string - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Installed:") { - installed = strings.TrimSpace(strings.TrimPrefix(line, "Installed:")) - } else if strings.HasPrefix(line, "Candidate:") { - candidate = strings.TrimSpace(strings.TrimPrefix(line, "Candidate:")) - } - } - if installed == "(none)" || installed == "" { + installed := parsePolicyInstalled(string(out)) + candidate := parsePolicyCandidate(string(out)) + if installed == "" { return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled) } - if candidate == "(none)" || candidate == "" || candidate == installed { + if candidate == "" || candidate == installed { return false, nil } return true, nil @@ -148,15 +104,7 @@ func listHeld(ctx context.Context) ([]snack.Package, error) { if err != nil { return nil, fmt.Errorf("apt-mark showhold: %w", err) } - var pkgs []snack.Package - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - pkgs = append(pkgs, snack.Package{Name: line, Installed: true}) - } - return pkgs, nil + return parseHoldList(string(out)), nil } func isHeld(ctx context.Context, pkg string) (bool, error) { @@ -198,14 +146,7 @@ func fileList(ctx context.Context, pkg string) ([]string, error) { } return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg) } - var files []string - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - line = strings.TrimSpace(line) - if line != "" { - files = append(files, line) - } - } - return files, nil + return parseFileList(string(out)), nil } func owner(ctx context.Context, path string) (string, error) { @@ -216,18 +157,11 @@ func owner(ctx context.Context, path string) (string, error) { if err != nil { return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound) } - // Output format: "package: /path/to/file" or "package1, package2: /path" - line := strings.TrimSpace(strings.Split(string(out), "\n")[0]) - colonIdx := strings.Index(line, ":") - if colonIdx < 0 { + pkg := parseOwner(string(out)) + if pkg == "" { return "", fmt.Errorf("dpkg -S %s: unexpected output", path) } - // Return first package if multiple - pkgPart := line[:colonIdx] - if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 { - pkgPart = strings.TrimSpace(pkgPart[:commaIdx]) - } - return strings.TrimSpace(pkgPart), nil + return pkg, nil } // --- RepoManager --- @@ -249,51 +183,14 @@ func listRepos(_ context.Context) ([]snack.Repository, error) { } scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - enabled := true - // deb822 format (.sources files) not fully parsed; treat as single entry - if strings.HasPrefix(line, "deb ") || strings.HasPrefix(line, "deb-src ") { - repos = append(repos, snack.Repository{ - ID: line, - URL: extractURL(line), - Enabled: enabled, - Type: strings.Fields(line)[0], - }) + if r := parseSourcesLine(scanner.Text()); r != nil { + repos = append(repos, *r) } } } return repos, nil } -// extractURL pulls the URL from a deb/deb-src line. -func extractURL(line string) string { - fields := strings.Fields(line) - inOptions := false - for i, f := range fields { - if i == 0 { - continue // skip deb/deb-src - } - if inOptions { - if strings.HasSuffix(f, "]") { - inOptions = false - } - continue - } - if strings.HasPrefix(f, "[") { - if strings.HasSuffix(f, "]") { - // Single-token options like [arch=amd64] - continue - } - inOptions = true - continue - } - return f - } - return "" -} func addRepo(ctx context.Context, repo snack.Repository) error { repoLine := repo.URL diff --git a/apt/helpers_linux_test.go b/apt/helpers_linux_test.go index b1626b9..3918298 100644 --- a/apt/helpers_linux_test.go +++ b/apt/helpers_linux_test.go @@ -150,50 +150,3 @@ func TestBuildArgs(t *testing.T) { }) } } - -func TestExtractURL(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "basic_deb", - input: "deb http://archive.ubuntu.com/ubuntu/ jammy main", - want: "http://archive.ubuntu.com/ubuntu/", - }, - { - name: "deb_src", - input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main", - want: "http://archive.ubuntu.com/ubuntu/", - }, - { - name: "with_options", - input: "deb [arch=amd64] https://apt.example.com/repo stable main", - want: "https://apt.example.com/repo", - }, - { - name: "with_signed_by", - input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main", - want: "https://repo.example.com/deb", - }, - { - name: "just_type", - input: "deb", - want: "", - }, - { - name: "empty_options_bracket", - input: "deb [] http://example.com/repo stable", - want: "http://example.com/repo", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractURL(tt.input) - if got != tt.want { - t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} diff --git a/apt/parse.go b/apt/parse.go index 090f8f0..756c2e8 100644 --- a/apt/parse.go +++ b/apt/parse.go @@ -51,6 +51,158 @@ func parseSearch(output string) []snack.Package { return pkgs } +// parsePolicyCandidate extracts the Candidate version from apt-cache policy output. +// Returns empty string if no candidate is found or candidate is "(none)". +func parsePolicyCandidate(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Candidate:") { + candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:")) + if candidate == "(none)" { + return "" + } + return candidate + } + } + return "" +} + +// parsePolicyInstalled extracts the Installed version from apt-cache policy output. +// Returns empty string if not installed or "(none)". +func parsePolicyInstalled(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Installed:") { + installed := strings.TrimSpace(strings.TrimPrefix(line, "Installed:")) + if installed == "(none)" { + return "" + } + return installed + } + } + return "" +} + +// parseUpgradeSimulation parses apt-get --just-print upgrade output. +// Lines starting with "Inst " indicate upgradable packages. +// Format: "Inst pkg [old-ver] (new-ver repo [arch])" +func parseUpgradeSimulation(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "Inst ") { + continue + } + line = strings.TrimPrefix(line, "Inst ") + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + name := fields[0] + parenStart := strings.Index(line, "(") + parenEnd := strings.Index(line, ")") + if parenStart < 0 || parenEnd < 0 { + continue + } + verFields := strings.Fields(line[parenStart+1 : parenEnd]) + if len(verFields) < 1 { + continue + } + pkgs = append(pkgs, snack.Package{ + Name: name, + Version: verFields[0], + Installed: true, + }) + } + return pkgs +} + +// parseHoldList parses apt-mark showhold output (one package name per line). +func parseHoldList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pkgs = append(pkgs, snack.Package{Name: line, Installed: true}) + } + return pkgs +} + +// parseFileList parses dpkg-query -L output (one file path per line). +func parseFileList(output string) []string { + var files []string + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files +} + +// parseOwner parses dpkg -S output to extract the owning package name. +// Output format: "package: /path/to/file" or "pkg1, pkg2: /path". +// Returns the first package name. +func parseOwner(output string) string { + line := strings.TrimSpace(strings.Split(output, "\n")[0]) + colonIdx := strings.Index(line, ":") + if colonIdx < 0 { + return "" + } + pkgPart := line[:colonIdx] + if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 { + pkgPart = strings.TrimSpace(pkgPart[:commaIdx]) + } + return strings.TrimSpace(pkgPart) +} + +// parseSourcesLine parses a single deb/deb-src line from sources.list. +// Returns a Repository if the line is valid, or nil if it's a comment/blank. +func parseSourcesLine(line string) *snack.Repository { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return nil + } + if !strings.HasPrefix(line, "deb ") && !strings.HasPrefix(line, "deb-src ") { + return nil + } + return &snack.Repository{ + ID: line, + URL: extractURL(line), + Enabled: true, + Type: strings.Fields(line)[0], + } +} + +// extractURL pulls the URL from a deb/deb-src line. +func extractURL(line string) string { + fields := strings.Fields(line) + inOptions := false + for i, f := range fields { + if i == 0 { + continue // skip deb/deb-src + } + if inOptions { + if strings.HasSuffix(f, "]") { + inOptions = false + } + continue + } + if strings.HasPrefix(f, "[") { + if strings.HasSuffix(f, "]") { + // Single-token options like [arch=amd64] + continue + } + inOptions = true + continue + } + return f + } + return "" +} + // parseInfo parses apt-cache show output into a Package. func parseInfo(output string) (*snack.Package, error) { p := &snack.Package{} diff --git a/apt/parse_test.go b/apt/parse_test.go index 42c40cd..9bee887 100644 --- a/apt/parse_test.go +++ b/apt/parse_test.go @@ -194,3 +194,445 @@ func TestParseInfo_EdgeCases(t *testing.T) { } }) } + +// --- New parse function tests --- + +func TestParsePolicyCandidate(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "normal_policy_output", + input: `bash: + Installed: 5.2-1 + Candidate: 5.2-2 + Version table: + 5.2-2 500 + 500 http://archive.ubuntu.com/ubuntu jammy/main amd64 Packages + *** 5.2-1 100 + 100 /var/lib/dpkg/status`, + want: "5.2-2", + }, + { + name: "candidate_none", + input: `virtual-pkg: + Installed: (none) + Candidate: (none) + Version table:`, + want: "", + }, + { + name: "empty_input", + input: "", + want: "", + }, + { + name: "installed_equals_candidate", + input: `curl: + Installed: 7.88.1-10+deb12u4 + Candidate: 7.88.1-10+deb12u4 + Version table: + *** 7.88.1-10+deb12u4 500`, + want: "7.88.1-10+deb12u4", + }, + { + name: "epoch_version", + input: `systemd: + Installed: 1:252-2 + Candidate: 1:252-3 + Version table:`, + want: "1:252-3", + }, + { + name: "no_candidate_line", + input: "bash:\n Installed: 5.2-1\n", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePolicyCandidate(tt.input) + if got != tt.want { + t.Errorf("parsePolicyCandidate() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParsePolicyInstalled(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "normal", + input: `bash: + Installed: 5.2-1 + Candidate: 5.2-2`, + want: "5.2-1", + }, + { + name: "not_installed", + input: `foo: + Installed: (none) + Candidate: 1.0`, + want: "", + }, + { + name: "empty", + input: "", + want: "", + }, + { + name: "epoch_version", + input: `systemd: + Installed: 1:252-2 + Candidate: 1:252-3`, + want: "1:252-2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePolicyInstalled(tt.input) + if got != tt.want { + t.Errorf("parsePolicyInstalled() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseUpgradeSimulation(t *testing.T) { + tests := []struct { + name string + input string + want []snack.Package + }{ + { + name: "empty", + input: "", + want: nil, + }, + { + name: "single_upgrade", + input: `Reading package lists... +Building dependency tree... +Reading state information... +The following packages will be upgraded: + bash +1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. +Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64]) +Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])`, + want: []snack.Package{ + {Name: "bash", Version: "5.2-2", Installed: true}, + }, + }, + { + name: "multiple_upgrades", + input: `Inst bash [5.2-1] (5.2-2 Ubuntu:22.04/jammy [amd64]) +Inst curl [7.88.0] (7.88.1 Ubuntu:22.04/jammy [amd64]) +Inst systemd [1:252-1] (1:252-2 Ubuntu:22.04/jammy [amd64]) +Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64]) +Conf curl (7.88.1 Ubuntu:22.04/jammy [amd64]) +Conf systemd (1:252-2 Ubuntu:22.04/jammy [amd64])`, + want: []snack.Package{ + {Name: "bash", Version: "5.2-2", Installed: true}, + {Name: "curl", Version: "7.88.1", Installed: true}, + {Name: "systemd", Version: "1:252-2", Installed: true}, + }, + }, + { + name: "no_inst_lines", + input: "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed.\n", + want: nil, + }, + { + name: "inst_without_parens", + input: "Inst bash no-parens-here\n", + want: nil, + }, + { + name: "inst_with_empty_parens", + input: "Inst bash [5.2-1] ()\n", + want: nil, + }, + { + name: "conf_lines_ignored", + input: "Conf bash (5.2-2 Ubuntu:22.04/jammy [amd64])\n", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseUpgradeSimulation(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("parseUpgradeSimulation() returned %d packages, want %d", len(got), len(tt.want)) + } + for i, g := range got { + w := tt.want[i] + if g.Name != w.Name || g.Version != w.Version || g.Installed != w.Installed { + t.Errorf("package[%d] = %+v, want %+v", i, g, w) + } + } + }) + } +} + +func TestParseHoldList(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty", "", nil}, + {"whitespace_only", " \n \n", nil}, + {"single_package", "bash\n", []string{"bash"}}, + {"multiple_packages", "bash\ncurl\nnginx\n", []string{"bash", "curl", "nginx"}}, + {"blank_lines_mixed", "\nbash\n\ncurl\n\n", []string{"bash", "curl"}}, + {"trailing_whitespace", " bash \n curl \n", []string{"bash", "curl"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseHoldList(tt.input) + var names []string + for _, p := range pkgs { + names = append(names, p.Name) + if !p.Installed { + t.Errorf("expected Installed=true for %q", p.Name) + } + } + if len(names) != len(tt.want) { + t.Fatalf("got %d packages, want %d", len(names), len(tt.want)) + } + for i, n := range names { + if n != tt.want[i] { + t.Errorf("package[%d] = %q, want %q", i, n, tt.want[i]) + } + } + }) + } +} + +func TestParseFileList(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty", "", nil}, + {"whitespace_only", " \n\n ", nil}, + { + name: "single_file", + input: "/usr/bin/bash\n", + want: []string{"/usr/bin/bash"}, + }, + { + name: "multiple_files", + input: "/.\n/usr\n/usr/bin\n/usr/bin/bash\n/usr/share/man/man1/bash.1.gz\n", + want: []string{"/.", "/usr", "/usr/bin", "/usr/bin/bash", "/usr/share/man/man1/bash.1.gz"}, + }, + { + name: "blank_lines_mixed", + input: "\n/usr/bin/curl\n\n/usr/share/doc/curl\n\n", + want: []string{"/usr/bin/curl", "/usr/share/doc/curl"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseFileList(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("parseFileList() returned %d files, want %d", len(got), len(tt.want)) + } + for i, f := range got { + if f != tt.want[i] { + t.Errorf("file[%d] = %q, want %q", i, f, tt.want[i]) + } + } + }) + } +} + +func TestParseOwner(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "single_package", + input: "bash: /usr/bin/bash\n", + want: "bash", + }, + { + name: "multiple_packages", + input: "bash, dash: /usr/bin/sh\n", + want: "bash", + }, + { + name: "package_with_arch", + input: "libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6\n", + want: "libc6", // parseOwner splits on first colon, arch suffix is stripped + }, + { + name: "multiple_lines", + input: "coreutils: /usr/bin/ls\ncoreutils: /usr/bin/cat\n", + want: "coreutils", + }, + { + name: "no_colon", + input: "unexpected output without colon", + want: "", + }, + { + name: "empty", + input: "", + want: "", + }, + { + name: "whitespace_around_package", + input: " nginx : /usr/sbin/nginx\n", + want: "nginx", + }, + { + name: "three_packages_comma_separated", + input: "pkg1, pkg2, pkg3: /some/path\n", + want: "pkg1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseOwner(tt.input) + if got != tt.want { + t.Errorf("parseOwner() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseSourcesLine(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + wantURL string + wantTyp string + }{ + { + name: "empty", + input: "", + wantNil: true, + }, + { + name: "comment", + input: "# This is a comment", + wantNil: true, + }, + { + name: "non_deb_line", + input: "some random text", + wantNil: true, + }, + { + name: "basic_deb", + input: "deb http://archive.ubuntu.com/ubuntu/ jammy main", + wantURL: "http://archive.ubuntu.com/ubuntu/", + wantTyp: "deb", + }, + { + name: "deb_src", + input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main", + wantURL: "http://archive.ubuntu.com/ubuntu/", + wantTyp: "deb-src", + }, + { + name: "with_options", + input: "deb [arch=amd64] https://repo.example.com/deb stable main", + wantURL: "https://repo.example.com/deb", + wantTyp: "deb", + }, + { + name: "with_signed_by", + input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com stable main", + wantURL: "https://repo.example.com", + wantTyp: "deb", + }, + { + name: "leading_whitespace", + input: " deb http://example.com/repo stable main", + wantURL: "http://example.com/repo", + wantTyp: "deb", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := parseSourcesLine(tt.input) + if tt.wantNil { + if r != nil { + t.Errorf("parseSourcesLine() = %+v, want nil", r) + } + return + } + if r == nil { + t.Fatal("parseSourcesLine() = nil, want non-nil") + } + if r.URL != tt.wantURL { + t.Errorf("URL = %q, want %q", r.URL, tt.wantURL) + } + if r.Type != tt.wantTyp { + t.Errorf("Type = %q, want %q", r.Type, tt.wantTyp) + } + if !r.Enabled { + t.Error("expected Enabled=true") + } + }) + } +} + +func TestExtractURL(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "basic_deb", + input: "deb http://archive.ubuntu.com/ubuntu/ jammy main", + want: "http://archive.ubuntu.com/ubuntu/", + }, + { + name: "deb_src", + input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main", + want: "http://archive.ubuntu.com/ubuntu/", + }, + { + name: "with_options", + input: "deb [arch=amd64] https://apt.example.com/repo stable main", + want: "https://apt.example.com/repo", + }, + { + name: "with_signed_by", + input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main", + want: "https://repo.example.com/deb", + }, + { + name: "just_type", + input: "deb", + want: "", + }, + { + name: "empty_options_bracket", + input: "deb [] http://example.com/repo stable", + want: "http://example.com/repo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractURL(tt.input) + if got != tt.want { + t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/aur/aur_test.go b/aur/aur_test.go index 2c2cd91..f576a2a 100644 --- a/aur/aur_test.go +++ b/aur/aur_test.go @@ -3,7 +3,9 @@ package aur import ( "testing" + "github.com/gogrlx/snack" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParsePackageList(t *testing.T) { @@ -63,3 +65,141 @@ func TestNewWithOptions(t *testing.T) { assert.Equal(t, "/tmp/aur-builds", a.BuildDir) assert.Equal(t, []string{"--skippgpcheck", "--nocheck"}, a.MakepkgFlags) } + +func TestInterfaceCompliance(t *testing.T) { + var _ snack.Manager = (*AUR)(nil) + var _ snack.VersionQuerier = (*AUR)(nil) + var _ snack.Cleaner = (*AUR)(nil) + var _ snack.PackageUpgrader = (*AUR)(nil) +} + +func TestInterfaceNonCompliance(t *testing.T) { + a := New() + var m snack.Manager = a + + if _, ok := m.(snack.FileOwner); ok { + t.Error("AUR should not implement FileOwner") + } + if _, ok := m.(snack.Holder); ok { + t.Error("AUR should not implement Holder") + } + if _, ok := m.(snack.RepoManager); ok { + t.Error("AUR should not implement RepoManager") + } + if _, ok := m.(snack.KeyManager); ok { + t.Error("AUR should not implement KeyManager") + } + if _, ok := m.(snack.Grouper); ok { + t.Error("AUR should not implement Grouper") + } + if _, ok := m.(snack.NameNormalizer); ok { + t.Error("AUR should not implement NameNormalizer") + } + if _, ok := m.(snack.DryRunner); ok { + t.Error("AUR should not implement DryRunner") + } +} + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + + tests := []struct { + name string + got bool + want bool + }{ + {"VersionQuery", caps.VersionQuery, true}, + {"Clean", caps.Clean, true}, + {"FileOwnership", caps.FileOwnership, false}, + {"Hold", caps.Hold, false}, + {"RepoManagement", caps.RepoManagement, false}, + {"KeyManagement", caps.KeyManagement, false}, + {"Groups", caps.Groups, false}, + {"NameNormalize", caps.NameNormalize, false}, + {"DryRun", caps.DryRun, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want) + } + }) + } +} + +func TestName(t *testing.T) { + a := New() + assert.Equal(t, "aur", a.Name()) +} + +func TestParsePackageList_EdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantNames []string + wantVers []string + }{ + { + name: "empty string", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n\t\n \n", + wantLen: 0, + }, + { + name: "single package", + input: "yay 12.5.7-1\n", + wantLen: 1, + wantNames: []string{"yay"}, + wantVers: []string{"12.5.7-1"}, + }, + { + name: "malformed single field", + input: "orphan\n", + wantLen: 0, + }, + { + name: "malformed mixed with valid", + input: "orphan\nyay 12.5.7-1\nbadline\nparu 2.0-1\n", + wantLen: 2, + wantNames: []string{"yay", "paru"}, + wantVers: []string{"12.5.7-1", "2.0-1"}, + }, + { + name: "extra fields ignored", + input: "yay 12.5.7-1 extra stuff\n", + wantLen: 1, + wantNames: []string{"yay"}, + wantVers: []string{"12.5.7-1"}, + }, + { + name: "trailing and leading whitespace on lines", + input: " yay 12.5.7-1 \n paru 2.0.4-1\n\n", + wantLen: 2, + wantNames: []string{"yay", "paru"}, + wantVers: []string{"12.5.7-1", "2.0.4-1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parsePackageList(tt.input) + require.Len(t, pkgs, tt.wantLen) + for i, p := range pkgs { + assert.Equal(t, "aur", p.Repository, "all packages should have Repository=aur") + assert.True(t, p.Installed, "all packages should have Installed=true") + if i < len(tt.wantNames) { + assert.Equal(t, tt.wantNames[i], p.Name) + } + if i < len(tt.wantVers) { + assert.Equal(t, tt.wantVers[i], p.Version) + } + } + }) + } +} diff --git a/dnf/dnf_test.go b/dnf/dnf_test.go new file mode 100644 index 0000000..aca96d9 --- /dev/null +++ b/dnf/dnf_test.go @@ -0,0 +1,86 @@ +package dnf + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +// Compile-time interface assertions — DNF implements all optional interfaces. +var ( + _ snack.Manager = (*DNF)(nil) + _ snack.VersionQuerier = (*DNF)(nil) + _ snack.Holder = (*DNF)(nil) + _ snack.Cleaner = (*DNF)(nil) + _ snack.FileOwner = (*DNF)(nil) + _ snack.RepoManager = (*DNF)(nil) + _ snack.KeyManager = (*DNF)(nil) + _ snack.Grouper = (*DNF)(nil) + _ snack.NameNormalizer = (*DNF)(nil) + _ snack.DryRunner = (*DNF)(nil) + _ snack.PackageUpgrader = (*DNF)(nil) +) + +func TestName(t *testing.T) { + d := New() + if got := d.Name(); got != "dnf" { + t.Errorf("Name() = %q, want %q", got, "dnf") + } +} + +func TestSupportsDryRun(t *testing.T) { + d := New() + if !d.SupportsDryRun() { + t.Error("SupportsDryRun() = false, want true") + } +} + +func TestGetCapabilities(t *testing.T) { + d := New() + caps := snack.GetCapabilities(d) + + tests := []struct { + name string + got bool + }{ + {"VersionQuery", caps.VersionQuery}, + {"Hold", caps.Hold}, + {"Clean", caps.Clean}, + {"FileOwnership", caps.FileOwnership}, + {"RepoManagement", caps.RepoManagement}, + {"KeyManagement", caps.KeyManagement}, + {"Groups", caps.Groups}, + {"NameNormalize", caps.NameNormalize}, + {"DryRun", caps.DryRun}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.got { + t.Errorf("Capabilities.%s = false, want true", tt.name) + } + }) + } +} + +func TestNormalizeNameMethod(t *testing.T) { + d := New() + tests := []struct { + input, want string + }{ + {"nginx.x86_64", "nginx"}, + {"curl", "curl"}, + } + for _, tt := range tests { + if got := d.NormalizeName(tt.input); got != tt.want { + t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseArchMethod(t *testing.T) { + d := New() + name, arch := d.ParseArch("nginx.x86_64") + if name != "nginx" || arch != "x86_64" { + t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch) + } +} diff --git a/dnf/parse_test.go b/dnf/parse_test.go index 8e9dfa6..4127c1a 100644 --- a/dnf/parse_test.go +++ b/dnf/parse_test.go @@ -3,8 +3,6 @@ package dnf import ( "strings" "testing" - - "github.com/gogrlx/snack" ) func TestParseList(t *testing.T) { @@ -458,15 +456,399 @@ updates-testing Fedora 43 - x86_64 - Test Updates } } -// Ensure interface checks from capabilities.go are satisfied. -var ( - _ snack.Manager = (*DNF)(nil) - _ snack.VersionQuerier = (*DNF)(nil) - _ snack.Holder = (*DNF)(nil) - _ snack.Cleaner = (*DNF)(nil) - _ snack.FileOwner = (*DNF)(nil) - _ snack.RepoManager = (*DNF)(nil) - _ snack.KeyManager = (*DNF)(nil) - _ snack.Grouper = (*DNF)(nil) - _ snack.NameNormalizer = (*DNF)(nil) -) +// --- Edge case tests --- + +func TestParseListEmpty(t *testing.T) { + pkgs := parseList("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseListSinglePackage(t *testing.T) { + input := `Installed Packages +curl.x86_64 7.76.1-23.el9 @baseos +` + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "curl" { + t.Errorf("Name = %q, want curl", pkgs[0].Name) + } +} + +func TestParseListMalformedLines(t *testing.T) { + input := `Installed Packages +curl.x86_64 7.76.1-23.el9 @baseos +thislinehasnospaces + only-one-field +bash.x86_64 5.1.8-6.el9 @anaconda +` + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs)) + } +} + +func TestParseListNoHeader(t *testing.T) { + // Lines that look like packages without the "Installed Packages" header + input := `curl.x86_64 7.76.1-23.el9 @baseos +bash.x86_64 5.1.8-6.el9 @anaconda +` + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } +} + +func TestParseListTwoColumns(t *testing.T) { + // Only name.arch and version, no repo column + input := `Installed Packages +curl.x86_64 7.76.1-23.el9 +` + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Repository != "" { + t.Errorf("Repository = %q, want empty", pkgs[0].Repository) + } +} + +func TestParseSearchEmpty(t *testing.T) { + pkgs := parseSearch("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseSearchSingleResult(t *testing.T) { + input := `=== Name Exactly Matched: curl === +curl.x86_64 : A utility for getting files from remote servers +` + pkgs := parseSearch(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "curl" { + t.Errorf("Name = %q, want curl", pkgs[0].Name) + } +} + +func TestParseSearchMalformedLines(t *testing.T) { + input := `=== Name Matched === +curl.x86_64 : A utility +no-separator-here +another.line.without : proper : colons +bash.noarch : Shell +` + pkgs := parseSearch(input) + // "curl.x86_64 : A utility" and "another.line.without : proper : colons" and "bash.noarch : Shell" + if len(pkgs) != 3 { + t.Fatalf("expected 3 packages, got %d", len(pkgs)) + } +} + +func TestParseInfoEmpty(t *testing.T) { + p := parseInfo("") + if p != nil { + t.Errorf("expected nil from empty input, got %+v", p) + } +} + +func TestParseInfoNoName(t *testing.T) { + input := `Version : 1.0 +Architecture : x86_64 +` + p := parseInfo(input) + if p != nil { + t.Errorf("expected nil when no Name field, got %+v", p) + } +} + +func TestParseInfoReleaseBeforeVersion(t *testing.T) { + // Release without prior Version should not panic + input := `Name : test +Release : 1.el9 +Version : 2.0 +` + p := parseInfo(input) + if p == nil { + t.Fatal("expected non-nil package") + } + // Release came before Version was set, so it won't append properly, + // but Version should at least be set + if p.Name != "test" { + t.Errorf("Name = %q, want test", p.Name) + } +} + +func TestParseInfoFromRepo(t *testing.T) { + input := `Name : bash +Version : 5.1.8 +Release : 6.el9 +From repo : baseos +Summary : The GNU Bourne Again shell +` + p := parseInfo(input) + if p == nil { + t.Fatal("expected non-nil package") + } + if p.Repository != "baseos" { + t.Errorf("Repository = %q, want baseos", p.Repository) + } +} + +func TestParseVersionLockEmpty(t *testing.T) { + pkgs := parseVersionLock("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseVersionLockSingleEntry(t *testing.T) { + input := `nginx-0:1.20.1-14.el9_2.1.* +` + pkgs := parseVersionLock(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "nginx" { + t.Errorf("Name = %q, want nginx", pkgs[0].Name) + } +} + +func TestParseRepoListEmpty(t *testing.T) { + repos := parseRepoList("") + if len(repos) != 0 { + t.Errorf("expected 0 repos from empty input, got %d", len(repos)) + } +} + +func TestParseRepoListSingleRepo(t *testing.T) { + input := `repo id repo name status +baseos CentOS Stream 9 - BaseOS enabled +` + repos := parseRepoList(input) + if len(repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(repos)) + } + if repos[0].ID != "baseos" || !repos[0].Enabled { + t.Errorf("unexpected repo: %+v", repos[0]) + } +} + +func TestParseGroupListEmpty(t *testing.T) { + groups := parseGroupList("") + if len(groups) != 0 { + t.Errorf("expected 0 groups from empty input, got %d", len(groups)) + } +} + +func TestParseGroupInfoEmpty(t *testing.T) { + pkgs := parseGroupInfo("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseGroupInfoWithMarks(t *testing.T) { + input := `Group: Web Server + Mandatory Packages: + = httpd + + mod_ssl + - php +` + pkgs := parseGroupInfo(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{"httpd", "mod_ssl", "php"} { + if !names[want] { + t.Errorf("missing package %q", want) + } + } +} + +func TestParseGroupIsInstalledEmpty(t *testing.T) { + if parseGroupIsInstalled("", "anything") { + t.Error("expected false for empty input") + } +} + +func TestNormalizeNameEdgeCases(t *testing.T) { + tests := []struct { + input, want string + }{ + {"", ""}, + {"pkg.unknown.ext", "pkg.unknown.ext"}, + {"name.with.dots.x86_64", "name.with.dots"}, + {"python3.11", "python3.11"}, + {"glibc.s390x", "glibc"}, + {"kernel.src", "kernel"}, + {".x86_64", ""}, + {"pkg.ppc64le", "pkg"}, + {"pkg.armv7hl", "pkg"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeName(tt.input) + if got != tt.want { + t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseArchEdgeCases(t *testing.T) { + tests := []struct { + input, wantName, wantArch string + }{ + {"", "", ""}, + {"pkg.i386", "pkg", "i386"}, + {"pkg.ppc64le", "pkg", "ppc64le"}, + {"pkg.s390x", "pkg", "s390x"}, + {"pkg.armv7hl", "pkg", "armv7hl"}, + {"pkg.src", "pkg", "src"}, + {"pkg.unknown", "pkg.unknown", ""}, + {"name.with.many.dots.noarch", "name.with.many.dots", "noarch"}, + {".noarch", "", "noarch"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, arch := parseArch(tt.input) + if name != tt.wantName || arch != tt.wantArch { + t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)", + tt.input, name, arch, tt.wantName, tt.wantArch) + } + }) + } +} + +// --- dnf5 edge case tests --- + +func TestStripPreambleEmpty(t *testing.T) { + got := stripPreamble("") + if got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestStripPreambleNoPreamble(t *testing.T) { + input := "Installed packages\nbash.x86_64 5.3.0-2.fc43 abc\n" + got := stripPreamble(input) + if got != input { + t.Errorf("expected unchanged output when no preamble present") + } +} + +func TestParseListDNF5Empty(t *testing.T) { + pkgs := parseListDNF5("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseListDNF5SinglePackage(t *testing.T) { + input := `Installed packages +curl.aarch64 7.76.1-23.el9 abc123 +` + pkgs := parseListDNF5(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "curl" || pkgs[0].Arch != "aarch64" { + t.Errorf("unexpected: %+v", pkgs[0]) + } +} + +func TestParseSearchDNF5Empty(t *testing.T) { + pkgs := parseSearchDNF5("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseInfoDNF5Empty(t *testing.T) { + p := parseInfoDNF5("") + if p != nil { + t.Errorf("expected nil from empty input, got %+v", p) + } +} + +func TestParseInfoDNF5NoName(t *testing.T) { + input := `Version : 1.0 +Architecture : x86_64 +` + p := parseInfoDNF5(input) + if p != nil { + t.Errorf("expected nil when no Name field, got %+v", p) + } +} + +func TestParseGroupListDNF5Empty(t *testing.T) { + groups := parseGroupListDNF5("") + if len(groups) != 0 { + t.Errorf("expected 0 groups from empty input, got %d", len(groups)) + } +} + +func TestParseGroupIsInstalledDNF5Empty(t *testing.T) { + if parseGroupIsInstalledDNF5("", "anything") { + t.Error("expected false for empty input") + } +} + +func TestParseVersionLockDNF5Empty(t *testing.T) { + pkgs := parseVersionLockDNF5("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseVersionLockDNF5SingleEntry(t *testing.T) { + input := `# Added by 'versionlock add' command on 2026-02-26 03:14:29 +Package name: nginx +evr = 1.20.1-14.el9 +` + pkgs := parseVersionLockDNF5(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "nginx" { + t.Errorf("Name = %q, want nginx", pkgs[0].Name) + } +} + +func TestParseRepoListDNF5Empty(t *testing.T) { + repos := parseRepoListDNF5("") + if len(repos) != 0 { + t.Errorf("expected 0 repos from empty input, got %d", len(repos)) + } +} + +func TestParseGroupInfoDNF5Empty(t *testing.T) { + pkgs := parseGroupInfoDNF5("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseGroupInfoDNF5SinglePackage(t *testing.T) { + input := `Id : test-group +Name : Test +Mandatory packages : single-pkg +` + pkgs := parseGroupInfoDNF5(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "single-pkg" { + t.Errorf("Name = %q, want single-pkg", pkgs[0].Name) + } +} diff --git a/flatpak/flatpak_test.go b/flatpak/flatpak_test.go index 0305dad..de73f82 100644 --- a/flatpak/flatpak_test.go +++ b/flatpak/flatpak_test.go @@ -37,6 +37,71 @@ func TestParseListEmpty(t *testing.T) { } } +func TestParseListEdgeCases(t *testing.T) { + t.Run("single entry", func(t *testing.T) { + pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "Firefox" { + t.Errorf("expected Firefox, got %q", pkgs[0].Name) + } + }) + + t.Run("two fields only", func(t *testing.T) { + pkgs := parseList("Firefox\torg.mozilla.Firefox\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "Firefox" { + t.Errorf("expected Firefox, got %q", pkgs[0].Name) + } + if pkgs[0].Version != "" { + t.Errorf("expected empty version, got %q", pkgs[0].Version) + } + if pkgs[0].Repository != "" { + t.Errorf("expected empty repository, got %q", pkgs[0].Repository) + } + }) + + t.Run("single field skipped", func(t *testing.T) { + pkgs := parseList("Firefox\n") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs)) + } + }) + + t.Run("extra fields", func(t *testing.T) { + pkgs := parseList("Firefox\torg.mozilla.Firefox\t131.0\tflathub\textra\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Repository != "flathub" { + t.Errorf("expected flathub, got %q", pkgs[0].Repository) + } + }) + + t.Run("whitespace only lines", func(t *testing.T) { + pkgs := parseList(" \n\t\n \n") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("three fields no repo", func(t *testing.T) { + pkgs := parseList("GIMP\torg.gimp.GIMP\t2.10.38\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Version != "2.10.38" { + t.Errorf("expected version 2.10.38, got %q", pkgs[0].Version) + } + if pkgs[0].Repository != "" { + t.Errorf("expected empty repository, got %q", pkgs[0].Repository) + } + }) +} + func TestParseSearch(t *testing.T) { input := "Firefox\torg.mozilla.Firefox\t131.0\tflathub\n" + "Firefox ESR\torg.mozilla.FirefoxESR\t128.3\tflathub\n" @@ -60,6 +125,32 @@ func TestParseSearchNoMatches(t *testing.T) { } } +func TestParseSearchEdgeCases(t *testing.T) { + t.Run("empty", func(t *testing.T) { + pkgs := parseSearch("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("single field line skipped", func(t *testing.T) { + pkgs := parseSearch("JustAName\n") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages (needs >=2 fields), got %d", len(pkgs)) + } + }) + + t.Run("not installed result", func(t *testing.T) { + pkgs := parseSearch("VLC\torg.videolan.VLC\t3.0.20\tflathub\n") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Installed { + t.Error("search results should not be marked installed") + } + }) +} + func TestParseInfo(t *testing.T) { input := `Name: Firefox Description: Fast, private web browser @@ -92,6 +183,46 @@ func TestParseInfoEmpty(t *testing.T) { } } +func TestParseInfoEdgeCases(t *testing.T) { + t.Run("name only", func(t *testing.T) { + pkg := parseInfo("Name: VLC\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Name != "VLC" { + t.Errorf("expected VLC, got %q", pkg.Name) + } + }) + + t.Run("no name returns nil", func(t *testing.T) { + pkg := parseInfo("Version: 1.0\nArch: x86_64\n") + if pkg != nil { + t.Error("expected nil when no Name field") + } + }) + + t.Run("no colon lines ignored", func(t *testing.T) { + pkg := parseInfo("Name: Test\nsome random line without colon\nVersion: 2.0\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Version != "2.0" { + t.Errorf("expected version 2.0, got %q", pkg.Version) + } + }) + + t.Run("value with colons", func(t *testing.T) { + pkg := parseInfo("Name: MyApp\nDescription: A tool: does things: well\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + // parseInfo uses strings.Index for first colon + if pkg.Description != "A tool: does things: well" { + t.Errorf("unexpected description: %q", pkg.Description) + } + }) +} + func TestParseRemotes(t *testing.T) { input := "flathub\thttps://dl.flathub.org/repo/\t\n" + "gnome-nightly\thttps://nightly.gnome.org/repo/\tdisabled\n" @@ -113,10 +244,162 @@ func TestParseRemotes(t *testing.T) { } } +func TestParseRemotesEdgeCases(t *testing.T) { + t.Run("empty", func(t *testing.T) { + repos := parseRemotes("") + if len(repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(repos)) + } + }) + + t.Run("single enabled remote", func(t *testing.T) { + repos := parseRemotes("flathub\thttps://dl.flathub.org/repo/\t\n") + if len(repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(repos)) + } + if !repos[0].Enabled { + t.Error("expected enabled") + } + if repos[0].Name != "flathub" { + t.Errorf("expected Name=flathub, got %q", repos[0].Name) + } + }) + + t.Run("single disabled remote", func(t *testing.T) { + repos := parseRemotes("test-remote\thttps://example.com/\tdisabled\n") + if len(repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(repos)) + } + if repos[0].Enabled { + t.Error("expected disabled") + } + }) + + t.Run("no URL field", func(t *testing.T) { + repos := parseRemotes("myremote\n") + if len(repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(repos)) + } + if repos[0].ID != "myremote" { + t.Errorf("expected myremote, got %q", repos[0].ID) + } + if repos[0].URL != "" { + t.Errorf("expected empty URL, got %q", repos[0].URL) + } + if !repos[0].Enabled { + t.Error("expected enabled by default") + } + }) + + t.Run("whitespace lines ignored", func(t *testing.T) { + repos := parseRemotes(" \n\n \n") + if len(repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(repos)) + } + }) +} + +func TestSemverCmp(t *testing.T) { + tests := []struct { + name string + a, b string + want int + }{ + {"equal", "1.0.0", "1.0.0", 0}, + {"less major", "1.0.0", "2.0.0", -1}, + {"greater major", "2.0.0", "1.0.0", 1}, + {"less minor", "1.2.3", "1.3.0", -1}, + {"less patch", "1.2.3", "1.2.4", -1}, + {"multi-digit", "1.10.0", "1.9.0", 1}, + {"short vs long equal", "1.0", "1.0.0", 0}, + {"short vs long less", "1.0", "1.0.1", -1}, + {"short vs long greater", "1.1", "1.0.9", 1}, + {"single component", "5", "3", 1}, + {"single equal", "3", "3", 0}, + {"empty vs empty", "", "", 0}, + {"empty vs version", "", "1.0", -1}, + {"version vs empty", "1.0", "", 1}, + {"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0}, + {"pre-release stripped", "1.0.0beta2", "1.0.0rc1", 0}, + {"four components", "1.2.3.4", "1.2.3.5", -1}, + {"different lengths", "1.0.0.0", "1.0.0", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := semverCmp(tt.a, tt.b) + if got != tt.want { + t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestStripNonNumeric(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"123", "123"}, + {"123abc", "123"}, + {"abc", ""}, + {"0beta", "0"}, + {"", ""}, + {"42-rc1", "42"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stripNonNumeric(tt.input) + if got != tt.want { + t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Flatpak)(nil) var _ snack.Cleaner = (*Flatpak)(nil) var _ snack.RepoManager = (*Flatpak)(nil) + var _ snack.VersionQuerier = (*Flatpak)(nil) + var _ snack.PackageUpgrader = (*Flatpak)(nil) +} + +// Compile-time interface checks in test file +var ( + _ snack.VersionQuerier = (*Flatpak)(nil) + _ snack.PackageUpgrader = (*Flatpak)(nil) +) + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + if !caps.Clean { + t.Error("expected Clean=true") + } + if !caps.RepoManagement { + t.Error("expected RepoManagement=true") + } + if !caps.VersionQuery { + t.Error("expected VersionQuery=true") + } + // Should be false + if caps.FileOwnership { + t.Error("expected FileOwnership=false") + } + if caps.DryRun { + t.Error("expected DryRun=false") + } + if caps.Hold { + t.Error("expected Hold=false") + } + if caps.KeyManagement { + t.Error("expected KeyManagement=false") + } + if caps.Groups { + t.Error("expected Groups=false") + } + if caps.NameNormalize { + t.Error("expected NameNormalize=false") + } } func TestName(t *testing.T) { diff --git a/pacman/helpers_test.go b/pacman/helpers_test.go index 5273186..dd04331 100644 --- a/pacman/helpers_test.go +++ b/pacman/helpers_test.go @@ -79,36 +79,88 @@ func TestBuildArgs_RootBeforeBaseArgs(t *testing.T) { assert.Greater(t, sIdx, rIdx, "root flag should come before base args") } -func TestParseUpgrades_Empty(t *testing.T) { - assert.Empty(t, parseUpgrades("")) -} +func TestParseUpgrades(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantNames []string + wantVers []string + }{ + { + name: "empty", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n \n\n\t\n", + wantLen: 0, + }, + { + name: "standard arrow format", + input: "linux 6.7.3.arch1-1 -> 6.7.4.arch1-1\nvim 9.0.2-1 -> 9.1.0-1\n", + wantLen: 2, + wantNames: []string{"linux", "vim"}, + wantVers: []string{"6.7.4.arch1-1", "9.1.0-1"}, + }, + { + name: "single package arrow format", + input: "curl 8.6.0-1 -> 8.7.1-1\n", + wantLen: 1, + wantNames: []string{"curl"}, + wantVers: []string{"8.7.1-1"}, + }, + { + name: "fallback two-field format", + input: "pkg 2.0\n", + wantLen: 1, + wantNames: []string{"pkg"}, + wantVers: []string{"2.0"}, + }, + { + name: "mixed arrow and fallback", + input: "linux 6.7.3 -> 6.7.4\npkg 2.0\n", + wantLen: 2, + wantNames: []string{"linux", "pkg"}, + wantVers: []string{"6.7.4", "2.0"}, + }, + { + name: "whitespace around entries", + input: "\n \nlinux 6.7.3 -> 6.7.4\n\n", + wantLen: 1, + wantNames: []string{"linux"}, + wantVers: []string{"6.7.4"}, + }, + { + name: "single field line skipped", + input: "orphan\nvalid 1.0 -> 2.0\n", + wantLen: 1, + }, + { + name: "epoch in version", + input: "java-runtime 1:21.0.2-1 -> 1:21.0.3-1\n", + wantLen: 1, + wantNames: []string{"java-runtime"}, + wantVers: []string{"1:21.0.3-1"}, + }, + } -func TestParseUpgrades_Standard(t *testing.T) { - input := `linux 6.7.3.arch1-1 -> 6.7.4.arch1-1 -vim 9.0.2-1 -> 9.1.0-1 -` - pkgs := parseUpgrades(input) - require.Len(t, pkgs, 2) - assert.Equal(t, "linux", pkgs[0].Name) - assert.Equal(t, "6.7.4.arch1-1", pkgs[0].Version) - assert.True(t, pkgs[0].Installed) - assert.Equal(t, "vim", pkgs[1].Name) - assert.Equal(t, "9.1.0-1", pkgs[1].Version) -} - -func TestParseUpgrades_FallbackFormat(t *testing.T) { - // Some versions of pacman might output "pkg newver" without the arrow - input := "pkg 2.0\n" - pkgs := parseUpgrades(input) - require.Len(t, pkgs, 1) - assert.Equal(t, "pkg", pkgs[0].Name) - assert.Equal(t, "2.0", pkgs[0].Version) -} - -func TestParseUpgrades_WhitespaceLines(t *testing.T) { - input := "\n \nlinux 6.7.3 -> 6.7.4\n\n" - pkgs := parseUpgrades(input) - require.Len(t, pkgs, 1) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseUpgrades(tt.input) + require.Len(t, pkgs, tt.wantLen) + for i, p := range pkgs { + assert.True(t, p.Installed, "all upgrade entries should have Installed=true") + if i < len(tt.wantNames) { + assert.Equal(t, tt.wantNames[i], p.Name) + } + if i < len(tt.wantVers) { + assert.Equal(t, tt.wantVers[i], p.Version) + } + } + }) + } } func TestParseGroupPkgSet_Empty(t *testing.T) { @@ -149,6 +201,31 @@ group pkg2 assert.Len(t, set, 2) } +func TestParseGroupPkgSet_WhitespaceOnly(t *testing.T) { + set := parseGroupPkgSet(" \n \n\t\n") + assert.Empty(t, set) +} + +func TestParseGroupPkgSet_MultipleGroups(t *testing.T) { + // Different group names, same package names — set uses pkg name (second field) + input := `base-devel gcc +xorg xorg-server +base-devel gcc +` + set := parseGroupPkgSet(input) + assert.Len(t, set, 2) + assert.Contains(t, set, "gcc") + assert.Contains(t, set, "xorg-server") +} + +func TestParseGroupPkgSet_ExtraFields(t *testing.T) { + // Lines with more than 2 fields — should still use second field + input := "group pkg extra stuff\n" + set := parseGroupPkgSet(input) + assert.Len(t, set, 1) + assert.Contains(t, set, "pkg") +} + func TestNew(t *testing.T) { p := New() assert.NotNil(t, p) diff --git a/pacman/pacman_test.go b/pacman/pacman_test.go index 86d83a5..f892c21 100644 --- a/pacman/pacman_test.go +++ b/pacman/pacman_test.go @@ -130,6 +130,58 @@ func TestBuildArgs(t *testing.T) { func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Pacman)(nil) + var _ snack.VersionQuerier = (*Pacman)(nil) + var _ snack.Cleaner = (*Pacman)(nil) + var _ snack.FileOwner = (*Pacman)(nil) + var _ snack.Grouper = (*Pacman)(nil) + var _ snack.DryRunner = (*Pacman)(nil) + var _ snack.PackageUpgrader = (*Pacman)(nil) +} + +func TestInterfaceNonCompliance(t *testing.T) { + p := New() + var m snack.Manager = p + + if _, ok := m.(snack.Holder); ok { + t.Error("Pacman should not implement Holder") + } + if _, ok := m.(snack.RepoManager); ok { + t.Error("Pacman should not implement RepoManager") + } + if _, ok := m.(snack.KeyManager); ok { + t.Error("Pacman should not implement KeyManager") + } + if _, ok := m.(snack.NameNormalizer); ok { + t.Error("Pacman should not implement NameNormalizer") + } +} + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + + tests := []struct { + name string + got bool + want bool + }{ + {"VersionQuery", caps.VersionQuery, true}, + {"Clean", caps.Clean, true}, + {"FileOwnership", caps.FileOwnership, true}, + {"Groups", caps.Groups, true}, + {"DryRun", caps.DryRun, true}, + {"Hold", caps.Hold, false}, + {"RepoManagement", caps.RepoManagement, false}, + {"KeyManagement", caps.KeyManagement, false}, + {"NameNormalize", caps.NameNormalize, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.want) + } + }) + } } func TestName(t *testing.T) { diff --git a/pkg/pkg_test.go b/pkg/pkg_test.go index 6311e09..e677dfb 100644 --- a/pkg/pkg_test.go +++ b/pkg/pkg_test.go @@ -23,6 +23,94 @@ func TestParseQuery(t *testing.T) { } } +func TestParseQueryEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n \n\n", + wantLen: 0, + }, + { + name: "single entry", + input: "vim\t9.0\tVi IMproved\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true}, + }, + }, + { + name: "missing description (two fields only)", + input: "bash\t5.2\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "bash", Version: "5.2", Description: "", Installed: true}, + }, + }, + { + name: "single field only (no tabs, skipped)", + input: "justname\n", + wantLen: 0, + }, + { + name: "description with tabs", + input: "pkg\t1.0\tA\ttabbed\tdescription\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "pkg", Version: "1.0", Description: "A\ttabbed\tdescription", Installed: true}, + }, + }, + { + name: "trailing and leading whitespace on lines", + input: " nginx\t1.24.0\tWeb server \n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "nginx", Version: "1.24.0", Description: "Web server", Installed: true}, + }, + }, + { + name: "multiple entries with blank lines between", + input: "a\t1.0\tAlpha\n\nb\t2.0\tBeta\n", + wantLen: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseQuery(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + if got.Description != want.Description { + t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description) + } + if got.Installed != want.Installed { + t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed) + } + } + }) + } +} + func TestParseSearch(t *testing.T) { input := `nginx-1.24.0 Robust and small WWW server curl-8.5.0 Command line tool for transferring data @@ -39,6 +127,81 @@ curl-8.5.0 Command line tool for transferring data } } +func TestParseSearchEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "name with many hyphens", + input: "py39-django-rest-framework-3.14.0 RESTful Web APIs for Django\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "py39-django-rest-framework", Version: "3.14.0", Description: "RESTful Web APIs for Django"}, + }, + }, + { + name: "no comment (name-version only)", + input: "zsh-5.9\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "zsh", Version: "5.9", Description: ""}, + }, + }, + { + name: "very long output many packages", + input: "a-1.0 desc1\nb-2.0 desc2\nc-3.0 desc3\nd-4.0 desc4\ne-5.0 desc5\n", + wantLen: 5, + }, + { + name: "single character name", + input: "R-4.3.2 Statistical Computing\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "R", Version: "4.3.2", Description: "Statistical Computing"}, + }, + }, + { + name: "version with complex suffix", + input: "libressl-3.8.2_1 TLS library\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "libressl", Version: "3.8.2_1", Description: "TLS library"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseSearch(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + if got.Description != want.Description { + t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description) + } + } + }) + } +} + func TestParseInfo(t *testing.T) { input := `Name : nginx Version : 1.24.0 @@ -63,6 +226,77 @@ Arch : FreeBSD:14:amd64 } } +func TestParseInfoEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + want *snack.Package + }{ + { + name: "empty input", + input: "", + wantNil: true, + }, + { + name: "no name field returns nil", + input: "Version : 1.0\nComment : test\n", + wantNil: true, + }, + { + name: "name only (missing other fields)", + input: "Name : bash\n", + want: &snack.Package{Name: "bash", Installed: true}, + }, + { + name: "extra unknown fields are ignored", + input: "Name : vim\nVersion : 9.0\nMaintainer : someone@example.com\nWWW : https://vim.org\nComment : Vi IMproved\n", + want: &snack.Package{Name: "vim", Version: "9.0", Description: "Vi IMproved", Installed: true}, + }, + { + name: "colon in value", + input: "Name : nginx\nComment : HTTP server: fast and reliable\n", + want: &snack.Package{Name: "nginx", Description: "HTTP server: fast and reliable", Installed: true}, + }, + { + name: "lines without colons are skipped", + input: "This is random text\nNo colons here\n", + wantNil: true, + }, + { + name: "whitespace around values", + input: "Name : curl \nVersion : 8.5.0 \n", + want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInfo(tt.input) + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil package") + } + if got.Name != tt.want.Name { + t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) + } + if got.Version != tt.want.Version { + t.Errorf("Version = %q, want %q", got.Version, tt.want.Version) + } + if got.Description != tt.want.Description { + t.Errorf("Description = %q, want %q", got.Description, tt.want.Description) + } + if got.Installed != tt.want.Installed { + t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed) + } + }) + } +} + func TestParseUpgrades(t *testing.T) { input := `Updating FreeBSD repository catalogue... The following 2 package(s) will be affected: @@ -84,6 +318,90 @@ Number of packages to be upgraded: 2 } } +func TestParseUpgradesEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "no upgrade lines", + input: "Updating FreeBSD repository catalogue...\nAll packages are up to date.\n", + wantLen: 0, + }, + { + name: "mix of Upgrading Installing Reinstalling", + input: `Upgrading nginx: 1.24.0 -> 1.26.0 +Installing newpkg: 0 -> 1.0.0 +Reinstalling bash: 5.2 -> 5.2 +`, + wantLen: 3, + wantPkgs: []snack.Package{ + {Name: "nginx", Version: "1.26.0", Installed: true}, + {Name: "newpkg", Version: "1.0.0", Installed: true}, + {Name: "bash", Version: "5.2", Installed: true}, + }, + }, + { + name: "line with -> but no recognized prefix is skipped", + input: "Something: 1.0 -> 2.0\n", + wantLen: 0, + }, + { + name: "upgrading line without colon is skipped", + input: "Upgrading nginx 1.24.0 -> 1.26.0\n", + wantLen: 0, + }, + { + name: "upgrading line with -> but not enough parts after colon", + input: "Upgrading nginx: -> \n", + wantLen: 0, + }, + { + name: "upgrading line with wrong arrow", + input: "Upgrading nginx: 1.24.0 => 1.26.0\n", + wantLen: 0, + }, + { + name: "single upgrade line", + input: "Upgrading zsh: 5.8 -> 5.9\n", + wantPkgs: []snack.Package{ + {Name: "zsh", Version: "5.9", Installed: true}, + }, + wantLen: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseUpgrades(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + if got.Installed != want.Installed { + t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed) + } + } + }) + } +} + func TestParseFileList(t *testing.T) { input := `nginx-1.24.0: /usr/local/sbin/nginx @@ -99,6 +417,67 @@ func TestParseFileList(t *testing.T) { } } +func TestParseFileListEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + want []string + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "header only no files", + input: "nginx-1.24.0:\n", + wantLen: 0, + }, + { + name: "paths with spaces", + input: "pkg-1.0:\n\t/usr/local/share/my package/file name.txt\n\t/usr/local/share/another dir/test\n", + wantLen: 2, + want: []string{ + "/usr/local/share/my package/file name.txt", + "/usr/local/share/another dir/test", + }, + }, + { + name: "single file", + input: "bash-5.2:\n\t/usr/local/bin/bash\n", + wantLen: 1, + want: []string{"/usr/local/bin/bash"}, + }, + { + name: "no header just file paths", + input: "/usr/local/bin/curl\n/usr/local/lib/libcurl.so\n", + wantLen: 2, + }, + { + name: "blank lines between files", + input: "pkg-1.0:\n\t/usr/local/bin/a\n\n\t/usr/local/bin/b\n", + wantLen: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files := parseFileList(tt.input) + if len(files) != tt.wantLen { + t.Fatalf("expected %d files, got %d", tt.wantLen, len(files)) + } + for i, w := range tt.want { + if i >= len(files) { + break + } + if files[i] != w { + t.Errorf("[%d] got %q, want %q", i, files[i], w) + } + } + }) + } +} + func TestParseOwner(t *testing.T) { input := "/usr/local/sbin/nginx was installed by package nginx-1.24.0\n" name := parseOwner(input) @@ -107,6 +486,48 @@ func TestParseOwner(t *testing.T) { } } +func TestParseOwnerEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "empty input", + input: "", + want: "", + }, + { + name: "standard format", + input: "/usr/local/bin/curl was installed by package curl-8.5.0\n", + want: "curl", + }, + { + name: "package name with hyphens", + input: "/usr/local/lib/libpython3.so was installed by package py39-python-3.9.18\n", + want: "py39-python", + }, + { + name: "no match returns trimmed input", + input: "some random output\n", + want: "some random output", + }, + { + name: "whitespace around", + input: " /usr/local/bin/bash was installed by package bash-5.2.21 \n", + want: "bash", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseOwner(tt.input) + if got != tt.want { + t.Errorf("parseOwner(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestSplitNameVersion(t *testing.T) { tests := []struct { input string @@ -126,6 +547,73 @@ func TestSplitNameVersion(t *testing.T) { } } +func TestSplitNameVersionEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantVersion string + }{ + { + name: "empty string", + input: "", + wantName: "", + wantVersion: "", + }, + { + name: "no hyphen", + input: "singleword", + wantName: "singleword", + wantVersion: "", + }, + { + name: "multiple hyphens", + input: "py39-django-rest-3.14.0", + wantName: "py39-django-rest", + wantVersion: "3.14.0", + }, + { + name: "leading hyphen", + input: "-1.0", + wantName: "-1.0", + wantVersion: "", + }, + { + name: "trailing hyphen", + input: "nginx-", + wantName: "nginx", + wantVersion: "", + }, + { + name: "only hyphen", + input: "-", + wantName: "-", + wantVersion: "", + }, + { + name: "hyphen at index 1", + input: "a-1.0", + wantName: "a", + wantVersion: "1.0", + }, + { + name: "version with underscore suffix", + input: "libressl-3.8.2_1", + wantName: "libressl", + wantVersion: "3.8.2_1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, ver := splitNameVersion(tt.input) + if name != tt.wantName || ver != tt.wantVersion { + t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)", + tt.input, name, ver, tt.wantName, tt.wantVersion) + } + }) + } +} + func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Pkg)(nil) var _ snack.VersionQuerier = (*Pkg)(nil) @@ -133,9 +621,48 @@ func TestInterfaceCompliance(t *testing.T) { var _ snack.FileOwner = (*Pkg)(nil) } +func TestPackageUpgraderInterface(t *testing.T) { + var _ snack.PackageUpgrader = (*Pkg)(nil) +} + func TestName(t *testing.T) { p := New() if p.Name() != "pkg" { t.Errorf("Name() = %q, want %q", p.Name(), "pkg") } } + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + + // Should be true + if !caps.VersionQuery { + t.Error("expected VersionQuery=true") + } + if !caps.Clean { + t.Error("expected Clean=true") + } + if !caps.FileOwnership { + t.Error("expected FileOwnership=true") + } + + // Should be false + if caps.Hold { + t.Error("expected Hold=false") + } + if caps.RepoManagement { + t.Error("expected RepoManagement=false") + } + if caps.KeyManagement { + t.Error("expected KeyManagement=false") + } + if caps.Groups { + t.Error("expected Groups=false") + } + if caps.NameNormalize { + t.Error("expected NameNormalize=false") + } + if caps.DryRun { + t.Error("expected DryRun=false") + } +} diff --git a/ports/ports_test.go b/ports/ports_test.go index 238c17c..221f89a 100644 --- a/ports/ports_test.go +++ b/ports/ports_test.go @@ -29,6 +29,93 @@ python-3.11.7p0 interpreted object-oriented programming language } } +func TestParseListEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n \n\n", + wantLen: 0, + }, + { + name: "single entry", + input: "vim-9.0.2100 Vi IMproved\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "vim", Version: "9.0.2100", Description: "Vi IMproved", Installed: true}, + }, + }, + { + name: "no description", + input: "vim-9.0.2100\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "vim", Version: "9.0.2100", Description: "", Installed: true}, + }, + }, + { + name: "empty lines between entries", + input: "bash-5.2 Shell\n\n\ncurl-8.5.0 Transfer tool\n", + wantLen: 2, + wantPkgs: []snack.Package{ + {Name: "bash", Version: "5.2", Description: "Shell", Installed: true}, + {Name: "curl", Version: "8.5.0", Description: "Transfer tool", Installed: true}, + }, + }, + { + name: "package with p-suffix version (OpenBSD style)", + input: "python-3.11.7p0 interpreted language\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "python", Version: "3.11.7p0", Description: "interpreted language", Installed: true}, + }, + }, + { + name: "package name with multiple hyphens", + input: "py3-django-rest-3.14.0 REST framework\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "py3-django-rest", Version: "3.14.0", Description: "REST framework", Installed: true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseList(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + if got.Description != want.Description { + t.Errorf("[%d] Description = %q, want %q", i, got.Description, want.Description) + } + if got.Installed != want.Installed { + t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed) + } + } + }) + } +} + func TestParseSearchResults(t *testing.T) { input := `nginx-1.24.0 nginx-1.25.3 @@ -42,6 +129,83 @@ nginx-1.25.3 } } +func TestParseSearchResultsEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n\n \n", + wantLen: 0, + }, + { + name: "single result", + input: "curl-8.5.0\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "curl", Version: "8.5.0"}, + }, + }, + { + name: "name with many hyphens", + input: "py3-django-rest-framework-3.14.0\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "py3-django-rest-framework", Version: "3.14.0"}, + }, + }, + { + name: "no version (no hyphen)", + input: "quirks\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "quirks", Version: ""}, + }, + }, + { + name: "p-suffix version", + input: "python-3.11.7p0\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "python", Version: "3.11.7p0"}, + }, + }, + { + name: "multiple results with blank lines", + input: "a-1.0\n\nb-2.0\n\nc-3.0\n", + wantLen: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseSearchResults(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + } + }) + } +} + func TestParseInfoOutput(t *testing.T) { input := `Information for nginx-1.24.0: @@ -82,6 +246,131 @@ curl is a tool to transfer data from or to a server. } } +func TestParseInfoOutputEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + pkgArg string + wantNil bool + want *snack.Package + }{ + { + name: "empty input and empty pkg arg", + input: "", + pkgArg: "", + wantNil: true, + }, + { + name: "empty input with pkg arg fallback", + input: "", + pkgArg: "curl-8.5.0", + want: &snack.Package{Name: "curl", Version: "8.5.0", Installed: true}, + }, + { + name: "no header, falls back to pkg arg", + input: "Some random output\nwithout the expected header", + pkgArg: "vim-9.0", + want: &snack.Package{Name: "vim", Version: "9.0", Installed: true}, + }, + { + name: "comment on same line as Comment: label", + input: `Information for zsh-5.9: + +Comment: Zsh shell +`, + pkgArg: "zsh-5.9", + want: &snack.Package{Name: "zsh", Version: "5.9", Description: "Zsh shell", Installed: true}, + }, + { + name: "comment on next line after Comment: label (not captured as description)", + input: `Information for bash-5.2: + +Comment: +GNU Bourne Again Shell +`, + pkgArg: "bash-5.2", + // Comment: with nothing after the colon sets Description="", + // and the next line isn't in a Description: block so it's ignored. + want: &snack.Package{Name: "bash", Version: "5.2", Description: "", Installed: true}, + }, + { + name: "description spans multiple lines", + input: `Information for git-2.43.0: + +Comment: distributed version control system +Description: +Git is a fast, scalable, distributed revision control system +with an unusually rich command set. +`, + pkgArg: "git-2.43.0", + want: &snack.Package{Name: "git", Version: "2.43.0", Description: "distributed version control system", Installed: true}, + }, + { + name: "extra fields are ignored", + input: `Information for tmux-3.3a: + +Comment: terminal multiplexer +Maintainer: someone@openbsd.org +WWW: https://tmux.github.io +Description: +tmux is a terminal multiplexer. +`, + pkgArg: "tmux-3.3a", + want: &snack.Package{Name: "tmux", Version: "3.3a", Description: "terminal multiplexer", Installed: true}, + }, + { + name: "pkg arg with no version (no hyphen)", + input: "", + pkgArg: "quirks", + want: &snack.Package{Name: "quirks", Version: "", Installed: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInfoOutput(tt.input, tt.pkgArg) + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil package") + } + if got.Name != tt.want.Name { + t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) + } + if got.Version != tt.want.Version { + t.Errorf("Version = %q, want %q", got.Version, tt.want.Version) + } + if tt.want.Description != "" && got.Description != tt.want.Description { + t.Errorf("Description = %q, want %q", got.Description, tt.want.Description) + } + if got.Installed != tt.want.Installed { + t.Errorf("Installed = %v, want %v", got.Installed, tt.want.Installed) + } + }) + } +} + +func TestParseInfoOutputEmpty(t *testing.T) { + pkg := parseInfoOutput("", "") + if pkg != nil { + t.Error("expected nil for empty input and empty pkg name") + } +} + +func TestParseInfoOutputFallbackToPkgArg(t *testing.T) { + input := "Some random output\nwithout the expected header" + pkg := parseInfoOutput(input, "curl-8.5.0") + if pkg == nil { + t.Fatal("expected non-nil package from fallback") + } + if pkg.Name != "curl" || pkg.Version != "8.5.0" { + t.Errorf("unexpected fallback parse: %+v", pkg) + } +} + func TestSplitNameVersion(t *testing.T) { tests := []struct { input string @@ -101,57 +390,76 @@ func TestSplitNameVersion(t *testing.T) { } } -func TestParseListEmpty(t *testing.T) { - pkgs := parseList("") - if len(pkgs) != 0 { - t.Fatalf("expected 0 packages, got %d", len(pkgs)) +func TestSplitNameVersionEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantVersion string + }{ + { + name: "empty string", + input: "", + wantName: "", + wantVersion: "", + }, + { + name: "no hyphen", + input: "singleword", + wantName: "singleword", + wantVersion: "", + }, + { + name: "multiple hyphens", + input: "py3-django-rest-3.14.0", + wantName: "py3-django-rest", + wantVersion: "3.14.0", + }, + { + name: "leading hyphen (idx=0, returns whole string)", + input: "-1.0", + wantName: "-1.0", + wantVersion: "", + }, + { + name: "trailing hyphen", + input: "nginx-", + wantName: "nginx", + wantVersion: "", + }, + { + name: "only hyphen", + input: "-", + wantName: "-", + wantVersion: "", + }, + { + name: "hyphen at index 1", + input: "a-1.0", + wantName: "a", + wantVersion: "1.0", + }, + { + name: "p-suffix version", + input: "python-3.11.7p0", + wantName: "python", + wantVersion: "3.11.7p0", + }, + { + name: "version with v prefix", + input: "go-v1.21.5", + wantName: "go", + wantVersion: "v1.21.5", + }, } -} - -func TestParseListWhitespaceOnly(t *testing.T) { - pkgs := parseList(" \n \n\n") - if len(pkgs) != 0 { - t.Fatalf("expected 0 packages, got %d", len(pkgs)) - } -} - -func TestParseListNoDescription(t *testing.T) { - input := "vim-9.0.2100\n" - pkgs := parseList(input) - if len(pkgs) != 1 { - t.Fatalf("expected 1 package, got %d", len(pkgs)) - } - if pkgs[0].Name != "vim" || pkgs[0].Version != "9.0.2100" { - t.Errorf("unexpected package: %+v", pkgs[0]) - } - if pkgs[0].Description != "" { - t.Errorf("expected empty description, got %q", pkgs[0].Description) - } -} - -func TestParseSearchResultsEmpty(t *testing.T) { - pkgs := parseSearchResults("") - if len(pkgs) != 0 { - t.Fatalf("expected 0 packages, got %d", len(pkgs)) - } -} - -func TestParseInfoOutputEmpty(t *testing.T) { - pkg := parseInfoOutput("", "") - if pkg != nil { - t.Error("expected nil for empty input and empty pkg name") - } -} - -func TestParseInfoOutputFallbackToPkgArg(t *testing.T) { - // No "Information for" header — should fall back to parsing the pkg argument. - input := "Some random output\nwithout the expected header" - pkg := parseInfoOutput(input, "curl-8.5.0") - if pkg == nil { - t.Fatal("expected non-nil package from fallback") - } - if pkg.Name != "curl" || pkg.Version != "8.5.0" { - t.Errorf("unexpected fallback parse: %+v", pkg) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, ver := splitNameVersion(tt.input) + if name != tt.wantName || ver != tt.wantVersion { + t.Errorf("splitNameVersion(%q) = (%q, %q), want (%q, %q)", + tt.input, name, ver, tt.wantName, tt.wantVersion) + } + }) } } @@ -163,14 +471,7 @@ func TestSplitNameVersionNoHyphen(t *testing.T) { } func TestSplitNameVersionLeadingHyphen(t *testing.T) { - // A hyphen at position 0 should return the whole string as name. name, ver := splitNameVersion("-1.0") - if name != "" || ver != "1.0" { - // LastIndex("-1.0", "-") is 0, and idx <= 0 returns (s, "") - // Actually idx=0 means the condition idx <= 0 is true - } - // Re-check: idx=0, condition is idx <= 0, so returns (s, "") - name, ver = splitNameVersion("-1.0") if name != "-1.0" || ver != "" { t.Errorf("splitNameVersion(\"-1.0\") = (%q, %q), want (\"-1.0\", \"\")", name, ver) } @@ -196,6 +497,91 @@ python-3.11.7p0 -> python-3.11.8p0 } } +func TestParseUpgradeOutputEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantPkgs []snack.Package + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n\n \n", + wantLen: 0, + }, + { + name: "single upgrade", + input: "bash-5.2 -> bash-5.3\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "bash", Version: "5.3", Installed: true}, + }, + }, + { + name: "line without -> is skipped", + input: "Some info line\nbash-5.2 -> bash-5.3\nAnother line\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "bash", Version: "5.3", Installed: true}, + }, + }, + { + name: "malformed line (-> but not enough fields)", + input: "-> bash-5.3\n", + wantLen: 0, + }, + { + name: "wrong arrow (=> instead of ->)", + input: "bash-5.2 => bash-5.3\n", + wantLen: 0, + }, + { + name: "package name with multiple hyphens", + input: "py3-django-rest-3.14.0 -> py3-django-rest-3.15.0\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "py3-django-rest", Version: "3.15.0", Installed: true}, + }, + }, + { + name: "p-suffix versions", + input: "python-3.11.7p0 -> python-3.11.8p1\n", + wantLen: 1, + wantPkgs: []snack.Package{ + {Name: "python", Version: "3.11.8p1", Installed: true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseUpgradeOutput(tt.input) + if len(pkgs) != tt.wantLen { + t.Fatalf("expected %d packages, got %d", tt.wantLen, len(pkgs)) + } + for i, want := range tt.wantPkgs { + if i >= len(pkgs) { + break + } + got := pkgs[i] + if got.Name != want.Name { + t.Errorf("[%d] Name = %q, want %q", i, got.Name, want.Name) + } + if got.Version != want.Version { + t.Errorf("[%d] Version = %q, want %q", i, got.Version, want.Version) + } + if got.Installed != want.Installed { + t.Errorf("[%d] Installed = %v, want %v", i, got.Installed, want.Installed) + } + } + }) + } +} + func TestParseUpgradeOutputEmpty(t *testing.T) { pkgs := parseUpgradeOutput("") if len(pkgs) != 0 { @@ -221,6 +607,73 @@ Files: } } +func TestParseFileListOutputEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + want []string + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "header only no files", + input: "Information for pkg-1.0:\n\nFiles:\n", + wantLen: 0, + }, + { + name: "paths with spaces", + input: "Information for pkg-1.0:\n\nFiles:\n/usr/local/share/my dir/file name.txt\n/usr/local/share/another path/test\n", + wantLen: 2, + want: []string{ + "/usr/local/share/my dir/file name.txt", + "/usr/local/share/another path/test", + }, + }, + { + name: "single file", + input: "Files:\n/usr/local/bin/bash\n", + wantLen: 1, + want: []string{"/usr/local/bin/bash"}, + }, + { + name: "no header just paths", + input: "/usr/local/bin/a\n/usr/local/bin/b\n", + wantLen: 2, + }, + { + name: "blank lines between files", + input: "Files:\n/usr/local/bin/a\n\n/usr/local/bin/b\n", + wantLen: 2, + }, + { + name: "non-path lines are skipped", + input: "Information for pkg-1.0:\n\nFiles:\nNot a path\n/usr/local/bin/real\n", + wantLen: 1, + want: []string{"/usr/local/bin/real"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files := parseFileListOutput(tt.input) + if len(files) != tt.wantLen { + t.Fatalf("expected %d files, got %d", tt.wantLen, len(files)) + } + for i, w := range tt.want { + if i >= len(files) { + break + } + if files[i] != w { + t.Errorf("[%d] got %q, want %q", i, files[i], w) + } + } + }) + } +} + func TestParseFileListOutputEmpty(t *testing.T) { files := parseFileListOutput("") if len(files) != 0 { @@ -245,6 +698,58 @@ func TestParseOwnerOutput(t *testing.T) { } } +func TestParseOwnerOutputEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "empty input", + input: "", + want: "", + }, + { + name: "whitespace only", + input: " \n ", + want: "", + }, + { + name: "name with many hyphens", + input: "py3-django-rest-framework-3.14.0", + want: "py3-django-rest-framework", + }, + { + name: "no version (no hyphen)", + input: "quirks", + want: "quirks", + }, + { + name: "leading/trailing whitespace", + input: " curl-8.5.0 ", + want: "curl", + }, + { + name: "p-suffix version", + input: "python-3.11.7p0", + want: "python", + }, + { + name: "trailing newline", + input: "bash-5.2\n", + want: "bash", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseOwnerOutput(tt.input) + if got != tt.want { + t.Errorf("parseOwnerOutput(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Ports)(nil) var _ snack.VersionQuerier = (*Ports)(nil) @@ -253,9 +758,48 @@ func TestInterfaceCompliance(t *testing.T) { var _ snack.PackageUpgrader = (*Ports)(nil) } +func TestPackageUpgraderInterface(t *testing.T) { + var _ snack.PackageUpgrader = (*Ports)(nil) +} + func TestName(t *testing.T) { p := New() if p.Name() != "ports" { t.Errorf("Name() = %q, want %q", p.Name(), "ports") } } + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + + // Should be true + if !caps.VersionQuery { + t.Error("expected VersionQuery=true") + } + if !caps.Clean { + t.Error("expected Clean=true") + } + if !caps.FileOwnership { + t.Error("expected FileOwnership=true") + } + + // Should be false + if caps.Hold { + t.Error("expected Hold=false") + } + if caps.RepoManagement { + t.Error("expected RepoManagement=false") + } + if caps.KeyManagement { + t.Error("expected KeyManagement=false") + } + if caps.Groups { + t.Error("expected Groups=false") + } + if caps.NameNormalize { + t.Error("expected NameNormalize=false") + } + if caps.DryRun { + t.Error("expected DryRun=false") + } +} diff --git a/rpm/parse_test.go b/rpm/parse_test.go index 2661ace..09bcf76 100644 --- a/rpm/parse_test.go +++ b/rpm/parse_test.go @@ -2,8 +2,6 @@ package rpm import ( "testing" - - "github.com/gogrlx/snack" ) func TestParseList(t *testing.T) { @@ -95,9 +93,128 @@ func TestParseArchSuffix(t *testing.T) { } } -// Compile-time interface checks. -var ( - _ snack.Manager = (*RPM)(nil) - _ snack.FileOwner = (*RPM)(nil) - _ snack.NameNormalizer = (*RPM)(nil) -) +// --- Edge case tests --- + +func TestParseListEmpty(t *testing.T) { + pkgs := parseList("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages from empty input, got %d", len(pkgs)) + } +} + +func TestParseListSinglePackage(t *testing.T) { + input := "curl\t7.76.1-23.el9\tA utility\n" + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "curl" { + t.Errorf("Name = %q, want curl", pkgs[0].Name) + } +} + +func TestParseListNoDescription(t *testing.T) { + input := "bash\t5.1.8-6.el9\n" + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "" { + t.Errorf("Description = %q, want empty", pkgs[0].Description) + } +} + +func TestParseListMalformedLines(t *testing.T) { + input := "bash\t5.1.8-6.el9\tShell\nno-tab-here\ncurl\t7.76.1\tHTTP tool\n" + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages (skip malformed), got %d", len(pkgs)) + } +} + +func TestParseInfoEmpty(t *testing.T) { + p := parseInfo("") + if p != nil { + t.Errorf("expected nil from empty input, got %+v", p) + } +} + +func TestParseInfoNoName(t *testing.T) { + input := `Version : 1.0 +Architecture: x86_64 +` + p := parseInfo(input) + if p != nil { + t.Errorf("expected nil when no Name field, got %+v", p) + } +} + +func TestParseInfoArchField(t *testing.T) { + // Test both "Architecture" and "Arch" key forms + input := `Name : test +Version : 1.0 +Release : 1.el9 +Arch : aarch64 +Summary : Test package +` + p := parseInfo(input) + if p == nil { + t.Fatal("expected non-nil package") + } + if p.Arch != "aarch64" { + t.Errorf("Arch = %q, want aarch64", p.Arch) + } +} + +func TestNormalizeNameEdgeCases(t *testing.T) { + tests := []struct { + input, want string + }{ + {"", ""}, + {"pkg.unknown.ext", "pkg.unknown.ext"}, + {"name.with.dots.x86_64", "name.with.dots"}, + {"python3.11", "python3.11"}, + {"glibc.s390x", "glibc"}, + {"kernel.src", "kernel"}, + {".x86_64", ""}, + {"pkg.ppc64le", "pkg"}, + {"pkg.armv7hl", "pkg"}, + {"pkg.i386", "pkg"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeName(tt.input) + if got != tt.want { + t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseArchSuffixEdgeCases(t *testing.T) { + tests := []struct { + input, wantName, wantArch string + }{ + {"", "", ""}, + {"pkg.i386", "pkg", "i386"}, + {"pkg.ppc64le", "pkg", "ppc64le"}, + {"pkg.s390x", "pkg", "s390x"}, + {"pkg.armv7hl", "pkg", "armv7hl"}, + {"pkg.src", "pkg", "src"}, + {"pkg.aarch64", "pkg", "aarch64"}, + {"pkg.noarch", "pkg", "noarch"}, + {"pkg.unknown", "pkg.unknown", ""}, + {"name.with.many.dots.noarch", "name.with.many.dots", "noarch"}, + {".noarch", "", "noarch"}, + {"pkg.x86_64.extra", "pkg.x86_64.extra", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, arch := parseArchSuffix(tt.input) + if name != tt.wantName || arch != tt.wantArch { + t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", + tt.input, name, arch, tt.wantName, tt.wantArch) + } + }) + } +} diff --git a/rpm/rpm_test.go b/rpm/rpm_test.go new file mode 100644 index 0000000..f11f104 --- /dev/null +++ b/rpm/rpm_test.go @@ -0,0 +1,87 @@ +package rpm + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +// Compile-time interface assertions. +var ( + _ snack.Manager = (*RPM)(nil) + _ snack.FileOwner = (*RPM)(nil) + _ snack.NameNormalizer = (*RPM)(nil) +) + +func TestName(t *testing.T) { + r := New() + if got := r.Name(); got != "rpm" { + t.Errorf("Name() = %q, want %q", got, "rpm") + } +} + +func TestGetCapabilities(t *testing.T) { + r := New() + caps := snack.GetCapabilities(r) + + wantTrue := map[string]bool{ + "FileOwnership": caps.FileOwnership, + "NameNormalize": caps.NameNormalize, + } + for name, got := range wantTrue { + t.Run(name+"_true", func(t *testing.T) { + if !got { + t.Errorf("Capabilities.%s = false, want true", name) + } + }) + } + + wantFalse := map[string]bool{ + "VersionQuery": caps.VersionQuery, + "Hold": caps.Hold, + "Clean": caps.Clean, + "RepoManagement": caps.RepoManagement, + "KeyManagement": caps.KeyManagement, + "Groups": caps.Groups, + "DryRun": caps.DryRun, + } + for name, got := range wantFalse { + t.Run(name+"_false", func(t *testing.T) { + if got { + t.Errorf("Capabilities.%s = true, want false", name) + } + }) + } +} + +func TestNormalizeNameMethod(t *testing.T) { + r := New() + tests := []struct { + input, want string + }{ + {"nginx.x86_64", "nginx"}, + {"curl", "curl"}, + {"bash.noarch", "bash"}, + } + for _, tt := range tests { + if got := r.NormalizeName(tt.input); got != tt.want { + t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseArchMethod(t *testing.T) { + r := New() + name, arch := r.ParseArch("nginx.x86_64") + if name != "nginx" || arch != "x86_64" { + t.Errorf("ParseArch(\"nginx.x86_64\") = (%q, %q), want (\"nginx\", \"x86_64\")", name, arch) + } +} + +// RPM should NOT implement DryRunner. +func TestNotDryRunner(t *testing.T) { + r := New() + if _, ok := interface{}(r).(snack.DryRunner); ok { + t.Error("RPM should not implement DryRunner") + } +} diff --git a/snap/snap_test.go b/snap/snap_test.go index 845257d..2144a4d 100644 --- a/snap/snap_test.go +++ b/snap/snap_test.go @@ -35,6 +35,56 @@ func TestParseSnapListEmpty(t *testing.T) { } } +func TestParseSnapListEdgeCases(t *testing.T) { + t.Run("single entry", func(t *testing.T) { + input := `Name Version Rev Tracking Publisher Notes +core22 20240111 1122 latest/stable canonical✓ base +` + pkgs := parseSnapList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "core22" { + t.Errorf("expected core22, got %q", pkgs[0].Name) + } + }) + + t.Run("header only no trailing newline", func(t *testing.T) { + input := "Name Version Rev Tracking Publisher Notes" + pkgs := parseSnapList(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("single field line skipped", func(t *testing.T) { + input := "Name Version Rev Tracking Publisher Notes\nsinglefield\n" + pkgs := parseSnapList(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages (need >=2 fields), got %d", len(pkgs)) + } + }) + + t.Run("extra whitespace lines", func(t *testing.T) { + input := "Name Version Rev Tracking Publisher Notes\n \n\n" + pkgs := parseSnapList(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("many columns", func(t *testing.T) { + input := "Name Version Rev Tracking Publisher Notes\nsnap1 2.0 100 latest/stable pub note extra more\n" + pkgs := parseSnapList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "snap1" || pkgs[0].Version != "2.0" { + t.Errorf("unexpected package: %+v", pkgs[0]) + } + }) +} + func TestParseSnapFind(t *testing.T) { input := `Name Version Publisher Notes Summary firefox 131.0 mozilla✓ - Mozilla Firefox web browser @@ -52,6 +102,67 @@ chromium 129.0 nickvdp - Chromium web browser } } +func TestParseSnapFindEdgeCases(t *testing.T) { + t.Run("single result", func(t *testing.T) { + input := "Name Version Publisher Notes Summary\nfirefox 131.0 mozilla - Web browser\n" + pkgs := parseSnapFind(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "firefox" { + t.Errorf("expected firefox, got %q", pkgs[0].Name) + } + if pkgs[0].Description != "Web browser" { + t.Errorf("expected 'Web browser', got %q", pkgs[0].Description) + } + }) + + t.Run("header only", func(t *testing.T) { + input := "Name Version Publisher Notes Summary\n" + pkgs := parseSnapFind(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) + + t.Run("too few fields skipped", func(t *testing.T) { + input := "Name Version Publisher Notes Summary\nfoo 1.0 pub\n" + pkgs := parseSnapFind(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages (need >=4 fields), got %d", len(pkgs)) + } + }) + + t.Run("exactly four fields no summary", func(t *testing.T) { + input := "Name Version Publisher Notes Summary\nfoo 1.0 pub note\n" + pkgs := parseSnapFind(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "" { + t.Errorf("expected empty description, got %q", pkgs[0].Description) + } + }) + + t.Run("multi-word summary", func(t *testing.T) { + input := "Name Version Publisher Notes Summary\nmysnap 2.0 me - A very long description with many words\n" + pkgs := parseSnapFind(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "A very long description with many words" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } + }) + + t.Run("empty input", func(t *testing.T) { + pkgs := parseSnapFind("") + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) +} + func TestParseSnapInfo(t *testing.T) { input := `name: firefox summary: Mozilla Firefox web browser @@ -84,6 +195,57 @@ func TestParseSnapInfoEmpty(t *testing.T) { } } +func TestParseSnapInfoEdgeCases(t *testing.T) { + t.Run("not installed snap", func(t *testing.T) { + input := `name: hello-world +summary: A simple hello world snap +publisher: Canonical✓ (canonical✓) +snap-id: buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ +` + pkg := parseSnapInfo(input) + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Name != "hello-world" { + t.Errorf("expected hello-world, got %q", pkg.Name) + } + if pkg.Installed { + t.Error("expected Installed=false for snap without installed field") + } + if pkg.Version != "" { + t.Errorf("expected empty version, got %q", pkg.Version) + } + }) + + t.Run("name only", func(t *testing.T) { + pkg := parseSnapInfo("name: test\n") + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Name != "test" { + t.Errorf("expected test, got %q", pkg.Name) + } + }) + + t.Run("no name returns nil", func(t *testing.T) { + pkg := parseSnapInfo("summary: something\ninstalled: 1.0 (1) 10MB\n") + if pkg != nil { + t.Error("expected nil when no name field") + } + }) + + t.Run("no colon lines ignored", func(t *testing.T) { + input := "name: mysnap\nrandom text without colon\nsummary: A snap\n" + pkg := parseSnapInfo(input) + if pkg == nil { + t.Fatal("expected non-nil") + } + if pkg.Description != "A snap" { + t.Errorf("unexpected description: %q", pkg.Description) + } + }) +} + func TestParseSnapInfoVersion(t *testing.T) { input := `name: firefox channels: @@ -105,6 +267,39 @@ func TestParseSnapInfoVersionMissing(t *testing.T) { } } +func TestParseSnapInfoVersionEdgeCases(t *testing.T) { + t.Run("empty input", func(t *testing.T) { + ver := parseSnapInfoVersion("") + if ver != "" { + t.Errorf("expected empty, got %q", ver) + } + }) + + t.Run("stable channel with dashes", func(t *testing.T) { + input := " latest/stable: 2024.01.15 2024-01-15 (100) 50MB -\n" + ver := parseSnapInfoVersion(input) + if ver != "2024.01.15" { + t.Errorf("expected 2024.01.15, got %q", ver) + } + }) + + t.Run("closed channel marked --", func(t *testing.T) { + input := " latest/stable: --\n" + ver := parseSnapInfoVersion(input) + if ver != "" { + t.Errorf("expected empty for closed channel, got %q", ver) + } + }) + + t.Run("closed channel marked ^", func(t *testing.T) { + input := " latest/stable: ^ \n" + ver := parseSnapInfoVersion(input) + if ver != "" { + t.Errorf("expected empty for ^ channel, got %q", ver) + } + }) +} + func TestParseSnapRefreshList(t *testing.T) { input := `Name Version Rev Publisher Notes firefox 132.0 4650 mozilla✓ - @@ -128,30 +323,127 @@ All snaps up to date. } } +func TestParseSnapRefreshListEdgeCases(t *testing.T) { + t.Run("multiple upgrades", func(t *testing.T) { + input := "Name Version Rev Publisher Notes\nfoo 2.0 10 pub -\nbar 3.0 20 pub -\n" + pkgs := parseSnapRefreshList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "foo" || pkgs[1].Name != "bar" { + t.Errorf("unexpected packages: %+v, %+v", pkgs[0], pkgs[1]) + } + }) + + t.Run("header only", func(t *testing.T) { + input := "Name Version Rev Publisher Notes\n" + pkgs := parseSnapRefreshList(input) + if len(pkgs) != 0 { + t.Errorf("expected 0 packages, got %d", len(pkgs)) + } + }) +} + func TestSemverCmp(t *testing.T) { tests := []struct { + name string a, b string want int }{ - {"1.0.0", "1.0.0", 0}, - {"1.0.0", "2.0.0", -1}, - {"2.0.0", "1.0.0", 1}, - {"1.2.3", "1.2.4", -1}, - {"1.10.0", "1.9.0", 1}, - {"1.0", "1.0.0", 0}, - {"131.0", "132.0", -1}, + {"equal", "1.0.0", "1.0.0", 0}, + {"less major", "1.0.0", "2.0.0", -1}, + {"greater major", "2.0.0", "1.0.0", 1}, + {"less patch", "1.2.3", "1.2.4", -1}, + {"multi-digit minor", "1.10.0", "1.9.0", 1}, + {"short vs long equal", "1.0", "1.0.0", 0}, + {"real versions", "131.0", "132.0", -1}, + // Edge cases + {"single component", "5", "3", 1}, + {"single equal", "3", "3", 0}, + {"empty vs empty", "", "", 0}, + {"empty vs version", "", "1.0", -1}, + {"version vs empty", "1.0", "", 1}, + {"non-numeric suffix", "1.0.0-beta", "1.0.0-rc1", 0}, + {"four components", "1.2.3.4", "1.2.3.5", -1}, + {"different lengths padded", "1.0.0.0", "1.0.0", 0}, + {"short less", "1.0", "1.0.1", -1}, + {"short greater", "1.1", "1.0.9", 1}, } for _, tt := range tests { - got := semverCmp(tt.a, tt.b) - if got != tt.want { - t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) - } + t.Run(tt.name, func(t *testing.T) { + got := semverCmp(tt.a, tt.b) + if got != tt.want { + t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestStripNonNumeric(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"123", "123"}, + {"123abc", "123"}, + {"abc", ""}, + {"0beta", "0"}, + {"", ""}, + {"42-rc1", "42"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stripNonNumeric(tt.input) + if got != tt.want { + t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want) + } + }) } } func TestInterfaceCompliance(t *testing.T) { var _ snack.Manager = (*Snap)(nil) var _ snack.VersionQuerier = (*Snap)(nil) + var _ snack.Cleaner = (*Snap)(nil) + var _ snack.PackageUpgrader = (*Snap)(nil) +} + +// Compile-time interface checks +var ( + _ snack.Cleaner = (*Snap)(nil) + _ snack.PackageUpgrader = (*Snap)(nil) +) + +func TestCapabilities(t *testing.T) { + caps := snack.GetCapabilities(New()) + if !caps.VersionQuery { + t.Error("expected VersionQuery=true") + } + if !caps.Clean { + t.Error("expected Clean=true") + } + // Should be false + if caps.FileOwnership { + t.Error("expected FileOwnership=false") + } + if caps.DryRun { + t.Error("expected DryRun=false") + } + if caps.Hold { + t.Error("expected Hold=false") + } + if caps.RepoManagement { + t.Error("expected RepoManagement=false") + } + if caps.KeyManagement { + t.Error("expected KeyManagement=false") + } + if caps.Groups { + t.Error("expected Groups=false") + } + if caps.NameNormalize { + t.Error("expected NameNormalize=false") + } } func TestName(t *testing.T) {