use std::path::{Path, PathBuf};
use smol_str::SmolStr;
use super::identity::Identity;
use crate::utils::which::{self, SearchPath};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageManager {
name: SmolStr,
binary: PathBuf,
needs_sudo: bool,
install_cmd_template: SmolStr,
}
impl PackageManager {
pub fn name(&self) -> &str {
&self.name
}
pub fn binary(&self) -> &Path {
&self.binary
}
pub fn needs_sudo(&self) -> bool {
self.needs_sudo
}
pub fn install_command(&self, packages: &[String]) -> String {
let pkgs = packages.join(" ");
let rendered = self.install_cmd_template.replace("{pkgs}", &pkgs);
if self.needs_sudo {
format!("sudo {rendered}")
} else {
rendered
}
}
}
#[derive(Debug, Clone, Copy)]
struct Grammar {
name: &'static str,
binary_name: &'static str,
needs_sudo: bool,
install_cmd_template: &'static str,
role: Role,
native_ids: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Preferred,
Native,
Supplementary,
}
const GRAMMARS: &[Grammar] = &[
Grammar {
name: "brew",
binary_name: "brew",
needs_sudo: false,
install_cmd_template: "brew install {pkgs}",
role: Role::Preferred,
native_ids: &[],
},
Grammar {
name: "dnf",
binary_name: "dnf",
needs_sudo: true,
install_cmd_template: "dnf install {pkgs}",
role: Role::Native,
native_ids: &["fedora", "rhel", "centos", "rocky", "almalinux"],
},
Grammar {
name: "apt",
binary_name: "apt",
needs_sudo: true,
install_cmd_template: "apt install {pkgs}",
role: Role::Native,
native_ids: &["debian", "ubuntu"],
},
Grammar {
name: "pacman",
binary_name: "pacman",
needs_sudo: true,
install_cmd_template: "pacman -S {pkgs}",
role: Role::Native,
native_ids: &["arch"],
},
Grammar {
name: "cargo",
binary_name: "cargo",
needs_sudo: false,
install_cmd_template: "cargo install {pkgs}",
role: Role::Supplementary,
native_ids: &[],
},
];
pub fn detect_all(
path: &SearchPath,
identity: &Identity,
) -> Result<(Option<PackageManager>, Vec<PackageManager>), which::Error> {
let mut found: Vec<(Role, PackageManager)> = Vec::new();
for g in GRAMMARS {
let Some(binary) = which::get_path(g.binary_name, path)? else {
continue;
};
found.push((
g.role,
PackageManager {
name: SmolStr::new_static(g.name),
binary,
needs_sudo: g.needs_sudo,
install_cmd_template: SmolStr::new_static(g.install_cmd_template),
},
));
}
let primary = pick_primary(&found, identity).map(|i| found.remove(i).1);
let supplementary = found.into_iter().map(|(_, pm)| pm).collect();
Ok((primary, supplementary))
}
fn pick_primary(found: &[(Role, PackageManager)], identity: &Identity) -> Option<usize> {
if let Some(i) = found.iter().position(|(r, _)| *r == Role::Preferred) {
return Some(i);
}
if let Some(native) = native_for(identity)
&& let Some(i) = found.iter().position(|(_, pm)| pm.name() == native)
{
return Some(i);
}
found.iter().position(|(r, _)| *r == Role::Native)
}
fn native_for(identity: &Identity) -> Option<&'static str> {
let claims = |id: &str| {
GRAMMARS
.iter()
.find(|g| g.native_ids.contains(&id))
.map(|g| g.name)
};
if let Some(id) = identity.distro_id.as_deref()
&& let Some(name) = claims(id)
{
return Some(name);
}
identity.distro_id_like.iter().find_map(|l| claims(l))
}
const BREW_PREFIX_CANDIDATES: &[&str] =
&["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"];
pub fn brew_prefix(path_found: Option<&Path>) -> Result<Option<PathBuf>, super::Error> {
brew_prefix_among(path_found, BREW_PREFIX_CANDIDATES)
}
fn brew_prefix_among(
path_found: Option<&Path>,
candidates: &[&str],
) -> Result<Option<PathBuf>, super::Error> {
let derived = path_found.and_then(|b| b.parent()?.parent().map(Path::to_path_buf));
if let Some(p) = &derived
&& candidates.iter().any(|c| Path::new(c) == p)
{
return Ok(Some(p.clone()));
}
for prefix in candidates.iter().map(Path::new) {
let brew = prefix.join("bin/brew");
if brew
.try_exists()
.map_err(|e| super::Error::BrewProbe(brew.clone(), e))?
{
return Ok(Some(prefix.to_path_buf()));
}
}
Ok(derived)
}
#[cfg(test)]
pub fn for_test(name: &str, binary: PathBuf) -> PackageManager {
let g = GRAMMARS
.iter()
.find(|g| g.name == name)
.unwrap_or_else(|| panic!("unknown manager name in for_test: {name}"));
PackageManager {
name: SmolStr::new_static(g.name),
binary,
needs_sudo: g.needs_sudo,
install_cmd_template: SmolStr::new_static(g.install_cmd_template),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pm(name: &str) -> PackageManager {
for_test(name, PathBuf::from(format!("/usr/bin/{name}")))
}
fn candidates(root: &Path, names: &[&str]) -> Vec<String> {
names
.iter()
.map(|n| root.join(n).to_string_lossy().into_owned())
.collect()
}
fn install_brew(prefix: &Path) -> PathBuf {
let bin = prefix.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let brew = bin.join("brew");
std::fs::write(&brew, "#!/bin/sh\n").unwrap();
brew
}
#[test]
fn install_command_brew_no_sudo() {
let pkgs = vec!["ripgrep".into(), "fd".into()];
assert_eq!(pm("brew").install_command(&pkgs), "brew install ripgrep fd");
}
#[test]
fn install_command_dnf_with_sudo() {
let pkgs = vec!["ripgrep".into()];
assert_eq!(pm("dnf").install_command(&pkgs), "sudo dnf install ripgrep");
}
#[test]
fn install_command_pacman_uses_sync_flag() {
let pkgs = vec!["skim".into()];
assert_eq!(pm("pacman").install_command(&pkgs), "sudo pacman -S skim");
}
#[test]
fn install_command_apt_with_sudo() {
let pkgs = vec!["fd-find".into()];
assert_eq!(pm("apt").install_command(&pkgs), "sudo apt install fd-find");
}
#[test]
fn install_command_cargo_no_sudo() {
let pkgs = vec!["skim".into(), "starship".into()];
assert_eq!(
pm("cargo").install_command(&pkgs),
"cargo install skim starship"
);
}
#[test]
fn brew_prefix_derived_from_binary_on_a_standard_root() {
let dir = tempfile::tempdir().unwrap();
let cands = candidates(dir.path(), &["opt/homebrew", "usr/local"]);
let refs: Vec<&str> = cands.iter().map(String::as_str).collect();
let brew = install_brew(Path::new(&cands[0]));
assert_eq!(
brew_prefix_among(Some(&brew), &refs).unwrap(),
Some(PathBuf::from(&cands[0]))
);
}
#[test]
fn brew_prefix_found_by_probe_when_brew_is_not_on_path() {
let dir = tempfile::tempdir().unwrap();
let cands = candidates(dir.path(), &["opt/homebrew", "usr/local"]);
let refs: Vec<&str> = cands.iter().map(String::as_str).collect();
install_brew(Path::new(&cands[0]));
assert_eq!(
brew_prefix_among(None, &refs).unwrap(),
Some(PathBuf::from(&cands[0]))
);
}
#[test]
fn brew_prefix_prefers_a_standard_root_over_a_path_shim() {
let dir = tempfile::tempdir().unwrap();
let cands = candidates(dir.path(), &["opt/homebrew"]);
let refs: Vec<&str> = cands.iter().map(String::as_str).collect();
install_brew(Path::new(&cands[0]));
let shim = install_brew(&dir.path().join("home/user/.local"));
assert_eq!(
brew_prefix_among(Some(&shim), &refs).unwrap(),
Some(PathBuf::from(&cands[0]))
);
}
#[test]
fn brew_prefix_falls_back_to_a_custom_install_on_path() {
let dir = tempfile::tempdir().unwrap();
let cands = candidates(dir.path(), &["opt/homebrew"]);
let refs: Vec<&str> = cands.iter().map(String::as_str).collect();
let custom = dir.path().join("srv/brew");
let brew = install_brew(&custom);
assert_eq!(brew_prefix_among(Some(&brew), &refs).unwrap(), Some(custom));
}
#[test]
fn brew_prefix_none_without_brew_anywhere() {
let dir = tempfile::tempdir().unwrap();
let cands = candidates(dir.path(), &["opt/homebrew", "usr/local"]);
let refs: Vec<&str> = cands.iter().map(String::as_str).collect();
assert_eq!(brew_prefix_among(None, &refs).unwrap(), None);
}
#[test]
fn detect_all_empty_search_path_yields_nothing() {
let path = SearchPath::new(Vec::<PathBuf>::new());
let (primary, supp) = detect_all(&path, &Identity::default()).unwrap();
assert!(primary.is_none());
assert!(supp.is_empty());
}
#[cfg(unix)]
fn path_with(dir: &Path, binaries: &[&str]) -> SearchPath {
use std::os::unix::fs::PermissionsExt;
for name in binaries {
let bin = dir.join(name);
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
SearchPath::new(vec![dir.to_path_buf()])
}
fn identity(id: &str, id_like: &[&str]) -> Identity {
Identity {
distro_id: Some(SmolStr::new(id)),
distro_id_like: id_like.iter().map(|s| SmolStr::new(*s)).collect(),
distro_version_id: None,
}
}
#[cfg(unix)]
#[test]
fn brew_and_native_both_surface_with_brew_primary() {
let dir = tempfile::tempdir().unwrap();
let path = path_with(dir.path(), &["brew", "dnf"]);
let (primary, supp) = detect_all(&path, &identity("fedora", &[])).unwrap();
assert_eq!(primary.as_ref().map(PackageManager::name), Some("brew"));
let supp: Vec<&str> = supp.iter().map(PackageManager::name).collect();
assert!(
supp.contains(&"dnf"),
"native dnf should ride along as supplementary, got {supp:?}",
);
}
#[cfg(unix)]
#[test]
fn primary_follows_host_identity_not_registry_order() {
let dir = tempfile::tempdir().unwrap();
let path = path_with(dir.path(), &["dnf", "apt"]);
let (primary, supp) = detect_all(&path, &identity("ubuntu", &["debian"])).unwrap();
assert_eq!(primary.as_ref().map(PackageManager::name), Some("apt"));
let supp: Vec<&str> = supp.iter().map(PackageManager::name).collect();
assert_eq!(supp, vec!["dnf"], "dnf still surfaces, just not primary");
}
#[cfg(unix)]
#[test]
fn primary_falls_back_to_registry_order_for_an_unclaimed_distro() {
let dir = tempfile::tempdir().unwrap();
let path = path_with(dir.path(), &["dnf", "apt"]);
let (primary, _) = detect_all(&path, &identity("haiku", &[])).unwrap();
assert_eq!(primary.as_ref().map(PackageManager::name), Some("dnf"));
}
#[cfg(unix)]
#[test]
fn cargo_alone_is_never_primary() {
let dir = tempfile::tempdir().unwrap();
let path = path_with(dir.path(), &["cargo"]);
let (primary, supp) = detect_all(&path, &identity("fedora", &[])).unwrap();
assert!(primary.is_none());
assert_eq!(
supp.iter().map(PackageManager::name).collect::<Vec<_>>(),
vec!["cargo"]
);
}
#[test]
fn native_for_matches_id_then_id_like() {
assert_eq!(native_for(&identity("fedora", &[])), Some("dnf"));
assert_eq!(native_for(&identity("debian", &[])), Some("apt"));
assert_eq!(native_for(&identity("arch", &[])), Some("pacman"));
assert_eq!(
native_for(&identity("pop", &["ubuntu", "debian"])),
Some("apt")
);
assert_eq!(
native_for(&identity("endeavouros", &["arch"])),
Some("pacman")
);
assert_eq!(native_for(&identity("bazzite", &["fedora"])), Some("dnf"));
assert_eq!(native_for(&identity("haiku", &[])), None);
assert_eq!(native_for(&Identity::default()), None);
}
#[test]
fn registry_has_no_duplicate_names() {
let mut names: Vec<&str> = GRAMMARS.iter().map(|g| g.name).collect();
names.sort();
let mut dedup = names.clone();
dedup.dedup();
assert_eq!(names, dedup, "duplicate manager names in GRAMMARS");
}
}