use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use versions::Versioning;
use crate::config::Settings;
use crate::system::packages::{self, PackageRequest, SystemPackageManager};
#[derive(Debug, Clone)]
pub(crate) struct SystemDep {
pub check: SystemDepCheck,
pub version: Option<VersionConstraint>,
pub optional: Option<String>,
pub packages: IndexMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SystemDepCheck {
Bin(String),
PkgConfig(String),
SharedLib(String),
Command(String),
}
impl SystemDepCheck {
fn kind(&self) -> &'static str {
match self {
SystemDepCheck::Bin(_) => "bin",
SystemDepCheck::PkgConfig(_) => "pkgconfig",
SystemDepCheck::SharedLib(_) => "sharedlib",
SystemDepCheck::Command(_) => "command",
}
}
fn value(&self) -> &str {
match self {
SystemDepCheck::Bin(s)
| SystemDepCheck::PkgConfig(s)
| SystemDepCheck::SharedLib(s)
| SystemDepCheck::Command(s) => s,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VersionOp {
AtLeast,
Greater,
AtMost,
Less,
Exact,
}
impl VersionOp {
fn symbol(self) -> &'static str {
match self {
VersionOp::AtLeast => ">=",
VersionOp::Greater => ">",
VersionOp::AtMost => "<=",
VersionOp::Less => "<",
VersionOp::Exact => "=",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct VersionConstraint {
pub op: VersionOp,
pub version: Versioning,
}
impl VersionConstraint {
pub(crate) fn parse(s: &str) -> eyre::Result<Self> {
let s = s.trim();
let (op, rest) = if let Some(r) = s.strip_prefix(">=") {
(VersionOp::AtLeast, r)
} else if let Some(r) = s.strip_prefix("<=") {
(VersionOp::AtMost, r)
} else if let Some(r) = s.strip_prefix('>') {
(VersionOp::Greater, r)
} else if let Some(r) = s.strip_prefix('<') {
(VersionOp::Less, r)
} else if let Some(r) = s.strip_prefix('=') {
(VersionOp::Exact, r)
} else {
(VersionOp::AtLeast, s)
};
let version = Versioning::new(rest.trim())
.ok_or_else(|| eyre::eyre!("invalid version constraint '{s}'"))?;
Ok(Self { op, version })
}
pub(crate) fn satisfied_by(&self, current: &Versioning) -> bool {
match self.op {
VersionOp::AtLeast => current >= &self.version,
VersionOp::Greater => current > &self.version,
VersionOp::AtMost => current <= &self.version,
VersionOp::Less => current < &self.version,
VersionOp::Exact => current == &self.version,
}
}
}
impl fmt::Display for VersionConstraint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.op.symbol(), self.version)
}
}
impl SystemDep {
pub(crate) fn label(&self) -> String {
let base = match &self.check {
SystemDepCheck::Bin(name) => name.clone(),
SystemDepCheck::PkgConfig(name) => format!("pkg-config {name}"),
SystemDepCheck::SharedLib(name) => format!("shared library {name}"),
SystemDepCheck::Command(cmd) => format!("`{cmd}`"),
};
match &self.version {
Some(v) => format!("{base} {v}"),
None => base,
}
}
fn fingerprint(&self) -> String {
match &self.version {
Some(v) => format!("{}:{}:{v}", self.check.kind(), self.check.value()),
None => format!("{}:{}", self.check.kind(), self.check.value()),
}
}
}
impl fmt::Display for SystemDep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.label())
}
}
#[derive(Debug, Clone)]
pub(crate) struct DepStatus {
pub dep: SystemDep,
pub found: Option<String>,
pub satisfied: bool,
pub reason: Option<String>,
}
impl TryFrom<vfox::SystemDependency> for SystemDep {
type Error = eyre::Error;
fn try_from(d: vfox::SystemDependency) -> eyre::Result<Self> {
let check = match (&d.bin, &d.pkgconfig, &d.sharedlib, &d.command) {
(Some(b), None, None, None) => SystemDepCheck::Bin(b.clone()),
(None, Some(p), None, None) => SystemDepCheck::PkgConfig(p.clone()),
(None, None, Some(s), None) => SystemDepCheck::SharedLib(s.clone()),
(None, None, None, Some(c)) => SystemDepCheck::Command(c.clone()),
_ => eyre::bail!(
"systemDependencies entry must set exactly one of bin/pkgconfig/sharedlib/command"
),
};
let version = match (&check, &d.version) {
(_, None) => None,
(SystemDepCheck::Bin(_) | SystemDepCheck::PkgConfig(_), Some(v)) => {
Some(VersionConstraint::parse(v)?)
}
(check, Some(_)) => {
warn!(
"systemDependencies: `version` is only supported for bin/pkgconfig checks, ignoring it for the {} check",
check.kind()
);
None
}
};
let packages = d
.packages
.into_iter()
.map(|(mgr, candidates)| {
let candidates: Vec<String> = candidates
.into_iter()
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
(mgr, candidates)
})
.filter(|(_, candidates)| !candidates.is_empty())
.collect();
Ok(SystemDep {
check,
version,
optional: d.optional.clone(),
packages,
})
}
}
type DetectOutcome = (bool, Option<String>, Option<String>);
static CACHE: Lazy<Mutex<HashMap<String, DetectOutcome>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub(crate) async fn detect(deps: &[SystemDep]) -> Vec<DepStatus> {
detect_inner(deps, true).await
}
pub(crate) async fn detect_fresh(deps: &[SystemDep]) -> Vec<DepStatus> {
detect_inner(deps, false).await
}
async fn detect_inner(deps: &[SystemDep], use_cache: bool) -> Vec<DepStatus> {
let mut out = Vec::with_capacity(deps.len());
for dep in deps {
let fp = dep.fingerprint();
let (satisfied, found, reason) =
if use_cache && let Some(cached) = CACHE.lock().unwrap().get(&fp).cloned() {
cached
} else {
let outcome = detect_outcome(dep).await;
CACHE.lock().unwrap().insert(fp, outcome.clone());
outcome
};
out.push(DepStatus {
dep: dep.clone(),
found,
satisfied,
reason,
});
}
out
}
async fn detect_outcome(dep: &SystemDep) -> DetectOutcome {
match &dep.check {
SystemDepCheck::Bin(name) => check_bin(name, dep.version.as_ref()).await,
SystemDepCheck::PkgConfig(name) => check_pkgconfig(name, dep.version.as_ref()).await,
SystemDepCheck::SharedLib(name) => check_sharedlib(name).await,
SystemDepCheck::Command(cmd) => check_command(cmd).await,
}
}
async fn check_bin(
name: &str,
constraint: Option<&VersionConstraint>,
) -> (bool, Option<String>, Option<String>) {
let Some(path) = crate::file::which_spawnable(name) else {
return (false, None, Some(format!("`{name}` not found on PATH")));
};
let Some(constraint) = constraint else {
return (true, None, None);
};
match run_capture(&path, &["--version"]).await {
Some((_, output)) => match extract_version(&output) {
Some(v) => {
let versioning = Versioning::new(&v);
match versioning {
Some(versioning) => {
let ok = constraint.satisfied_by(&versioning);
(ok, Some(v), None)
}
None => {
debug!(
"system dep: `{name}` version '{v}' unparseable, treating as satisfied"
);
(true, Some(v), None)
}
}
}
None => {
debug!(
"system dep: could not extract version from `{name} --version`, treating as satisfied"
);
(true, None, None)
}
},
None => {
debug!("system dep: `{name} --version` failed to run, treating as satisfied");
(true, None, None)
}
}
}
async fn check_pkgconfig(
name: &str,
constraint: Option<&VersionConstraint>,
) -> (bool, Option<String>, Option<String>) {
let Some(path) = crate::file::which_spawnable("pkg-config") else {
return (
false,
None,
Some("pkg-config is not installed (needed to detect this library)".to_string()),
);
};
match run_capture(&path, &["--exists", name]).await {
Some((true, _)) => {}
Some((false, _)) => {
return (
false,
None,
Some(format!("pkg-config module `{name}` not found")),
);
}
None => {
debug!(
"system dep: `pkg-config --exists {name}` timed out or failed to spawn, treating as satisfied"
);
return (true, None, None);
}
}
let Some(constraint) = constraint else {
return (true, None, None);
};
match run_capture(&path, &["--modversion", name]).await {
Some((true, output)) => {
let v = output.trim().to_string();
match Versioning::new(&v) {
Some(versioning) => (constraint.satisfied_by(&versioning), Some(v), None),
None => {
debug!(
"system dep: pkg-config modversion '{v}' unparseable, treating as satisfied"
);
(true, Some(v), None)
}
}
}
_ => (true, None, None),
}
}
#[cfg(target_os = "linux")]
async fn check_sharedlib(soname: &str) -> (bool, Option<String>, Option<String>) {
for ldconfig in ["ldconfig", "/sbin/ldconfig"] {
if let Some((true, output)) = run_capture(ldconfig, &["-p"]).await {
if output
.lines()
.any(|l| l.split_whitespace().next() == Some(soname))
{
return (true, None, None);
}
break;
}
}
if let Ok(paths) = std::env::var("LD_LIBRARY_PATH") {
for dir in std::env::split_paths(&paths) {
if dir.join(soname).exists() {
return (true, None, None);
}
}
}
(
false,
None,
Some(format!(
"shared library `{soname}` not found by the dynamic linker"
)),
)
}
#[cfg(not(target_os = "linux"))]
async fn check_sharedlib(soname: &str) -> (bool, Option<String>, Option<String>) {
debug!("system dep: sharedlib check for `{soname}` is Linux-only, treating as satisfied");
(true, None, None)
}
async fn check_command(cmd: &str) -> (bool, Option<String>, Option<String>) {
let (program, args): (&str, Vec<&str>) = if cfg!(windows) {
("cmd", vec!["/C", cmd])
} else {
("sh", vec!["-c", cmd])
};
match run_capture(program, &args).await {
Some((true, _)) => (true, None, None),
_ => (
false,
None,
Some(format!("command `{cmd}` did not succeed")),
),
}
}
async fn run_capture(
program: impl AsRef<std::ffi::OsStr>,
args: &[&str],
) -> Option<(bool, String)> {
let program = program.as_ref();
let fut = tokio::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.output();
let output = match tokio::time::timeout(std::time::Duration::from_secs(5), fut).await {
Ok(Ok(output)) => output,
Ok(Err(_)) => return None,
Err(_) => {
debug!(
"system dep: `{}` timed out, treating check as inconclusive",
program.to_string_lossy()
);
return None;
}
};
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
Some((output.status.success(), combined))
}
fn extract_version(text: &str) -> Option<String> {
static RE: Lazy<regex::Regex> = Lazy::new(|| regex::Regex::new(r"\d+(?:\.\d+)+").unwrap());
RE.find(text).map(|m| m.as_str().to_string())
}
pub(crate) async fn pick_manager(dep: &SystemDep) -> Option<Arc<dyn SystemPackageManager>> {
let enabled = Settings::get().system_packages.managers.clone();
for m in packages::all_managers() {
let name = m.name();
if enabled
.as_ref()
.is_some_and(|e| !e.iter().any(|x| x == name))
{
continue;
}
if m.unavailable_reason_async().await.is_some() {
continue;
}
if dep.packages.get(name).is_some_and(|c| !c.is_empty()) {
return Some(m);
}
}
None
}
async fn resolve_package(m: &Arc<dyn SystemPackageManager>, dep: &SystemDep) -> Option<String> {
let candidates = dep.packages.get(m.name())?;
match candidates.split_first()? {
(first, []) => Some(first.clone()),
(first, _) => match m.available(candidates).await {
Ok(available) => Some(
candidates
.iter()
.zip(available)
.find(|(_, exists)| *exists)
.map(|(name, _)| name)
.unwrap_or(first)
.clone(),
),
Err(err) => {
debug!(
"could not query {} for {}, using {first}: {err:#}",
m.name(),
dep.label()
);
Some(first.clone())
}
},
}
}
pub(crate) async fn build_requests(
missing: &[&SystemDep],
) -> (IndexMap<String, Vec<PackageRequest>>, Vec<SystemDep>) {
let mut by_mgr: IndexMap<String, Vec<PackageRequest>> = IndexMap::new();
let mut unremediable = vec![];
for dep in missing {
let resolved = match pick_manager(dep).await {
Some(m) => resolve_package(&m, dep).await.map(|pkg| (m, pkg)),
None => None,
};
match resolved {
Some((m, pkg)) => {
let requests = by_mgr.entry(m.name().to_string()).or_default();
if !requests.iter().any(|r| r.name == pkg) {
requests.push(PackageRequest {
name: pkg,
version: None,
tap_url: None,
desired: crate::system::packages::PackageDesiredState::Present,
});
}
}
None => unremediable.push((*dep).clone()),
}
}
(by_mgr, unremediable)
}
pub(crate) async fn hint_commands(missing: &[&SystemDep]) -> Vec<String> {
let (by_mgr, _) = build_requests(missing).await;
by_mgr
.into_iter()
.map(|(mgr, requests)| {
let pkgs = requests
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(" ");
format!("{}{pkgs}", install_prefix(&mgr))
})
.collect()
}
fn install_prefix(mgr: &str) -> &'static str {
match mgr {
"brew" => "brew install ",
"brew-cask" => "brew install --cask ",
"apt" => "sudo apt-get install -y ",
"dnf" => "sudo dnf install -y ",
"pacman" => "sudo pacman -S ",
"apk" => "sudo apk add ",
"mas" => "mas install ",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_constraint_parse_and_compare() {
let c = VersionConstraint::parse(">=3.0").unwrap();
assert_eq!(c.op, VersionOp::AtLeast);
assert!(!c.satisfied_by(&Versioning::new("2.3").unwrap()));
assert!(c.satisfied_by(&Versioning::new("3.0").unwrap()));
assert!(c.satisfied_by(&Versioning::new("3.8.2").unwrap()));
let bare = VersionConstraint::parse("3.0").unwrap();
assert_eq!(bare.op, VersionOp::AtLeast);
assert!(bare.satisfied_by(&Versioning::new("3.1").unwrap()));
assert!(!bare.satisfied_by(&Versioning::new("2.9").unwrap()));
assert!(
VersionConstraint::parse(">2")
.unwrap()
.satisfied_by(&Versioning::new("3").unwrap())
);
assert!(
!VersionConstraint::parse(">3")
.unwrap()
.satisfied_by(&Versioning::new("3").unwrap())
);
assert!(
VersionConstraint::parse("<=1.2")
.unwrap()
.satisfied_by(&Versioning::new("1.2").unwrap())
);
assert!(
VersionConstraint::parse("=3.0")
.unwrap()
.satisfied_by(&Versioning::new("3.0").unwrap())
);
assert!(
!VersionConstraint::parse("=3.0")
.unwrap()
.satisfied_by(&Versioning::new("3.0.1").unwrap())
);
}
#[test]
fn test_extract_version() {
assert_eq!(
extract_version("bison (GNU Bison) 2.3").as_deref(),
Some("2.3")
);
assert_eq!(extract_version("GNU Make 3.81").as_deref(), Some("3.81"));
assert_eq!(
extract_version("OpenSSL 3.0.13 30 Jan 2024").as_deref(),
Some("3.0.13")
);
assert_eq!(extract_version("no version here").as_deref(), None);
}
struct StubManager {
stock: Vec<String>,
queries: Mutex<usize>,
}
#[async_trait::async_trait(?Send)]
impl SystemPackageManager for StubManager {
fn name(&self) -> &str {
"stub"
}
fn is_available(&self) -> bool {
true
}
fn unavailable_reason(&self) -> String {
unreachable!()
}
async fn installed(
&self,
_pkgs: &[PackageRequest],
) -> crate::result::Result<Vec<crate::system::packages::PackageStatus>> {
Ok(vec![])
}
async fn available(&self, names: &[String]) -> crate::result::Result<Vec<bool>> {
*self.queries.lock().unwrap() += 1;
Ok(names.iter().map(|n| self.stock.contains(n)).collect())
}
async fn install(
&self,
_pkgs: &[PackageRequest],
_opts: &crate::system::packages::InstallOpts,
) -> crate::result::Result<()> {
Ok(())
}
}
fn dep_with_packages(candidates: Vec<&str>) -> SystemDep {
SystemDep {
check: SystemDepCheck::SharedLib("libaio.so.1".into()),
version: None,
optional: None,
packages: [(
"stub".to_string(),
candidates.into_iter().map(str::to_string).collect(),
)]
.into_iter()
.collect(),
}
}
#[tokio::test]
async fn test_resolve_package_picks_first_available_candidate() {
let m: Arc<dyn SystemPackageManager> = Arc::new(StubManager {
stock: vec!["libaio1".to_string()],
queries: Mutex::new(0),
});
let dep = dep_with_packages(vec!["libaio1t64", "libaio1"]);
assert_eq!(resolve_package(&m, &dep).await.as_deref(), Some("libaio1"));
let dep = dep_with_packages(vec!["nonexistent", "also-nonexistent"]);
assert_eq!(
resolve_package(&m, &dep).await.as_deref(),
Some("nonexistent")
);
}
#[tokio::test]
async fn test_resolve_package_does_not_query_for_a_single_candidate() {
let m = Arc::new(StubManager {
stock: vec![],
queries: Mutex::new(0),
});
let dyn_m: Arc<dyn SystemPackageManager> = m.clone();
let dep = dep_with_packages(vec!["libaio1"]);
assert_eq!(
resolve_package(&dyn_m, &dep).await.as_deref(),
Some("libaio1")
);
assert_eq!(*m.queries.lock().unwrap(), 0);
}
#[tokio::test]
async fn test_resolve_package_returns_none_for_empty_candidates() {
let m: Arc<dyn SystemPackageManager> = Arc::new(StubManager {
stock: vec![],
queries: Mutex::new(0),
});
let dep = dep_with_packages(vec![]);
assert_eq!(resolve_package(&m, &dep).await, None);
}
async fn available_manager_name() -> Option<String> {
for m in packages::all_managers() {
if m.unavailable_reason_async().await.is_none() {
return Some(m.name().to_string());
}
}
None
}
fn dep_under_manager(mgr: &str, candidates: Vec<&str>) -> SystemDep {
SystemDep {
check: SystemDepCheck::SharedLib("libaio.so.1".into()),
version: None,
optional: None,
packages: [(
mgr.to_string(),
candidates.into_iter().map(str::to_string).collect(),
)]
.into_iter()
.collect(),
}
}
#[tokio::test]
async fn test_pick_manager_skips_empty_candidate_list() {
let Some(mgr) = available_manager_name().await else {
return; };
let usable = dep_under_manager(&mgr, vec!["some-package"]);
assert!(pick_manager(&usable).await.is_some());
let empty = dep_under_manager(&mgr, vec![]);
assert!(pick_manager(&empty).await.is_none());
let (by_mgr, unremediable) = build_requests(&[&empty]).await;
assert!(by_mgr.is_empty());
assert_eq!(unremediable.len(), 1);
}
#[test]
fn test_blank_candidate_names_are_dropped() {
let d = vfox::SystemDependency {
sharedlib: Some("libaio.so.1".into()),
packages: [
("apt".to_string(), vec!["".to_string(), " ".to_string()]),
(
"dnf".to_string(),
vec!["".to_string(), " libaio ".to_string()],
),
]
.into_iter()
.collect(),
..Default::default()
};
let dep = SystemDep::try_from(d).unwrap();
assert!(!dep.packages.contains_key("apt"));
assert_eq!(dep.packages.get("dnf"), Some(&vec!["libaio".to_string()]));
}
#[tokio::test]
async fn test_resolve_package_falls_back_when_manager_cannot_answer() {
struct CannotAnswer;
#[async_trait::async_trait(?Send)]
impl SystemPackageManager for CannotAnswer {
fn name(&self) -> &str {
"stub"
}
fn is_available(&self) -> bool {
true
}
fn unavailable_reason(&self) -> String {
unreachable!()
}
async fn installed(
&self,
_pkgs: &[PackageRequest],
) -> crate::result::Result<Vec<crate::system::packages::PackageStatus>> {
Ok(vec![])
}
async fn available(&self, _names: &[String]) -> crate::result::Result<Vec<bool>> {
eyre::bail!("apt-cache not found")
}
async fn install(
&self,
_pkgs: &[PackageRequest],
_opts: &crate::system::packages::InstallOpts,
) -> crate::result::Result<()> {
Ok(())
}
}
let m: Arc<dyn SystemPackageManager> = Arc::new(CannotAnswer);
let dep = dep_with_packages(vec!["libaio1t64", "libaio1"]);
assert_eq!(
resolve_package(&m, &dep).await.as_deref(),
Some("libaio1t64")
);
}
#[test]
fn test_label() {
let dep = SystemDep {
check: SystemDepCheck::Bin("bison".into()),
version: Some(VersionConstraint::parse(">=3.0").unwrap()),
optional: None,
packages: Default::default(),
};
assert_eq!(dep.label(), "bison >=3.0");
let dep = SystemDep {
check: SystemDepCheck::PkgConfig("libxml-2.0".into()),
version: None,
optional: None,
packages: Default::default(),
};
assert_eq!(dep.label(), "pkg-config libxml-2.0");
}
#[tokio::test]
async fn test_check_bin_present_and_missing() {
let present = if cfg!(windows) { "cmd" } else { "sh" };
let (ok, _, _) = check_bin(present, None).await;
assert!(ok);
let (ok, _, reason) = check_bin("definitely-not-a-real-binary-xyz", None).await;
assert!(!ok);
assert!(reason.is_some());
}
#[tokio::test]
async fn test_check_command() {
let (succeed, fail) = if cfg!(windows) {
("exit 0", "exit 1")
} else {
("true", "false")
};
let (ok, _, _) = check_command(succeed).await;
assert!(ok);
let (ok, _, _) = check_command(fail).await;
assert!(!ok);
}
#[test]
fn test_version_only_applies_to_bin_and_pkgconfig() {
let d = vfox::SystemDependency {
bin: Some("bison".into()),
version: Some(">=3.0".into()),
..Default::default()
};
let dep = SystemDep::try_from(d).unwrap();
assert!(dep.version.is_some());
let d = vfox::SystemDependency {
sharedlib: Some("libaio.so.1".into()),
version: Some(">=1.0".into()),
..Default::default()
};
let dep = SystemDep::try_from(d).unwrap();
assert!(matches!(dep.check, SystemDepCheck::SharedLib(_)));
assert!(dep.version.is_none());
assert_eq!(dep.label(), "shared library libaio.so.1");
}
}