zenops 0.18.0

Declarative system configuration management for shell config and dotfiles.
Documentation
//! Tiny abstraction over the host's package manager.
//!
//! Managers split into two roles:
//!
//! - **Primary** — the manager `detect` picks as "the" install path.
//!   Brew (PATH-probed) when present, otherwise the native manager
//!   derived from the host [`Os`] (DNF5 on Fedora 42).
//! - **Supplementary** — managers that don't replace the primary but
//!   cover gaps. Cargo (PATH-probed) is the only one today: it
//!   installs Rust crates the system manager doesn't ship.
//!
//! Per-pkg output shows a hint line for every detected manager that has
//! packages for that pkg; the aggregate footer is emitted once per
//! detected manager with eligible missing pkgs.
//!
//! Naming: `Dnf5` is the internal variant (pinning the DNF5 syntax that
//! the `[install.dnf5]` package list is validated against), but
//! [`DetectedPackageManager::name`] returns `"dnf"` for human output —
//! schema-internal vs user-facing naming, by deliberate split.
//!
//! Adding a new manager means extending the enum, [`detect`] or
//! [`detect_supplementary`], [`DetectedPackageManager::packages_for`],
//! [`DetectedPackageManager::install_command`], and the matching
//! `InstallHint` field.

use crate::{
    config::pkg::InstallHint,
    error::Error,
    os::{Distro, Os},
};

/// A package manager zenops will use to install pkg dependencies on the
/// current host. Each variant pins both the install-command grammar and
/// the matching `InstallHint` field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectedPackageManager {
    /// Homebrew (macOS or Linuxbrew). Add-on; detected by `PATH` probe.
    /// Primary role — when present, supersedes the native manager.
    Brew,
    /// DNF5 — Fedora 41+ default. Derived from the host `Os`, not
    /// probed, since DNF5 is guaranteed by the base system on supported
    /// Fedora releases. Internal name `Dnf5` because the field name and
    /// schema pin the syntax version; user-facing label is `"dnf"`.
    /// Primary role on Fedora hosts.
    Dnf5,
    /// Apt — Debian / Ubuntu default. Derived from the host `Os`, not
    /// probed; the install command uses the friendly `apt` wrapper
    /// (modern user-facing form) rather than the lower-level `apt-get`.
    /// Primary role on Ubuntu hosts.
    Apt,
    /// Pacman — Arch Linux default. Derived from the host `Os`, not
    /// probed. Install command uses `pacman -S <pkgs>` (Sync subcommand
    /// is the standard install invocation).
    /// Primary role on Arch hosts.
    Pacman,
    /// `cargo install` for Rust crates. Add-on; detected by `PATH`
    /// probe. Supplementary role: never primary (most pkgs aren't
    /// crates), but fills gaps for Rust tools missing from the system
    /// repos (e.g. `skim` on Fedora 42).
    Cargo,
    // Future package managers (zypper, apk, …) should be added here,
    // wired into `detect` / `detect_supplementary` (probed or Os-derived
    // as appropriate), and threaded through `packages_for` /
    // `install_command` / `InstallHint`.
}

impl DetectedPackageManager {
    /// Stable lowercase identifier for human output, JSON events, and
    /// the `Install eligible via <name>:` footer (e.g. `"brew"`,
    /// `"dnf"`, `"cargo"`). For DNF, this is `"dnf"` even though the
    /// internal variant is `Dnf5` — the schema/version pin is internal,
    /// the user-facing label is the friendly form.
    pub fn name(self) -> &'static str {
        match self {
            Self::Brew => "brew",
            Self::Dnf5 => "dnf",
            Self::Apt => "apt",
            Self::Pacman => "pacman",
            Self::Cargo => "cargo",
        }
    }

    /// Packages this manager would install for the given install hint.
    /// Returns an empty slice when the hint declares no packages for this
    /// manager — `packages = []` is the explicit signal that the pkg has
    /// no install path via this manager.
    pub fn packages_for(self, hint: &InstallHint) -> &[String] {
        match self {
            Self::Brew => &hint.brew.packages,
            Self::Dnf5 => &hint.dnf5.packages,
            Self::Apt => &hint.apt.packages,
            Self::Pacman => &hint.pacman.packages,
            Self::Cargo => &hint.cargo.packages,
        }
    }

    /// Build the one-shot command that installs the given packages via
    /// this manager. DNF uses the `dnf` alias (DNF5 on Fedora 42, but
    /// the alias is what people type); apt uses the friendly `apt`
    /// wrapper rather than `apt-get`; pacman's install subcommand is
    /// `-S` (Sync); cargo install handles multiple crates
    /// space-separated.
    pub fn install_command(self, packages: &[String]) -> String {
        match self {
            Self::Brew => format!("brew install {}", packages.join(" ")),
            Self::Dnf5 => format!("sudo dnf install {}", packages.join(" ")),
            Self::Apt => format!("sudo apt install {}", packages.join(" ")),
            Self::Pacman => format!("sudo pacman -S {}", packages.join(" ")),
            Self::Cargo => format!("cargo install {}", packages.join(" ")),
        }
    }
}

/// Pick the *primary* package manager for this host. Brew, when present,
/// wins — a user who has installed brew has signaled they want it. On a
/// Linux host without brew, fall back to [`detect_native`] for the
/// distro-shipped manager.
///
/// Cargo is *never* returned here even when it's on PATH — see
/// [`detect_supplementary`] for managers that cover gaps the primary
/// can't fill rather than replacing it.
pub fn detect(os: &Os) -> Result<Option<DetectedPackageManager>, Error> {
    if crate::utils::which::exists("brew")? {
        return Ok(Some(DetectedPackageManager::Brew));
    }
    Ok(detect_native(os))
}

/// The manager shipped by the host OS itself, with no PATH probing.
/// Split out from [`detect`] so the Fedora-fallback logic is testable
/// without depending on whether brew is installed on the test machine.
///
/// Macos returns `None` today because no native manager is modeled — brew
/// is technically an add-on on macOS too.
pub fn detect_native(os: &Os) -> Option<DetectedPackageManager> {
    match os {
        Os::Macos => None,
        Os::Linux(linux) => match linux.distro {
            Distro::Fedora(_) => Some(DetectedPackageManager::Dnf5),
            Distro::Ubuntu(_) => Some(DetectedPackageManager::Apt),
            Distro::Arch => Some(DetectedPackageManager::Pacman),
        },
    }
}

/// Additional managers present on PATH that complement the primary —
/// today, just cargo. Returned in display order. A pkg's `cargo` hint
/// surfaces only when this list contains [`DetectedPackageManager::Cargo`];
/// without cargo on PATH, recommending `cargo install foo` would be a
/// dead end.
pub fn detect_supplementary(_os: &Os) -> Result<Vec<DetectedPackageManager>, Error> {
    let mut out = Vec::new();
    if crate::utils::which::exists("cargo")? {
        out.push(DetectedPackageManager::Cargo);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::pkg::{AptHint, BrewHint, CargoHint, Dnf5Hint, PacmanHint};
    use crate::os::{Distro, Fedora, FedoraVersion, Linux};

    fn fedora42() -> Os {
        Os::Linux(Linux {
            distro: Distro::Fedora(Fedora {
                version: FedoraVersion::F42,
            }),
        })
    }

    #[test]
    fn detect_native_returns_dnf5_for_fedora_42() {
        assert_eq!(
            detect_native(&fedora42()),
            Some(DetectedPackageManager::Dnf5)
        );
    }

    #[test]
    fn detect_native_returns_none_for_macos() {
        assert_eq!(detect_native(&Os::Macos), None);
    }

    #[test]
    fn dnf5_user_facing_name_drops_version_suffix() {
        // Internal variant is `Dnf5` (schema pin); user-facing label is
        // `"dnf"` — the version suffix lives in `[install.dnf5]` and
        // serialization, not in human output.
        assert_eq!(DetectedPackageManager::Dnf5.name(), "dnf");
    }

    #[test]
    fn cargo_name_and_install_command() {
        assert_eq!(DetectedPackageManager::Cargo.name(), "cargo");
        let pkgs = vec!["skim".into(), "starship".into()];
        assert_eq!(
            DetectedPackageManager::Cargo.install_command(&pkgs),
            "cargo install skim starship",
        );
    }

    #[test]
    fn packages_for_each_manager_reads_its_own_field() {
        let hint = InstallHint {
            brew: BrewHint {
                packages: vec!["sk".into()],
            },
            dnf5: Dnf5Hint {
                packages: vec!["starship".into()],
            },
            apt: AptHint {
                packages: vec!["starship".into()],
            },
            pacman: PacmanHint {
                packages: vec!["skim".into()],
            },
            cargo: CargoHint {
                packages: vec!["skim".into()],
            },
        };
        assert_eq!(DetectedPackageManager::Brew.packages_for(&hint), ["sk"]);
        assert_eq!(
            DetectedPackageManager::Dnf5.packages_for(&hint),
            ["starship"],
        );
        assert_eq!(
            DetectedPackageManager::Apt.packages_for(&hint),
            ["starship"],
        );
        assert_eq!(DetectedPackageManager::Pacman.packages_for(&hint), ["skim"],);
        assert_eq!(DetectedPackageManager::Cargo.packages_for(&hint), ["skim"]);
    }

    #[test]
    fn packages_for_empty_returns_empty_slice() {
        let hint = InstallHint::default();
        assert!(DetectedPackageManager::Brew.packages_for(&hint).is_empty());
        assert!(DetectedPackageManager::Dnf5.packages_for(&hint).is_empty());
        assert!(DetectedPackageManager::Apt.packages_for(&hint).is_empty());
        assert!(
            DetectedPackageManager::Pacman
                .packages_for(&hint)
                .is_empty()
        );
        assert!(DetectedPackageManager::Cargo.packages_for(&hint).is_empty());
    }

    #[test]
    fn install_command_joins_packages_with_spaces() {
        let pkgs = vec!["sk".into(), "starship".into()];
        assert_eq!(
            DetectedPackageManager::Brew.install_command(&pkgs),
            "brew install sk starship"
        );
        assert_eq!(
            DetectedPackageManager::Dnf5.install_command(&pkgs),
            "sudo dnf install sk starship"
        );
        assert_eq!(
            DetectedPackageManager::Pacman.install_command(&pkgs),
            "sudo pacman -S sk starship"
        );
        assert_eq!(
            DetectedPackageManager::Cargo.install_command(&pkgs),
            "cargo install sk starship"
        );
    }
}