diff --git a/.github/INSTALLER_CI_README.md b/.github/INSTALLER_CI_README.md index c0b5b44..fe735e8 100644 --- a/.github/INSTALLER_CI_README.md +++ b/.github/INSTALLER_CI_README.md @@ -16,6 +16,8 @@ This directory contains GitHub Actions workflows for the dotfiles installer proj **Platforms Tested**: - Ubuntu (latest) - Debian (bookworm container) +- Fedora (latest) +- CentOS (latest) - macOS (latest) ## Build Process @@ -94,4 +96,4 @@ mkdir -p "$HOME" - **Linting**: Will integrate golangci-lint later - **Security Scanning**: Can add Trivy scans if needed -- **Release Automation**: Will add when ready for releases \ No newline at end of file +- **Release Automation**: Will add when ready for releases diff --git a/.github/workflows/installer-ci.yml b/.github/workflows/installer-ci.yml index 8915da2..d3808c7 100644 --- a/.github/workflows/installer-ci.yml +++ b/.github/workflows/installer-ci.yml @@ -113,6 +113,12 @@ jobs: - os: ubuntu-latest platform: debian container: debian:bookworm + - os: ubuntu-latest + platform: fedora + container: fedora:latest + - os: ubuntu-latest + platform: centos + container: quay.io/centos/centos:latest - os: macos-latest platform: macos @@ -236,6 +242,9 @@ jobs: run: | if [ "${{ matrix.platform }}" = "macos" ]; then brew install expect + elif [ "${{ matrix.platform }}" = "fedora" ] || [ "${{ matrix.platform }}" = "centos" ]; then + # For Fedora/CentOS - install expect via dnf + dnf install -y expect else # For Ubuntu/Debian - check if sudo exists, otherwise run directly (containers) if command -v sudo >/dev/null 2>&1; then diff --git a/installer/cmd/install.go b/installer/cmd/install.go index feffac1..2095110 100644 --- a/installer/cmd/install.go +++ b/installer/cmd/install.go @@ -10,9 +10,11 @@ import ( "github.com/MrPointer/dotfiles/installer/lib/apt" "github.com/MrPointer/dotfiles/installer/lib/brew" "github.com/MrPointer/dotfiles/installer/lib/compatibility" + "github.com/MrPointer/dotfiles/installer/lib/dnf" "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager" "github.com/MrPointer/dotfiles/installer/lib/dotfilesmanager/chezmoi" "github.com/MrPointer/dotfiles/installer/lib/gpg" + "github.com/MrPointer/dotfiles/installer/lib/packageresolver" "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" "github.com/MrPointer/dotfiles/installer/lib/shell" "github.com/MrPointer/dotfiles/installer/utils/logger" @@ -138,6 +140,8 @@ func createPackageManagerForSystem(sysInfo *compatibility.SystemInfo) pkgmanager switch sysInfo.DistroName { case "ubuntu", "debian": return apt.NewAptPackageManager(cliLogger, globalCommander, globalOsManager, privilege.NewDefaultEscalator(cliLogger, globalCommander, globalOsManager), GetDisplayMode()) + case "fedora", "centos", "rhel": + return dnf.NewDnfPackageManager(cliLogger, globalCommander, globalOsManager, privilege.NewDefaultEscalator(cliLogger, globalCommander, globalOsManager), GetDisplayMode()) default: cliLogger.Warning("Unsupported Linux distribution for automatic package installation: %s", sysInfo.DistroName) return nil @@ -155,6 +159,23 @@ func createPackageManagerForSystem(sysInfo *compatibility.SystemInfo) pkgmanager } } +// createPackageResolverForSystem creates a package resolver for the given system. +func createPackageResolverForSystem(packageManager pkgmanager.PackageManager, sysInfo *compatibility.SystemInfo) *packageresolver.Resolver { + // Load package mappings + mappings, err := packageresolver.LoadPackageMappings(viper.GetViper(), "") + if err != nil { + return nil + } + + // Create and return resolver + resolver, err := packageresolver.NewResolver(mappings, packageManager, sysInfo) + if err != nil { + return nil + } + + return resolver +} + // handlePrerequisiteInstallation handles automatic installation of missing prerequisites. // Returns true if prerequisites were installed and compatibility should be re-checked. func handlePrerequisiteInstallation(sysInfo compatibility.SystemInfo, log logger.Logger) bool { @@ -212,14 +233,32 @@ func handlePrerequisiteInstallation(sysInfo compatibility.SystemInfo, log logger // Install each selected prerequisite installed := false + + // Create package resolver to get proper package info including types + resolver := createPackageResolverForSystem(packageManager, &sysInfo) + if resolver == nil { + log.Warning("Cannot resolve package information for this system") + return false + } + for _, name := range prerequisitesToInstall { if detail, exists := sysInfo.Prerequisites.Details[name]; exists { log.StartProgress(fmt.Sprintf("Installing %s", detail.Description)) - // Use the prerequisite name directly as the package name - packageInfo := pkgmanager.NewRequestedPackageInfo(name, nil) + // Resolve the prerequisite to get proper package name and type + resolvedPackage, err := resolver.Resolve(name, "") + if err != nil { + log.FailProgress(fmt.Sprintf("Failed to resolve package %s", name), err) + return false + } + + // Create package info with resolved name and type + packageInfo := pkgmanager.NewRequestedPackageInfoWithType( + resolvedPackage.Name, + resolvedPackage.Type, + resolvedPackage.VersionConstraints) - err := packageManager.InstallPackage(packageInfo) + err = packageManager.InstallPackage(packageInfo) if err != nil { log.FailProgress(fmt.Sprintf("Failed to install %s", detail.Description), err) return false diff --git a/installer/internal/config/compatibility.yaml b/installer/internal/config/compatibility.yaml index ecd13e3..119a163 100644 --- a/installer/internal/config/compatibility.yaml +++ b/installer/internal/config/compatibility.yaml @@ -58,14 +58,17 @@ operatingSystems: description: "Git version control system" install_hint: "sudo apt-get install git" fedora: - supported: false - notes: "Support planned for future release" + supported: true + version_constraint: ">= 35" + notes: "Fedora 35 or newer" prerequisites: - - name: "development-tools" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "build-essential" command: "gcc" description: "Development tools group including gcc, make, and other build tools" install_hint: "sudo dnf group install 'Development Tools'" - - name: "procps-ng" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "procps" command: "ps" description: "Process utilities" install_hint: "sudo dnf install procps-ng" @@ -82,14 +85,17 @@ operatingSystems: description: "Git version control system" install_hint: "sudo dnf install git" centos: - supported: false - notes: "Support planned for future release" + supported: true + notes: "CentOS 6 or newer" + version_constraint: ">= 6" prerequisites: - - name: "development-tools" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "build-essential" command: "gcc" description: "Development tools group including gcc, make, and other build tools" install_hint: "sudo dnf group install 'Development Tools'" - - name: "procps-ng" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "procps" command: "ps" description: "Process utilities" install_hint: "sudo dnf install procps-ng" @@ -106,14 +112,17 @@ operatingSystems: description: "Git version control system" install_hint: "sudo dnf install git" redhat: - supported: false - notes: "Support planned for future release" + supported: true + notes: "Red Hat Enterprise Linux 6 or newer" + version_constraint: ">= 6" prerequisites: - - name: "development-tools" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "build-essential" command: "gcc" description: "Development tools group including gcc, make, and other build tools" install_hint: "sudo dnf group install 'Development Tools'" - - name: "procps-ng" + # Use same name as Ubuntu because this acts as a key, resolved later by the package-resolver + - name: "procps" command: "ps" description: "Process utilities" install_hint: "sudo dnf install procps-ng" @@ -129,13 +138,19 @@ operatingSystems: command: "git" description: "Git version control system" install_hint: "sudo dnf install git" + rocky: + supported: false + notes: "No current plans for support" + almalinux: + supported: false + notes: "No current plans for support" suse: supported: false notes: "No current plans for support" # Add more Linux distributions here as needed darwin: supported: true - notes: "macOS 10.15 or newer recommended" + notes: "macOS 12 or newer" prerequisites: - name: "xcode-command-line-tools" command: "xcode-select" diff --git a/installer/internal/config/packagemap.yaml b/installer/internal/config/packagemap.yaml index ec2bbb1..7d5e0d1 100644 --- a/installer/internal/config/packagemap.yaml +++ b/installer/internal/config/packagemap.yaml @@ -5,6 +5,8 @@ packages: name: git brew: name: git + dnf: + name: git gpg: apt: name: gnupg2 # Debian/Ubuntu often use gnupg2 @@ -17,8 +19,41 @@ packages: name: neovim brew: name: neovim + dnf: + name: neovim zsh: apt: name: zsh brew: name: zsh + dnf: + name: zsh + build-essential: + apt: + name: build-essential + dnf: + type: group + name: + fedora: development-tools + centos: "Development Tools" + rhel: "Development Tools" + # Note: Other package managers would define their equivalents here + curl: + apt: + name: curl + brew: + name: curl + dnf: + name: curl + file: + apt: + name: file + brew: + name: file + dnf: + name: file + procps: + apt: + name: procps + dnf: + name: procps-ng diff --git a/installer/lib/dnf/dnf.go b/installer/lib/dnf/dnf.go new file mode 100644 index 0000000..0bb153c --- /dev/null +++ b/installer/lib/dnf/dnf.go @@ -0,0 +1,239 @@ +package dnf + +import ( + "fmt" + "strings" + + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" +) + +type DnfPackageManager struct { + logger logger.Logger + commander utils.Commander + programQuery osmanager.ProgramQuery + escalator privilege.Escalator + displayMode utils.DisplayMode +} + +var _ pkgmanager.PackageManager = (*DnfPackageManager)(nil) + +// NewDnfPackageManager creates a new DnfPackageManager instance. +func NewDnfPackageManager(logger logger.Logger, commander utils.Commander, programQuery osmanager.ProgramQuery, escalator privilege.Escalator, displayMode utils.DisplayMode) *DnfPackageManager { + return &DnfPackageManager{ + logger: logger, + commander: commander, + programQuery: programQuery, + escalator: escalator, + displayMode: displayMode, + } +} + +// GetInfo retrieves information about the DNF package manager. +func (d *DnfPackageManager) GetInfo() (pkgmanager.PackageManagerInfo, error) { + d.logger.Debug("Getting info about dnf") + + dnfVersion, err := d.programQuery.GetProgramVersion("dnf", func(version string) (string, error) { + if version == "" { + return "", nil + } + + // DNF version output typically contains "dnf 4.14.0" format. + // Extract just the version number. + parts := strings.Fields(version) + if len(parts) >= 2 { + return parts[1], nil + } + + return version, nil + }) + if err != nil { + return pkgmanager.DefaultPackageManagerInfo(), fmt.Errorf("failed to get DNF version: %w", err) + } + + return pkgmanager.PackageManagerInfo{ + Name: "dnf", + Version: dnfVersion, + }, nil +} + +// GetPackageVersion retrieves the version of an installed package. +func (d *DnfPackageManager) GetPackageVersion(packageName string) (string, error) { + packages, err := d.ListInstalledPackages() + if err != nil { + return "", fmt.Errorf("failed to list installed packages: %w", err) + } + + for _, pkg := range packages { + if pkg.Name == packageName { + return pkg.Version, nil + } + } + + return "", fmt.Errorf("package %s is not installed", packageName) +} + +// InstallPackage installs a package using DNF. +func (d *DnfPackageManager) InstallPackage(requestedPackageInfo pkgmanager.RequestedPackageInfo) error { + d.logger.Debug("Installing package %s with dnf", requestedPackageInfo.Name) + + if requestedPackageInfo.VersionConstraints != nil { + d.logger.Debug("DNF doesn't support version constraints, installing the latest version of package %s", requestedPackageInfo.Name) + } + + var installArgs []string + if requestedPackageInfo.Type == "group" { + d.logger.Debug("Installing package group %s with dnf", requestedPackageInfo.Name) + installArgs = []string{"group", "install", "-y", requestedPackageInfo.Name} + } else { + d.logger.Debug("Installing regular package %s with dnf", requestedPackageInfo.Name) + installArgs = []string{"install", "-y", requestedPackageInfo.Name} + } + + escalatedInstall, err := d.escalator.EscalateCommand("dnf", installArgs) + if err != nil { + return fmt.Errorf("failed to determine privilege escalation for dnf install: %w", err) + } + + var discardOutputOption utils.Option = utils.EmptyOption() + if d.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = d.commander.RunCommand(escalatedInstall.Command, escalatedInstall.Args, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to install package %s: %w", requestedPackageInfo.Name, err) + } + + d.logger.Debug("Package %s installed successfully with dnf", requestedPackageInfo.Name) + return nil +} + +// IsPackageInstalled checks if a package is installed. +func (d *DnfPackageManager) IsPackageInstalled(packageInfo pkgmanager.PackageInfo) (bool, error) { + d.logger.Debug("Checking if package %s is installed with dnf", packageInfo.Name) + + if packageInfo.Type == "group" { + return d.isGroupInstalled(packageInfo.Name) + } + + packages, err := d.ListInstalledPackages() + if err != nil { + return false, fmt.Errorf("failed to list installed packages: %w", err) + } + + for _, pkg := range packages { + if pkg.Name == packageInfo.Name { + d.logger.Debug("Package %s is installed with dnf", packageInfo.Name) + return true, nil + } + } + + d.logger.Debug("Package %s is not installed with dnf", packageInfo.Name) + return false, nil +} + +// isGroupInstalled checks if a package group is installed. +func (d *DnfPackageManager) isGroupInstalled(groupName string) (bool, error) { + d.logger.Debug("Checking if group %s is installed with dnf", groupName) + + output, err := d.commander.RunCommand("dnf", []string{"group", "list", "installed"}, utils.WithCaptureOutput()) + if err != nil { + return false, fmt.Errorf("failed to list installed groups: %w", err) + } + + trimmedOutput := strings.TrimSpace(string(output.Stdout)) + d.logger.Trace("Raw output from dnf group list installed: %s", trimmedOutput) + + // DNF group list output contains group names, check if our group is listed + lines := strings.Split(trimmedOutput, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, groupName) { + d.logger.Debug("Group %s is installed with dnf", groupName) + return true, nil + } + } + + d.logger.Debug("Group %s is not installed with dnf", groupName) + return false, nil +} + +// ListInstalledPackages returns a list of all installed packages. +func (d *DnfPackageManager) ListInstalledPackages() ([]pkgmanager.PackageInfo, error) { + d.logger.Debug("Listing packages installed with dnf") + + // Use dnf list installed to get installed packages with versions. + output, err := d.commander.RunCommand("dnf", []string{"list", "installed"}, utils.WithCaptureOutput()) + if err != nil { + return nil, fmt.Errorf("failed to list installed packages: %w", err) + } + + trimmedOutput := strings.TrimSpace(string(output.Stdout)) + if trimmedOutput == "" { + return []pkgmanager.PackageInfo{}, nil + } + + d.logger.Trace("Raw output from dnf list installed: %s", trimmedOutput) + + lines := strings.Split(trimmedOutput, "\n") + packages := make([]pkgmanager.PackageInfo, 0) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Installed Packages") { + continue + } + + // DNF output format: "package-name.arch version repo" + parts := strings.Fields(line) + if len(parts) >= 2 { + // Extract package name (remove architecture suffix if present) + packageWithArch := parts[0] + packageName := packageWithArch + if dotIndex := strings.LastIndex(packageWithArch, "."); dotIndex != -1 { + packageName = packageWithArch[:dotIndex] + } + + version := parts[1] + packages = append(packages, pkgmanager.NewPackageInfo(packageName, version)) + } + } + + return packages, nil +} + +// UninstallPackage uninstalls a package using DNF. +func (d *DnfPackageManager) UninstallPackage(packageInfo pkgmanager.PackageInfo) error { + d.logger.Debug("Uninstalling package %s with dnf", packageInfo.Name) + + var removeArgs []string + if packageInfo.Type == "group" { + d.logger.Debug("Uninstalling package group %s with dnf", packageInfo.Name) + removeArgs = []string{"group", "remove", "-y", packageInfo.Name} + } else { + d.logger.Debug("Uninstalling regular package %s with dnf", packageInfo.Name) + removeArgs = []string{"remove", "-y", packageInfo.Name} + } + + removeResult, err := d.escalator.EscalateCommand("dnf", removeArgs) + if err != nil { + return fmt.Errorf("failed to determine privilege escalation for dnf remove: %w", err) + } + + var discardOutputOption utils.Option = utils.EmptyOption() + if d.displayMode.ShouldDiscardOutput() { + discardOutputOption = utils.WithDiscardOutput() + } + + _, err = d.commander.RunCommand(removeResult.Command, removeResult.Args, discardOutputOption) + if err != nil { + return fmt.Errorf("failed to uninstall package %s: %w", packageInfo.Name, err) + } + + d.logger.Debug("Package %s uninstalled successfully with dnf", packageInfo.Name) + return nil +} diff --git a/installer/lib/dnf/dnf_test.go b/installer/lib/dnf/dnf_test.go new file mode 100644 index 0000000..c2a72ff --- /dev/null +++ b/installer/lib/dnf/dnf_test.go @@ -0,0 +1,443 @@ +package dnf_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/dnf" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" +) + +func Test_DnfPackageManager_ImplementsPackageManagerInterface(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + require.Implements(t, (*pkgmanager.PackageManager)(nil), dnfManager) +} + +func Test_NewDnfPackageManager_ReturnsValidInstance(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + require.NotNil(t, dnfManager) +} + +func Test_GetInfo_ReturnsValidDnfManagerInfo(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + if program == "dnf" { + return versionExtractor("dnf 4.14.0") + } + return "", nil + }, + } + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + info, err := dnfManager.GetInfo() + + require.NoError(t, err) + require.Equal(t, "dnf", info.Name) + require.Equal(t, "4.14.0", info.Version) +} + +func Test_GetInfo_ReturnsError_WhenVersionQueryFails(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{ + GetProgramVersionFunc: func(program string, versionExtractor osmanager.VersionExtractor, queryArgs ...string) (string, error) { + return "", errors.New("command not found") + }, + } + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + info, err := dnfManager.GetInfo() + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to get DNF version") + require.Equal(t, pkgmanager.DefaultPackageManagerInfo(), info) +} + +func Test_InstallPackage_InstallsRegularPackage_Successfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "sudo", command) + require.Equal(t, []string{"dnf", "install", "-y", "git"}, args) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("git", nil)) + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_InstallPackage_InstallsGroupPackage_Successfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "sudo", command) + require.Equal(t, []string{"dnf", "group", "install", "-y", "Development Tools"}, args) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.InstallPackage(pkgmanager.NewRequestedPackageInfoWithType("Development Tools", "group", nil)) + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_InstallPackage_ReturnsError_WhenEscalationFails(t *testing.T) { + mockCommander := &utils.MoqCommander{} + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{}, errors.New("escalation failed") + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("git", nil)) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to determine privilege escalation for dnf install") +} + +func Test_InstallPackage_ReturnsError_WhenCommandFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return nil, errors.New("package not found") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("nonexistent", nil)) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to install package nonexistent") +} + +func Test_UninstallPackage_UninstallsRegularPackage_Successfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "sudo", command) + require.Equal(t, []string{"dnf", "remove", "-y", "git"}, args) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.UninstallPackage(pkgmanager.NewPackageInfo("git", "2.39.0")) + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_UninstallPackage_UninstallsGroupPackage_Successfully(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "sudo", command) + require.Equal(t, []string{"dnf", "group", "remove", "-y", "Development Tools"}, args) + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.UninstallPackage(pkgmanager.NewPackageInfoWithType("Development Tools", "1.0", "group")) + + require.NoError(t, err) + require.Len(t, mockCommander.RunCommandCalls(), 1) +} + +func Test_IsPackageInstalled_ReturnsTrueForInstalledRegularPackage(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "dnf", command) + require.Equal(t, []string{"list", "installed"}, args) + return &utils.Result{ + Stdout: []byte("Installed Packages\ngit.x86_64 2.39.0-1.fc38 @fedora\nvim.x86_64 9.0.1160-1.fc38 @fedora"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + isInstalled, err := dnfManager.IsPackageInstalled(pkgmanager.NewPackageInfo("git", "")) + + require.NoError(t, err) + require.True(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsFalseForNotInstalledRegularPackage(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "dnf", command) + require.Equal(t, []string{"list", "installed"}, args) + return &utils.Result{ + Stdout: []byte("Installed Packages\nvim.x86_64 9.0.1160-1.fc38 @fedora"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + isInstalled, err := dnfManager.IsPackageInstalled(pkgmanager.NewPackageInfo("git", "")) + + require.NoError(t, err) + require.False(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsTrueForInstalledGroup(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "dnf", command) + require.Equal(t, []string{"group", "list", "installed"}, args) + return &utils.Result{ + Stdout: []byte("Available Environment Groups:\n Development Tools\n Server Tools"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + isInstalled, err := dnfManager.IsPackageInstalled(pkgmanager.NewPackageInfoWithType("Development Tools", "", "group")) + + require.NoError(t, err) + require.True(t, isInstalled) +} + +func Test_IsPackageInstalled_ReturnsFalseForNotInstalledGroup(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + require.Equal(t, "dnf", command) + require.Equal(t, []string{"group", "list", "installed"}, args) + return &utils.Result{ + Stdout: []byte("Available Environment Groups:\n Server Tools"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + isInstalled, err := dnfManager.IsPackageInstalled(pkgmanager.NewPackageInfoWithType("Development Tools", "", "group")) + + require.NoError(t, err) + require.False(t, isInstalled) +} + +func Test_ListInstalledPackages_ReturnsValidPackageList(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("Installed Packages\ngit.x86_64 2.39.0-1.fc38 @fedora\nvim.x86_64 9.0.1160-1.fc38 @fedora\nzsh.x86_64 5.9-1.fc38 @fedora"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + packages, err := dnfManager.ListInstalledPackages() + + require.NoError(t, err) + require.Len(t, packages, 3) + + // Check that packages are parsed correctly + expectedPackages := []pkgmanager.PackageInfo{ + {Name: "git", Version: "2.39.0-1.fc38"}, + {Name: "vim", Version: "9.0.1160-1.fc38"}, + {Name: "zsh", Version: "5.9-1.fc38"}, + } + + for i, expected := range expectedPackages { + require.Equal(t, expected.Name, packages[i].Name) + require.Equal(t, expected.Version, packages[i].Version) + } +} + +func Test_ListInstalledPackages_ReturnsEmptyList_WhenNoPackagesInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("Installed Packages\n"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + packages, err := dnfManager.ListInstalledPackages() + + require.NoError(t, err) + require.Empty(t, packages) +} + +func Test_ListInstalledPackages_ReturnsError_WhenCommandFails(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return nil, errors.New("dnf command failed") + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + packages, err := dnfManager.ListInstalledPackages() + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to list installed packages") + require.Nil(t, packages) +} + +func Test_GetPackageVersion_ReturnsCorrectVersion_WhenPackageInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("Installed Packages\ngit.x86_64 2.39.0-1.fc38 @fedora\nvim.x86_64 9.0.1160-1.fc38 @fedora"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + version, err := dnfManager.GetPackageVersion("git") + + require.NoError(t, err) + require.Equal(t, "2.39.0-1.fc38", version) +} + +func Test_GetPackageVersion_ReturnsError_WhenPackageNotInstalled(t *testing.T) { + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + return &utils.Result{ + Stdout: []byte("Installed Packages\nvim.x86_64 9.0.1160-1.fc38 @fedora"), + }, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{} + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + version, err := dnfManager.GetPackageVersion("git") + + require.Error(t, err) + require.Contains(t, err.Error(), "package git is not installed") + require.Empty(t, version) +} + +func Test_InstallPackage_DiscardsOutput_WhenDisplayModeRequiresIt(t *testing.T) { + var capturedOptions []utils.Option + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + capturedOptions = options + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.InstallPackage(pkgmanager.NewRequestedPackageInfo("git", nil)) + + require.NoError(t, err) + require.Len(t, capturedOptions, 1) + // We can't directly test the option type, but we can verify it was passed +} + +func Test_UninstallPackage_DiscardsOutput_WhenDisplayModeRequiresIt(t *testing.T) { + var capturedOptions []utils.Option + mockCommander := &utils.MoqCommander{ + RunCommandFunc: func(command string, args []string, options ...utils.Option) (*utils.Result, error) { + capturedOptions = options + return &utils.Result{}, nil + }, + } + mockProgramQuery := &osmanager.MoqProgramQuery{} + mockEscalator := &privilege.MoqEscalator{ + EscalateCommandFunc: func(command string, args []string) (privilege.EscalationResult, error) { + return privilege.EscalationResult{Command: "sudo", Args: append([]string{"dnf"}, args...)}, nil + }, + } + + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, mockCommander, mockProgramQuery, mockEscalator, utils.DisplayModeProgress) + + err := dnfManager.UninstallPackage(pkgmanager.NewPackageInfo("git", "2.39.0")) + + require.NoError(t, err) + require.Len(t, capturedOptions, 1) + // We can't directly test the option type, but we can verify it was passed +} diff --git a/installer/lib/dnf/integration_test.go b/installer/lib/dnf/integration_test.go new file mode 100644 index 0000000..0f48a2f --- /dev/null +++ b/installer/lib/dnf/integration_test.go @@ -0,0 +1,175 @@ +package dnf_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MrPointer/dotfiles/installer/lib/dnf" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" + "github.com/MrPointer/dotfiles/installer/utils" + "github.com/MrPointer/dotfiles/installer/utils/logger" + "github.com/MrPointer/dotfiles/installer/utils/osmanager" + "github.com/MrPointer/dotfiles/installer/utils/privilege" +) + +func Test_DnfPackageManager_CanCheckIfPackageExists_Integration(t *testing.T) { + if !isDnfAvailable() { + t.Skip("DNF not available on this system") + } + + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + // Test with a commonly available package that should exist + packageInfo := pkgmanager.NewPackageInfo("bash", "") + isInstalled, err := dnfManager.IsPackageInstalled(packageInfo) + + require.NoError(t, err) + // bash is typically installed on all systems, but we don't assert the result + // since the test environment might vary + t.Logf("bash package installed: %v", isInstalled) +} + +func Test_DnfPackageManager_CanCheckIfGroupExists_Integration(t *testing.T) { + if !isDnfAvailable() { + t.Skip("DNF not available on this system") + } + + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + // Test with a commonly available group + groupInfo := pkgmanager.NewPackageInfoWithType("Development Tools", "", "group") + isInstalled, err := dnfManager.IsPackageInstalled(groupInfo) + + require.NoError(t, err) + // Development Tools group existence varies by system, but the check should not fail + t.Logf("Development Tools group installed: %v", isInstalled) +} + +func Test_DnfPackageManager_CanListInstalledPackages_Integration(t *testing.T) { + if !isDnfAvailable() { + t.Skip("DNF not available on this system") + } + + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + packages, err := dnfManager.ListInstalledPackages() + + require.NoError(t, err) + require.NotNil(t, packages) + + // On a typical system, there should be at least some packages installed + if len(packages) > 0 { + t.Logf("Found %d installed packages", len(packages)) + + // Verify package structure + firstPackage := packages[0] + require.NotEmpty(t, firstPackage.Name) + require.NotEmpty(t, firstPackage.Version) + + t.Logf("Example package: %s version %s", firstPackage.Name, firstPackage.Version) + } +} + +func Test_DnfPackageManager_CanGetManagerInfo_Integration(t *testing.T) { + if !isDnfAvailable() { + t.Skip("DNF not available on this system") + } + + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + info, err := dnfManager.GetInfo() + + require.NoError(t, err) + require.Equal(t, "dnf", info.Name) + require.NotEmpty(t, info.Version) + + t.Logf("DNF version: %s", info.Version) +} + +func Test_DnfPackageManager_PrerequisiteInstallationWorkflow_Integration(t *testing.T) { + if !isDnfAvailable() { + t.Skip("DNF not available on this system") + } + + // This test requires root privileges or sudo access + if os.Getuid() != 0 && !hasSudoAccess() { + t.Skip("This test requires root privileges or sudo access") + } + + defaultCommander := utils.NewDefaultCommander(logger.DefaultLogger) + defaultOsManager := osmanager.NewUnixOsManager(logger.DefaultLogger, defaultCommander, false) + + escalator := privilege.NewDefaultEscalator(logger.DefaultLogger, defaultCommander, defaultOsManager) + dnfManager := dnf.NewDnfPackageManager(logger.DefaultLogger, defaultCommander, defaultOsManager, escalator, utils.DisplayModeProgress) + + // Test with a lightweight package that's commonly available but might not be installed + testPackageName := "tree" + packageInfo := pkgmanager.NewPackageInfo(testPackageName, "") + requestedPackageInfo := pkgmanager.NewRequestedPackageInfo(testPackageName, nil) + + // Check if package is initially installed + initiallyInstalled, err := dnfManager.IsPackageInstalled(packageInfo) + require.NoError(t, err) + + // If not installed, install it + if !initiallyInstalled { + t.Logf("Installing test package: %s", testPackageName) + err = dnfManager.InstallPackage(requestedPackageInfo) + require.NoError(t, err) + + // Verify installation + isInstalled, err := dnfManager.IsPackageInstalled(packageInfo) + require.NoError(t, err) + require.True(t, isInstalled, "Package should be installed after installation") + + // Get package version + version, err := dnfManager.GetPackageVersion(testPackageName) + require.NoError(t, err) + require.NotEmpty(t, version) + t.Logf("Installed package version: %s", version) + + // Clean up by removing the package + t.Logf("Cleaning up test package: %s", testPackageName) + err = dnfManager.UninstallPackage(packageInfo) + require.NoError(t, err) + + // Verify removal + isInstalled, err = dnfManager.IsPackageInstalled(packageInfo) + require.NoError(t, err) + require.False(t, isInstalled, "Package should be removed after uninstallation") + } else { + t.Logf("Package %s is already installed, skipping install/uninstall test", testPackageName) + } +} + +// isDnfAvailable checks if DNF is available on the system +func isDnfAvailable() bool { + commander := utils.NewDefaultCommander(logger.DefaultLogger) + _, err := commander.RunCommand("which", []string{"dnf"}, utils.WithCaptureOutput()) + return err == nil +} + +// hasSudoAccess checks if the current user has sudo access +func hasSudoAccess() bool { + commander := utils.NewDefaultCommander(logger.DefaultLogger) + _, err := commander.RunCommand("sudo", []string{"-n", "true"}, utils.WithCaptureOutput()) + return err == nil +} diff --git a/installer/lib/packageresolver/integration_test.go b/installer/lib/packageresolver/integration_test.go index 812b813..fccd9b8 100644 --- a/installer/lib/packageresolver/integration_test.go +++ b/installer/lib/packageresolver/integration_test.go @@ -6,7 +6,9 @@ import ( "path/filepath" "testing" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" "github.com/MrPointer/dotfiles/installer/lib/packageresolver" + "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" "github.com/spf13/viper" "github.com/stretchr/testify/require" ) @@ -157,3 +159,70 @@ func Test_LoadPackageMappings_HandlesLargeConfigFile(t *testing.T) { require.Len(t, pkg49, len(packageManagers)) require.Equal(t, "apt-pkg-49", pkg49["apt"].Name) } + +func Test_Resolver_DistroSpecificMapping_WorksWithActualConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "distro-config.yaml") + + // Test with the actual distro-specific structure we implemented + configContent := `packages: + build-essential: + apt: + name: build-essential + dnf: + type: group + name: + fedora: development-tools + centos: "Development Tools" + rhel: "Development Tools"` + + err := os.WriteFile(configFile, []byte(configContent), 0644) + require.NoError(t, err) + + v := viper.New() + mappings, err := packageresolver.LoadPackageMappings(v, configFile) + require.NoError(t, err) + + testCases := []struct { + distroName string + expectedName string + }{ + {"fedora", "development-tools"}, + {"centos", "Development Tools"}, + {"rhel", "Development Tools"}, + } + + for _, tc := range testCases { + t.Run("resolves for "+tc.distroName, func(t *testing.T) { + // Create system info for the distro + sysInfo := &compatibility.SystemInfo{ + OSName: "linux", + DistroName: tc.distroName, + Arch: "amd64", + } + + // Create mock package manager + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + // Create resolver + resolver, err := packageresolver.NewResolver(mappings, mockPM, sysInfo) + require.NoError(t, err) + + // Resolve the package + result, err := resolver.Resolve("build-essential", "") + + require.NoError(t, err) + require.Equal(t, tc.expectedName, result.Name) + require.Equal(t, "group", result.Type) + require.Nil(t, result.VersionConstraints) + }) + } +} diff --git a/installer/lib/packageresolver/resolver.go b/installer/lib/packageresolver/resolver.go index fcd5527..d61ff9c 100644 --- a/installer/lib/packageresolver/resolver.go +++ b/installer/lib/packageresolver/resolver.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/Masterminds/semver" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" ) @@ -11,15 +12,18 @@ import ( type Resolver struct { mappings *PackageMappingCollection packageManagerName string // Normalized name like "apt", "brew" + systemInfo *compatibility.SystemInfo } var _ PackageManagerResolver = (*Resolver)(nil) // NewResolver creates a new package resolver. -// It requires the loaded package mappings and an active PackageManager to determine the current manager. +// It requires the loaded package mappings, an active PackageManager to determine the current manager, +// and system information for distro-specific mappings. func NewResolver( mappings *PackageMappingCollection, pm pkgmanager.PackageManager, + sysInfo *compatibility.SystemInfo, ) (*Resolver, error) { if mappings == nil { return nil, fmt.Errorf("package mappings cannot be nil") @@ -27,6 +31,9 @@ func NewResolver( if pm == nil { return nil, fmt.Errorf("package manager cannot be nil") } + if sysInfo == nil { + return nil, fmt.Errorf("system info cannot be nil") + } pmInfo, err := pm.GetInfo() if err != nil { @@ -36,6 +43,7 @@ func NewResolver( return &Resolver{ mappings: mappings, packageManagerName: pmInfo.Name, + systemInfo: sysInfo, }, nil } @@ -53,34 +61,31 @@ func (r *Resolver) Resolve( packageMapping, ok := r.mappings.Packages[genericPackageCode] if !ok { - // If no direct mapping, try to use the generic code as the package name itself. - // This allows users to specify packages not in the map, assuming the name is consistent. - // No version constraints can be applied in this case unless the package manager handles it. - var constraints *semver.Constraints - var err error - if versionConstraintString != "" { - constraints, err = semver.NewConstraint(versionConstraintString) - if err != nil { - return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("invalid version constraint string '%s' for package '%s': %w", versionConstraintString, genericPackageCode, err) - } - } - // Consider logging a warning here that a direct mapping was not found. - return pkgmanager.RequestedPackageInfo{ - Name: genericPackageCode, // Use the code as the name - VersionConstraints: constraints, - }, nil + // No mapping found for this package + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("no package mapping found for package '%s'", genericPackageCode) } var specificPackageName string + var packageType string managerSpecificCfg, managerFound := packageMapping[r.packageManagerName] - if managerFound && managerSpecificCfg.Name != "" { - specificPackageName = managerSpecificCfg.Name + if managerFound { + resolvedName, found := managerSpecificCfg.ResolvePackageName(r.systemInfo.DistroName) + if found && resolvedName != "" { + specificPackageName = resolvedName + packageType = managerSpecificCfg.Type + } else { + // Check if this package has distro-specific mappings + if r.hasDistroSpecificMappings(managerSpecificCfg) { + // Package requires specific distro handling but current distro is not mapped + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("package '%s' requires distro-specific mapping for '%s' distribution, but no mapping is defined", genericPackageCode, r.systemInfo.DistroName) + } + // No distro-specific mappings exist, but still no mapping for this package manager + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("no package mapping found for package '%s' on package manager '%s'", genericPackageCode, r.packageManagerName) + } } else { - // No specific name for this manager, fall back to generic code - specificPackageName = genericPackageCode - // Consider logging a warning here that a specific mapping was not found, - // and the generic code is being used as the package name. + // No mapping for this package manager + return pkgmanager.RequestedPackageInfo{}, fmt.Errorf("no package mapping found for package '%s' on package manager '%s'", genericPackageCode, r.packageManagerName) } var constraints *semver.Constraints @@ -94,10 +99,29 @@ func (r *Resolver) Resolve( return pkgmanager.RequestedPackageInfo{ Name: specificPackageName, + Type: packageType, VersionConstraints: constraints, // This will be nil if versionConstraintString was empty }, nil } +// hasDistroSpecificMappings checks if the ManagerSpecificMapping uses distro-specific name mappings. +func (r *Resolver) hasDistroSpecificMappings(cfg ManagerSpecificMapping) bool { + switch nameValue := cfg.Name.(type) { + case string: + // Simple string case - no distro-specific mappings + return false + case map[string]any: + // Map case - has distro-specific mappings + return len(nameValue) > 0 + case NameMapping: + // Direct NameMapping case - has distro-specific mappings + return len(nameValue) > 0 + default: + // Unsupported type - assume no distro-specific mappings + return false + } +} + // PackageManagerResolver defines the interface for resolving package information. // This is added here to allow for the var _ PackageManagerResolver = (*Resolver)(nil) check. type PackageManagerResolver interface { diff --git a/installer/lib/packageresolver/resolver_test.go b/installer/lib/packageresolver/resolver_test.go index 064d85d..8330517 100644 --- a/installer/lib/packageresolver/resolver_test.go +++ b/installer/lib/packageresolver/resolver_test.go @@ -5,11 +5,21 @@ import ( "testing" "github.com/Masterminds/semver" + "github.com/MrPointer/dotfiles/installer/lib/compatibility" "github.com/MrPointer/dotfiles/installer/lib/packageresolver" "github.com/MrPointer/dotfiles/installer/lib/pkgmanager" "github.com/stretchr/testify/require" ) +// createTestSystemInfo creates a basic SystemInfo for testing. +func createTestSystemInfo() *compatibility.SystemInfo { + return &compatibility.SystemInfo{ + OSName: "linux", + DistroName: "fedora", + Arch: "amd64", + } +} + func Test_NewResolver_CanCreateResolver_WithValidInputs(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: make(map[string]packageresolver.PackageMapping), @@ -20,7 +30,7 @@ func Test_NewResolver_CanCreateResolver_WithValidInputs(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) require.NotNil(t, resolver) @@ -33,7 +43,7 @@ func Test_NewResolver_ReturnsError_WhenMappingsIsNil(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(nil, mockPM) + resolver, err := packageresolver.NewResolver(nil, mockPM, createTestSystemInfo()) require.Error(t, err) require.Nil(t, resolver) @@ -45,13 +55,30 @@ func Test_NewResolver_ReturnsError_WhenPackageManagerIsNil(t *testing.T) { Packages: make(map[string]packageresolver.PackageMapping), } - resolver, err := packageresolver.NewResolver(mappings, nil) + resolver, err := packageresolver.NewResolver(mappings, nil, createTestSystemInfo()) require.Error(t, err) require.Nil(t, resolver) require.Contains(t, err.Error(), "package manager cannot be nil") } +func Test_NewResolver_ReturnsError_WhenSystemInfoIsNil(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: make(map[string]packageresolver.PackageMapping), + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "apt"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, nil) + + require.Error(t, err) + require.Nil(t, resolver) + require.Contains(t, err.Error(), "system info cannot be nil") +} + func Test_NewResolver_ReturnsError_WhenPackageManagerGetInfoFails(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: make(map[string]packageresolver.PackageMapping), @@ -62,7 +89,7 @@ func Test_NewResolver_ReturnsError_WhenPackageManagerGetInfoFails(t *testing.T) }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.Error(t, err) require.Nil(t, resolver) @@ -79,7 +106,7 @@ func Test_NewResolver_AcceptsAnyPackageManagerName(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) require.NotNil(t, resolver) @@ -103,12 +130,12 @@ func Test_NewResolver_UsesPackageManagerNameDirectly(t *testing.T) { pmName: "dnf", }, { - name: "uses pacman name directly", - pmName: "pacman", + name: "uses zypper name directly", + pmName: "zypper", }, { - name: "uses custom name directly", - pmName: "custom-pm", + name: "uses pacman name directly", + pmName: "pacman", }, } @@ -123,7 +150,7 @@ func Test_NewResolver_UsesPackageManagerNameDirectly(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) require.NotNil(t, resolver) @@ -141,7 +168,7 @@ func Test_Resolve_ReturnsError_WhenGenericPackageCodeIsEmpty(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("", "") @@ -154,9 +181,10 @@ func Test_Resolve_ReturnsError_WhenGenericPackageCodeIsEmpty(t *testing.T) { func Test_Resolve_UsesManagerSpecificName_WhenAvailable(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "neovim": { - "apt": {Name: "neovim"}, - "brew": {Name: "neovim"}, + "neovim": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "neovim", + }, }, }, } @@ -166,21 +194,24 @@ func Test_Resolve_UsesManagerSpecificName_WhenAvailable(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("neovim", "") require.NoError(t, err) require.Equal(t, "neovim", result.Name) + require.Equal(t, "", result.Type) require.Nil(t, result.VersionConstraints) } -func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameNotFound(t *testing.T) { +func Test_Resolve_ReturnsError_WhenManagerSpecificNameNotFound(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "nodejs": { - "brew": {Name: "node"}, + "nodejs": packageresolver.PackageMapping{ + "brew": packageresolver.ManagerSpecificMapping{ + Name: "node", + }, }, }, } @@ -190,17 +221,19 @@ func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameNotFound(t *test }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("nodejs", "") - require.NoError(t, err) - require.Equal(t, "nodejs", result.Name) // Falls back to generic code - require.Nil(t, result.VersionConstraints) + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "no package mapping found") + require.Contains(t, err.Error(), "nodejs") + require.Contains(t, err.Error(), "apt") } -func Test_Resolve_FallsBackToGenericCode_WhenNoMappingFound(t *testing.T) { +func Test_Resolve_ReturnsError_WhenNoMappingFound(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: make(map[string]packageresolver.PackageMapping), } @@ -210,21 +243,24 @@ func Test_Resolve_FallsBackToGenericCode_WhenNoMappingFound(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("unknown-package", "") - require.NoError(t, err) - require.Equal(t, "unknown-package", result.Name) - require.Nil(t, result.VersionConstraints) + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "no package mapping found") + require.Contains(t, err.Error(), "unknown-package") } -func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameIsEmpty(t *testing.T) { +func Test_Resolve_ReturnsError_WhenManagerSpecificNameIsEmpty(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "test-package": { - "apt": {Name: ""}, // Empty name + "test-package": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "", + }, }, }, } @@ -234,21 +270,25 @@ func Test_Resolve_FallsBackToGenericCode_WhenManagerSpecificNameIsEmpty(t *testi }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("test-package", "") - require.NoError(t, err) - require.Equal(t, "test-package", result.Name) - require.Nil(t, result.VersionConstraints) + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "no package mapping found") + require.Contains(t, err.Error(), "test-package") + require.Contains(t, err.Error(), "apt") } func Test_Resolve_ParsesVersionConstraints_WhenProvided(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "nodejs": { - "apt": {Name: "nodejs"}, + "nodejs": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "nodejs", + }, }, }, } @@ -258,7 +298,7 @@ func Test_Resolve_ParsesVersionConstraints_WhenProvided(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("nodejs", ">=16.0.0") @@ -277,8 +317,10 @@ func Test_Resolve_ParsesVersionConstraints_WhenProvided(t *testing.T) { func Test_Resolve_ReturnsError_WhenVersionConstraintIsInvalid(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "nodejs": { - "apt": {Name: "nodejs"}, + "nodejs": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "nodejs", + }, }, }, } @@ -288,7 +330,7 @@ func Test_Resolve_ReturnsError_WhenVersionConstraintIsInvalid(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("nodejs", "invalid-version-constraint") @@ -296,58 +338,43 @@ func Test_Resolve_ReturnsError_WhenVersionConstraintIsInvalid(t *testing.T) { require.Error(t, err) require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) require.Contains(t, err.Error(), "invalid version constraint string") - require.Contains(t, err.Error(), "invalid-version-constraint") - require.Contains(t, err.Error(), "nodejs") } func Test_Resolve_ParsesComplexVersionConstraints(t *testing.T) { testCases := []struct { - name string - constraint string - versionToTest string - expectedSatisfied bool + name string + constraint string + description string }{ { - name: "simple greater than constraint", - constraint: ">1.0.0", - versionToTest: "1.1.0", - expectedSatisfied: true, + name: "range constraint", + constraint: ">=16.0.0, <20.0.0", + description: "should parse range constraints", }, { - name: "simple greater than constraint not satisfied", - constraint: ">1.0.0", - versionToTest: "0.9.0", - expectedSatisfied: false, + name: "or constraint", + constraint: "^16.0.0 || ^18.0.0", + description: "should parse OR constraints", }, { - name: "range constraint satisfied", - constraint: ">=1.0.0, <2.0.0", - versionToTest: "1.5.0", - expectedSatisfied: true, + name: "exact version", + constraint: "18.17.1", + description: "should parse exact version constraints", }, { - name: "range constraint not satisfied", - constraint: ">=1.0.0, <2.0.0", - versionToTest: "2.1.0", - expectedSatisfied: false, + name: "tilde constraint", + constraint: "~16.14.0", + description: "should parse tilde constraints", }, { - name: "OR constraint satisfied by first part", - constraint: "<1.0.0 || >2.0.0", - versionToTest: "0.5.0", - expectedSatisfied: true, + name: "caret constraint", + constraint: "^16.0.0", + description: "should parse caret constraints", }, { - name: "OR constraint satisfied by second part", - constraint: "<1.0.0 || >2.0.0", - versionToTest: "3.0.0", - expectedSatisfied: true, - }, - { - name: "OR constraint not satisfied", - constraint: "<1.0.0 || >2.0.0", - versionToTest: "1.5.0", - expectedSatisfied: false, + name: "complex mixed constraint", + constraint: ">=14.0.0, <16.0.0 || >=16.14.0, <18.0.0 || >=18.12.0", + description: "should parse complex mixed constraints", }, } @@ -355,8 +382,10 @@ func Test_Resolve_ParsesComplexVersionConstraints(t *testing.T) { t.Run(tc.name, func(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "test-package": { - "apt": {Name: "test-pkg"}, + "test-package": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "test-package", + }, }, }, } @@ -366,60 +395,63 @@ func Test_Resolve_ParsesComplexVersionConstraints(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("test-package", tc.constraint) - require.NoError(t, err) - require.NotNil(t, result.VersionConstraints) + require.NoError(t, err, tc.description) + require.Equal(t, "test-package", result.Name) + require.NotNil(t, result.VersionConstraints, tc.description) - testVersion, err := semver.NewVersion(tc.versionToTest) + // Verify the constraint was parsed correctly by creating the same constraint + expectedConstraints, err := semver.NewConstraint(tc.constraint) require.NoError(t, err) - satisfied := result.VersionConstraints.Check(testVersion) - require.Equal(t, tc.expectedSatisfied, satisfied) + // Test with a sample version to ensure constraints work the same way + testVersion, _ := semver.NewVersion("16.0.0") + require.Equal(t, expectedConstraints.Check(testVersion), result.VersionConstraints.Check(testVersion), tc.description) }) } } func Test_Resolve_HandlesMultiplePackageManagers(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "nodejs": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "nodejs", + }, + "brew": packageresolver.ManagerSpecificMapping{ + Name: "node", + }, + "dnf": packageresolver.ManagerSpecificMapping{ + Name: "nodejs", + }, + }, + }, + } + testCases := []struct { name string packageManagerName string expectedPackageName string }{ { - name: "resolves for apt", + name: "apt manager uses nodejs", packageManagerName: "apt", expectedPackageName: "nodejs", }, { - name: "resolves for brew", + name: "brew manager uses node", packageManagerName: "brew", expectedPackageName: "node", }, { - name: "resolves for dnf", + name: "dnf manager uses nodejs", packageManagerName: "dnf", expectedPackageName: "nodejs", }, - { - name: "resolves for pacman", - packageManagerName: "pacman", - expectedPackageName: "nodejs", - }, - } - - mappings := &packageresolver.PackageMappingCollection{ - Packages: map[string]packageresolver.PackageMapping{ - "nodejs": { - "apt": {Name: "nodejs"}, - "brew": {Name: "node"}, - "dnf": {Name: "nodejs"}, - "pacman": {Name: "nodejs"}, - }, - }, } for _, tc := range testCases { @@ -430,55 +462,59 @@ func Test_Resolve_HandlesMultiplePackageManagers(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("nodejs", "") require.NoError(t, err) require.Equal(t, tc.expectedPackageName, result.Name) + require.Equal(t, "", result.Type) + require.Nil(t, result.VersionConstraints) }) } } func Test_Resolve_WorksWithRealWorldStructure(t *testing.T) { - // This test uses the actual structure from packagemap.yaml + // This test uses a structure that closely resembles the actual packagemap.yaml mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "git": { - "apt": {Name: "git"}, - "brew": {Name: "git"}, - }, - "gpg": { - "apt": {Name: "gnupg2"}, - "brew": {Name: "gnupg"}, - "dnf": {Name: "gnupg2"}, - }, - "neovim": { - "apt": {Name: "neovim"}, - "brew": {Name: "neovim"}, + "git": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "git", + }, + "brew": packageresolver.ManagerSpecificMapping{ + Name: "git", + }, + "dnf": packageresolver.ManagerSpecificMapping{ + Name: "git", + }, }, - "zsh": { - "apt": {Name: "zsh"}, - "brew": {Name: "zsh"}, + "neovim": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "neovim", + }, + "brew": packageresolver.ManagerSpecificMapping{ + Name: "neovim", + }, + "dnf": packageresolver.ManagerSpecificMapping{ + Name: "neovim", + }, }, }, } testCases := []struct { - packageManagerName string - packageCode string - expectedPackageName string + packageCode string + packageManagerName string + expectedName string }{ - {"apt", "git", "git"}, - {"brew", "git", "git"}, - {"apt", "gpg", "gnupg2"}, - {"brew", "gpg", "gnupg"}, - {"dnf", "gpg", "gnupg2"}, - {"apt", "neovim", "neovim"}, - {"brew", "neovim", "neovim"}, - {"apt", "zsh", "zsh"}, - {"brew", "zsh", "zsh"}, + {"git", "apt", "git"}, + {"git", "brew", "git"}, + {"git", "dnf", "git"}, + {"neovim", "apt", "neovim"}, + {"neovim", "brew", "neovim"}, + {"neovim", "dnf", "neovim"}, } for _, tc := range testCases { @@ -489,45 +525,290 @@ func Test_Resolve_WorksWithRealWorldStructure(t *testing.T) { }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve(tc.packageCode, "") require.NoError(t, err) - require.Equal(t, tc.expectedPackageName, result.Name) + require.Equal(t, tc.expectedName, result.Name) + require.Equal(t, "", result.Type) + require.Nil(t, result.VersionConstraints) }) } } -func Test_Resolve_HandlesPackageWithVersionConstraints_UsingRealWorldStructure(t *testing.T) { +func Test_Resolve_ParsesVersionConstraintsCorrectlyForMultiplePackages(t *testing.T) { mappings := &packageresolver.PackageMappingCollection{ Packages: map[string]packageresolver.PackageMapping{ - "git": { - "apt": {Name: "git"}, - "brew": {Name: "git"}, + "git": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "git", + }, }, }, } - mockPM := &pkgmanager.MoqPackageManager{ GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { return pkgmanager.PackageManagerInfo{Name: "apt"}, nil }, } - resolver, err := packageresolver.NewResolver(mappings, mockPM) + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) require.NoError(t, err) result, err := resolver.Resolve("git", ">=2.0.0") require.NoError(t, err) require.Equal(t, "git", result.Name) + require.Equal(t, "", result.Type) require.NotNil(t, result.VersionConstraints) - // Verify constraint works + // Test that the constraint works as expected version200, _ := semver.NewVersion("2.0.0") version100, _ := semver.NewVersion("1.0.0") require.True(t, result.VersionConstraints.Check(version200)) require.False(t, result.VersionConstraints.Check(version100)) } + +func Test_Resolve_RespectsTypeInfo_WhenProvided(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "build-tools": packageresolver.PackageMapping{ + "dnf": packageresolver.ManagerSpecificMapping{ + Name: "Development Tools", + Type: "group", + }, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) + require.NoError(t, err) + + result, err := resolver.Resolve("build-tools", "") + + require.NoError(t, err) + require.Equal(t, "Development Tools", result.Name) + require.Equal(t, "group", result.Type) + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_FallsBackToDirectMapping_WhenNoDistroSpecificFound(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "git": packageresolver.PackageMapping{ + "dnf": packageresolver.ManagerSpecificMapping{ + Name: "git", + Type: "", + }, + }, + }, + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, createTestSystemInfo()) + require.NoError(t, err) + + result, err := resolver.Resolve("git", "") + + require.NoError(t, err) + require.Equal(t, "git", result.Name) + require.Equal(t, "", result.Type) + require.Nil(t, result.VersionConstraints) +} + +func Test_Resolve_ReturnsError_WhenSimpleStringPackageHasNoMapping(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "simple-package": packageresolver.PackageMapping{ + "apt": packageresolver.ManagerSpecificMapping{ + Name: "apt-simple-package", + }, + // No DNF mapping + }, + }, + } + + // Test with DNF on any distro - should error since no mapping exists + sysInfo := &compatibility.SystemInfo{ + OSName: "linux", + DistroName: "fedora", + Arch: "amd64", + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, sysInfo) + require.NoError(t, err) + + result, err := resolver.Resolve("simple-package", "") + + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "no package mapping found") + require.Contains(t, err.Error(), "simple-package") + require.Contains(t, err.Error(), "dnf") +} + +func Test_Resolve_UsesDistroSpecificMapping_WhenAvailable(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "development-tools": packageresolver.PackageMapping{ + "dnf": packageresolver.ManagerSpecificMapping{ + Type: "group", + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + }, + }, + }, + }, + } + + testCases := []struct { + name string + distroName string + expectedName string + expectedType string + }{ + { + name: "uses fedora specific mapping", + distroName: "fedora", + expectedName: "development-tools", + expectedType: "group", + }, + { + name: "uses centos specific mapping", + distroName: "centos", + expectedName: "Development Tools", + expectedType: "group", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sysInfo := &compatibility.SystemInfo{ + OSName: "linux", + DistroName: tc.distroName, + Arch: "amd64", + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, sysInfo) + require.NoError(t, err) + + result, err := resolver.Resolve("development-tools", "") + + require.NoError(t, err) + require.Equal(t, tc.expectedName, result.Name) + require.Equal(t, tc.expectedType, result.Type) + require.Nil(t, result.VersionConstraints) + }) + } +} + +func Test_Resolve_ReturnsError_WhenDistroNotMappedForDistroSpecificPackage(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "development-tools": packageresolver.PackageMapping{ + "dnf": packageresolver.ManagerSpecificMapping{ + Type: "group", + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + }, + }, + }, + }, + } + + // Test with a distro that doesn't have specific mapping (should fail) + sysInfo := &compatibility.SystemInfo{ + OSName: "linux", + DistroName: "ubuntu", // No specific mapping for Ubuntu + Arch: "amd64", + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, sysInfo) + require.NoError(t, err) + + result, err := resolver.Resolve("development-tools", "") + + require.Error(t, err) + require.Equal(t, pkgmanager.RequestedPackageInfo{}, result) + require.Contains(t, err.Error(), "requires distro-specific mapping") + require.Contains(t, err.Error(), "ubuntu") +} + +func Test_Resolve_HandlesAllSupportedDistros(t *testing.T) { + mappings := &packageresolver.PackageMappingCollection{ + Packages: map[string]packageresolver.PackageMapping{ + "development-tools": packageresolver.PackageMapping{ + "dnf": packageresolver.ManagerSpecificMapping{ + Type: "group", + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + "rhel": "Development Tools", + }, + }, + }, + }, + } + + testCases := []struct { + distroName string + expectedName string + }{ + {"fedora", "development-tools"}, + {"centos", "Development Tools"}, + {"rhel", "Development Tools"}, + } + + for _, tc := range testCases { + t.Run("handles "+tc.distroName+" distro", func(t *testing.T) { + sysInfo := &compatibility.SystemInfo{ + OSName: "linux", + DistroName: tc.distroName, + Arch: "amd64", + } + mockPM := &pkgmanager.MoqPackageManager{ + GetInfoFunc: func() (pkgmanager.PackageManagerInfo, error) { + return pkgmanager.PackageManagerInfo{Name: "dnf"}, nil + }, + } + + resolver, err := packageresolver.NewResolver(mappings, mockPM, sysInfo) + require.NoError(t, err) + + result, err := resolver.Resolve("development-tools", "") + + require.NoError(t, err) + require.Equal(t, tc.expectedName, result.Name) + require.Equal(t, "group", result.Type) + require.Nil(t, result.VersionConstraints) + }) + } +} diff --git a/installer/lib/packageresolver/types.go b/installer/lib/packageresolver/types.go index d4d1c1a..2dbcfb4 100644 --- a/installer/lib/packageresolver/types.go +++ b/installer/lib/packageresolver/types.go @@ -11,7 +11,54 @@ type PackageMappingCollection struct { type PackageMapping map[string]ManagerSpecificMapping // ManagerSpecificMapping holds the actual package name for a specific package manager. +// The Name field can be either a string (single name for all distros) or a NameMapping (distro-specific names). type ManagerSpecificMapping struct { - // Name is the package name as recognized by the specific package manager. - Name string `mapstructure:"name"` + // Name can be either: + // - A string: single package name used for all distributions + // - A NameMapping: map of distribution-specific names + Name any `mapstructure:"name"` + + // Type is the package type (e.g., "group", "pattern"). Empty means regular package. + Type string `mapstructure:"type,omitempty"` +} + +// NameMapping holds distribution-specific package names. +// It supports only explicit distro mappings with no fallback behavior. +type NameMapping map[string]string + +// GetNameForDistro resolves the package name for a specific distribution. +// It only returns exact matches - no fallback behavior. +func (nm NameMapping) GetNameForDistro(distroName string) (string, bool) { + // Try exact distro match only + if name, exists := nm[distroName]; exists { + return name, true + } + + // No match found + return "", false +} + +// ResolvePackageName resolves the package name from ManagerSpecificMapping for a given distribution. +// It handles both string and NameMapping types for the Name field. +func (msm *ManagerSpecificMapping) ResolvePackageName(distroName string) (string, bool) { + switch nameValue := msm.Name.(type) { + case string: + // Simple string case - same name for all distros + return nameValue, nameValue != "" + case map[string]any: + // Convert to NameMapping for processing + nameMapping := make(NameMapping) + for k, v := range nameValue { + if str, ok := v.(string); ok { + nameMapping[k] = str + } + } + return nameMapping.GetNameForDistro(distroName) + case NameMapping: + // Direct NameMapping case + return nameValue.GetNameForDistro(distroName) + default: + // Unsupported type + return "", false + } } diff --git a/installer/lib/packageresolver/types_test.go b/installer/lib/packageresolver/types_test.go index c39e806..4244261 100644 --- a/installer/lib/packageresolver/types_test.go +++ b/installer/lib/packageresolver/types_test.go @@ -152,3 +152,179 @@ func Test_PackageMappingCollection_SupportsRealWorldStructure(t *testing.T) { require.Equal(t, "neovim", neovimMapping["apt"].Name) require.Equal(t, "neovim", neovimMapping["brew"].Name) } + +func Test_NameMapping_GetNameForDistro_ReturnsExactMatch_WhenDistroExists(t *testing.T) { + nameMapping := packageresolver.NameMapping{ + "fedora": "development-tools", + "centos": "Development Tools", + "ubuntu": "build-essential", + } + + name, found := nameMapping.GetNameForDistro("fedora") + require.True(t, found) + require.Equal(t, "development-tools", name) + + name, found = nameMapping.GetNameForDistro("centos") + require.True(t, found) + require.Equal(t, "Development Tools", name) + + name, found = nameMapping.GetNameForDistro("ubuntu") + require.True(t, found) + require.Equal(t, "build-essential", name) +} + +func Test_NameMapping_GetNameForDistro_ReturnsNotFound_WhenDistroNotMapped(t *testing.T) { + nameMapping := packageresolver.NameMapping{ + "fedora": "development-tools", + "centos": "Development Tools", + } + + // Should return not found for unmapped distro + name, found := nameMapping.GetNameForDistro("rhel") + require.False(t, found) + require.Equal(t, "", name) + + name, found = nameMapping.GetNameForDistro("unknown") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_NameMapping_GetNameForDistro_ReturnsNotFound_WhenNoMatchOrDefault(t *testing.T) { + nameMapping := packageresolver.NameMapping{ + "fedora": "development-tools", + "centos": "Development Tools", + } + + name, found := nameMapping.GetNameForDistro("unknown-distro") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_NameMapping_GetNameForDistro_ReturnsExactMatchOnly(t *testing.T) { + nameMapping := packageresolver.NameMapping{ + "fedora": "development-tools", + "centos": "Development Tools", + } + + // Should return exact matches only + name, found := nameMapping.GetNameForDistro("fedora") + require.True(t, found) + require.Equal(t, "development-tools", name) + + name, found = nameMapping.GetNameForDistro("centos") + require.True(t, found) + require.Equal(t, "Development Tools", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesStringName(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: "git", + Type: "", + } + + name, found := mapping.ResolvePackageName("fedora") + require.True(t, found) + require.Equal(t, "git", name) + + name, found = mapping.ResolvePackageName("centos") + require.True(t, found) + require.Equal(t, "git", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesMapName(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + }, + Type: "group", + } + + name, found := mapping.ResolvePackageName("fedora") + require.True(t, found) + require.Equal(t, "development-tools", name) + + name, found = mapping.ResolvePackageName("centos") + require.True(t, found) + require.Equal(t, "Development Tools", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesMapNameWithoutFallback(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + }, + Type: "group", + } + + name, found := mapping.ResolvePackageName("fedora") + require.True(t, found) + require.Equal(t, "development-tools", name) + + // Should not find unmapped distro + name, found = mapping.ResolvePackageName("rhel") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_ReturnsNotFound_WhenNoMatch(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: map[string]any{ + "fedora": "development-tools", + "centos": "Development Tools", + }, + Type: "group", + } + + name, found := mapping.ResolvePackageName("unknown-distro") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesEmptyString(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: "", + Type: "", + } + + name, found := mapping.ResolvePackageName("fedora") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesNilName(t *testing.T) { + mapping := packageresolver.ManagerSpecificMapping{ + Name: nil, + Type: "", + } + + name, found := mapping.ResolvePackageName("fedora") + require.False(t, found) + require.Equal(t, "", name) +} + +func Test_ManagerSpecificMapping_ResolvePackageName_HandlesDirectNameMapping(t *testing.T) { + nameMapping := packageresolver.NameMapping{ + "fedora": "development-tools", + "centos": "Development Tools", + } + + mapping := packageresolver.ManagerSpecificMapping{ + Name: nameMapping, + Type: "group", + } + + name, found := mapping.ResolvePackageName("fedora") + require.True(t, found) + require.Equal(t, "development-tools", name) + + name, found = mapping.ResolvePackageName("centos") + require.True(t, found) + require.Equal(t, "Development Tools", name) + + // Should not find unmapped distro + name, found = mapping.ResolvePackageName("rhel") + require.False(t, found) + require.Equal(t, "", name) +} diff --git a/installer/lib/pkgmanager/info.go b/installer/lib/pkgmanager/info.go index 9b42bde..e3e6227 100644 --- a/installer/lib/pkgmanager/info.go +++ b/installer/lib/pkgmanager/info.go @@ -30,6 +30,9 @@ type PackageInfo struct { // Version of the package. Version string `json:"version"` + + // Type of the package (e.g., "group", "pattern"). Empty means regular package. + Type string `json:"type,omitempty"` } func NewPackageInfo(name, version string) PackageInfo { @@ -39,10 +42,21 @@ func NewPackageInfo(name, version string) PackageInfo { } } +func NewPackageInfoWithType(name, version, packageType string) PackageInfo { + return PackageInfo{ + Name: name, + Version: version, + Type: packageType, + } +} + type RequestedPackageInfo struct { // Name of the package. Name string `json:"name"` + // Type of the package (e.g., "group", "pattern"). Empty means regular package. + Type string `json:"type,omitempty"` + // VersionConstraints defines the semver constraints for the requested package. // It's a pointer to allow for nil (no constraints). VersionConstraints *semver.Constraints `json:"version_constraint,omitempty"` @@ -54,3 +68,11 @@ func NewRequestedPackageInfo(name string, versionConstraints *semver.Constraints VersionConstraints: versionConstraints, } } + +func NewRequestedPackageInfoWithType(name, packageType string, versionConstraints *semver.Constraints) RequestedPackageInfo { + return RequestedPackageInfo{ + Name: name, + Type: packageType, + VersionConstraints: versionConstraints, + } +}