use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use smol_str::SmolStr;
use crate::utils::which::{self, SearchPath};
mod identity;
mod manager;
mod pattern;
pub use manager::PackageManager;
pub use pattern::OsPattern;
#[cfg(test)]
pub use identity::Identity;
#[cfg(test)]
pub use manager::for_test as manager_for_test;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Which(#[from] which::Error),
#[error("Failed to probe for brew at {0:?}: {1}")]
BrewProbe(PathBuf, #[source] std::io::Error),
}
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Shell {
Bash,
Zsh,
}
impl Shell {
pub fn from_basename(name: &str) -> Option<Self> {
match name {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
}
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OsFamily {
Linux,
Macos,
Other(SmolStr),
}
impl OsFamily {
fn detect() -> Self {
match std::env::consts::OS {
"linux" => OsFamily::Linux,
"macos" => OsFamily::Macos,
other => OsFamily::Other(SmolStr::new_static(other)),
}
}
pub fn as_token(&self) -> &str {
match self {
OsFamily::Linux => "linux",
OsFamily::Macos => "macos",
OsFamily::Other(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone)]
pub struct Platform {
home: PathBuf,
config_dir: PathBuf,
zenops_dir: PathBuf,
path: SearchPath,
arch: SmolStr,
os_family: OsFamily,
distro_id: Option<SmolStr>,
distro_id_like: Vec<SmolStr>,
distro_version_id: Option<SmolStr>,
hostname: SmolStr,
primary_pkg_manager: Option<PackageManager>,
supplementary_pkg_managers: Vec<PackageManager>,
brew_prefix: Option<PathBuf>,
detected_shell: Option<Shell>,
}
impl Platform {
pub fn detect(home: PathBuf, path: SearchPath) -> Result<Self, Error> {
let config_dir = home.join(".config");
let zenops_dir = home.join(".config/zenops");
let os_family = OsFamily::detect();
let identity = if matches!(os_family, OsFamily::Linux) {
identity::Identity::detect()
} else {
identity::Identity::default()
};
let arch = SmolStr::new_static(std::env::consts::ARCH);
let hostname = SmolStr::new(gethostname::gethostname().to_string_lossy());
let detected_shell = detect_shell_from_env();
let (primary_pkg_manager, supplementary_pkg_managers) =
manager::detect_all(&path, &identity)?;
let brew_on_path = primary_pkg_manager
.iter()
.chain(supplementary_pkg_managers.iter())
.find(|m| m.name() == "brew");
let brew_prefix = manager::brew_prefix(brew_on_path.map(PackageManager::binary))?;
Ok(Self {
home,
config_dir,
zenops_dir,
path,
arch,
os_family,
distro_id: identity.distro_id,
distro_id_like: identity.distro_id_like,
distro_version_id: identity.distro_version_id,
hostname,
primary_pkg_manager,
supplementary_pkg_managers,
brew_prefix,
detected_shell,
})
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn resolve_home_tilde(&self, path: &str) -> String {
if path == "~" {
self.home.to_string_lossy().into_owned()
} else if let Some(rest) = path.strip_prefix("~/") {
format!("{}/{rest}", self.home.display())
} else {
path.to_string()
}
}
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
pub fn zenops_dir(&self) -> &Path {
&self.zenops_dir
}
pub fn search_path(&self) -> &SearchPath {
&self.path
}
pub fn os_family(&self) -> &OsFamily {
&self.os_family
}
pub fn distro_id(&self) -> Option<&str> {
self.distro_id.as_deref()
}
pub fn distro_id_like(&self) -> &[SmolStr] {
&self.distro_id_like
}
pub fn distro_version_id(&self) -> Option<&str> {
self.distro_version_id.as_deref()
}
pub fn arch(&self) -> &str {
&self.arch
}
pub fn hostname(&self) -> &str {
&self.hostname
}
pub fn primary_pkg_manager(&self) -> Option<&PackageManager> {
self.primary_pkg_manager.as_ref()
}
pub fn supplementary_pkg_managers(&self) -> &[PackageManager] {
&self.supplementary_pkg_managers
}
pub fn all_pkg_managers(&self) -> impl Iterator<Item = &PackageManager> + '_ {
self.primary_pkg_manager
.iter()
.chain(self.supplementary_pkg_managers.iter())
}
pub fn pkg_manager_by_name(&self, name: &str) -> Option<&PackageManager> {
self.all_pkg_managers().find(|m| m.name() == name)
}
pub fn brew_prefix(&self) -> Option<&Path> {
self.brew_prefix.as_deref()
}
pub fn detected_shell(&self) -> Option<Shell> {
self.detected_shell
}
pub fn which(&self, binary: &str) -> Result<Option<PathBuf>, which::Error> {
which::get_path(binary, &self.path)
}
pub fn has_binary(&self, binary: &str) -> Result<bool, which::Error> {
which::exists(binary, &self.path)
}
pub fn template_inputs(&self) -> IndexMap<SmolStr, SmolStr> {
let mut m = IndexMap::new();
m.insert(
SmolStr::new_static("os"),
SmolStr::new(self.os_family.as_token()),
);
m.insert(SmolStr::new_static("arch"), self.arch.clone());
if let Some(prefix) = self.brew_prefix() {
m.insert(
SmolStr::new_static("brew_prefix"),
SmolStr::new(prefix.to_string_lossy()),
);
}
m
}
}
fn detect_shell_from_env() -> Option<Shell> {
let raw = std::env::var("SHELL").ok()?;
let name = Path::new(&raw).file_name()?.to_str()?;
Shell::from_basename(name)
}
#[cfg(test)]
impl Platform {
pub(crate) fn for_test_home(home: PathBuf) -> Self {
Self::for_test_with_identity(
home,
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("x86_64"),
OsFamily::Linux,
identity::Identity::default(),
SmolStr::new_static("test-host"),
None,
None,
Vec::new(),
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn for_test_with_identity(
home: PathBuf,
path: SearchPath,
arch: SmolStr,
os_family: OsFamily,
identity: identity::Identity,
hostname: SmolStr,
detected_shell: Option<Shell>,
primary_pkg_manager: Option<PackageManager>,
supplementary_pkg_managers: Vec<PackageManager>,
) -> Self {
let config_dir = home.join(".config");
let zenops_dir = home.join(".config/zenops");
Self {
home,
config_dir,
zenops_dir,
path,
arch,
os_family,
distro_id: identity.distro_id,
distro_id_like: identity.distro_id_like,
distro_version_id: identity.distro_version_id,
hostname,
primary_pkg_manager,
supplementary_pkg_managers,
brew_prefix: None,
detected_shell,
}
}
pub(crate) fn with_brew_prefix(mut self, prefix: PathBuf) -> Self {
self.brew_prefix = Some(prefix);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fedora_platform() -> Platform {
Platform::for_test_with_identity(
PathBuf::from("/home/test"),
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("x86_64"),
OsFamily::Linux,
Identity {
distro_id: Some(SmolStr::new("fedora")),
distro_id_like: Vec::new(),
distro_version_id: Some(SmolStr::new("42")),
},
SmolStr::new_static("host"),
Some(Shell::Zsh),
None,
Vec::new(),
)
}
#[test]
fn detect_runs_on_current_host_without_error() {
let home = std::env::temp_dir();
let path = SearchPath::from_env();
let p = Platform::detect(home, path).expect("detect must not error");
assert!(!p.hostname().is_empty());
assert!(!p.arch().is_empty());
}
#[test]
fn config_and_zenops_dirs_are_derived_from_home() {
let p = fedora_platform();
assert_eq!(p.home(), Path::new("/home/test"));
assert_eq!(p.config_dir(), Path::new("/home/test/.config"));
assert_eq!(p.zenops_dir(), Path::new("/home/test/.config/zenops"));
}
#[test]
fn template_inputs_include_os_and_arch() {
let p = fedora_platform();
let inputs = p.template_inputs();
assert_eq!(inputs.get("os").map(SmolStr::as_str), Some("linux"));
assert_eq!(inputs.get("arch").map(SmolStr::as_str), Some("x86_64"));
assert!(inputs.get("brew_prefix").is_none());
}
#[test]
fn template_inputs_include_brew_prefix_when_present() {
let p = Platform::for_test_with_identity(
PathBuf::from("/Users/test"),
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("aarch64"),
OsFamily::Macos,
Identity::default(),
SmolStr::new_static("mac"),
None,
None,
Vec::new(),
)
.with_brew_prefix(PathBuf::from("/opt/homebrew"));
assert!(p.pkg_manager_by_name("brew").is_none());
let inputs = p.template_inputs();
assert_eq!(
inputs.get("brew_prefix").map(SmolStr::as_str),
Some("/opt/homebrew"),
);
}
#[test]
fn os_family_other_renders_as_raw_token() {
let fam = OsFamily::Other(SmolStr::new_static("freebsd"));
assert_eq!(fam.as_token(), "freebsd");
}
#[test]
fn shell_from_basename() {
assert_eq!(Shell::from_basename("bash"), Some(Shell::Bash));
assert_eq!(Shell::from_basename("zsh"), Some(Shell::Zsh));
assert_eq!(Shell::from_basename("fish"), None);
assert_eq!(Shell::from_basename(""), None);
}
#[test]
fn all_pkg_managers_concatenates_primary_and_supplementary() {
let p = Platform::for_test_with_identity(
PathBuf::from("/h"),
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("x86_64"),
OsFamily::Linux,
Identity::default(),
SmolStr::new_static("h"),
None,
Some(manager::for_test("dnf", PathBuf::from("/usr/bin/dnf"))),
vec![manager::for_test(
"cargo",
PathBuf::from("/home/h/.cargo/bin/cargo"),
)],
);
let names: Vec<&str> = p.all_pkg_managers().map(PackageManager::name).collect();
assert_eq!(names, vec!["dnf", "cargo"]);
assert_eq!(
p.pkg_manager_by_name("dnf").map(PackageManager::name),
Some("dnf")
);
assert_eq!(
p.pkg_manager_by_name("cargo").map(PackageManager::name),
Some("cargo")
);
assert!(p.pkg_manager_by_name("brew").is_none());
}
#[test]
fn recognised_identity_with_no_managers_has_no_primary() {
let p = Platform::for_test_with_identity(
PathBuf::from("/home/test"),
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("x86_64"),
OsFamily::Linux,
Identity {
distro_id: Some(SmolStr::new("fedora")),
distro_id_like: Vec::new(),
distro_version_id: Some(SmolStr::new("42")),
},
SmolStr::new_static("host"),
None,
None,
Vec::new(),
);
assert_eq!(p.distro_id(), Some("fedora"));
assert!(p.primary_pkg_manager().is_none());
assert!(p.all_pkg_managers().next().is_none());
}
#[test]
fn resolve_home_tilde_only_expands_leading_tilde() {
let p = Platform::for_test_home(PathBuf::from("/home/test"));
assert_eq!(p.resolve_home_tilde("~"), "/home/test");
assert_eq!(p.resolve_home_tilde("~/foo/bar"), "/home/test/foo/bar");
assert_eq!(
p.resolve_home_tilde("~//etc/passwd"),
"/home/test//etc/passwd"
);
assert_eq!(p.resolve_home_tilde("/etc/~lock"), "/etc/~lock");
assert_eq!(p.resolve_home_tilde("~user/x"), "~user/x");
assert_eq!(p.resolve_home_tilde("plain/path"), "plain/path");
}
}