237 lines
8.1 KiB
Rust
237 lines
8.1 KiB
Rust
//! Dependency checking: probe required tools (node, python, go, ...),
|
|
//! compare minimum versions, and produce actionable error messages.
|
|
|
|
use crate::app::App;
|
|
use crate::config::{AgentDef, Dependency};
|
|
use crate::runner::Runner;
|
|
use anyhow::{bail, Result};
|
|
use std::collections::BTreeMap;
|
|
use std::cmp::Ordering;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DepCheck {
|
|
pub name: String,
|
|
pub found: bool,
|
|
pub version: Option<String>,
|
|
pub min: Option<String>,
|
|
pub ok: bool,
|
|
pub detail: String,
|
|
}
|
|
|
|
/// Default probe command for a dependency name: (program, args).
|
|
fn default_probe(name: &str) -> (String, Vec<String>) {
|
|
match name {
|
|
"go" => ("go".to_string(), vec!["version".to_string()]),
|
|
_ => (name.to_string(), vec!["--version".to_string()]),
|
|
}
|
|
}
|
|
|
|
/// Probe one dependency. Python is special: python3 is preferred.
|
|
pub fn check_dep(runner: &dyn Runner, dep: &Dependency) -> DepCheck {
|
|
let probes: Vec<(String, Vec<String>)> = if dep.name == "python" {
|
|
vec![
|
|
("python3".to_string(), vec!["--version".to_string()]),
|
|
("python".to_string(), vec!["--version".to_string()]),
|
|
]
|
|
} else if let Some(check) = &dep.check {
|
|
match shell_words::split(check) {
|
|
Ok(tokens) if !tokens.is_empty() => {
|
|
let prog = tokens[0].clone();
|
|
vec![(prog, tokens[1..].to_vec())]
|
|
}
|
|
_ => vec![default_probe(&dep.name)],
|
|
}
|
|
} else {
|
|
vec![default_probe(&dep.name)]
|
|
};
|
|
let empty = BTreeMap::new();
|
|
for (prog, args) in probes {
|
|
if runner.which(&prog).is_none() {
|
|
continue;
|
|
}
|
|
match runner.run(&prog, &args, &empty, None, true) {
|
|
Ok(out) if out.code == 0 => {
|
|
let combined = format!("{}
|
|
{}", out.stdout, out.stderr);
|
|
let version = crate::version::find_version(&combined);
|
|
let ok = match (&dep.min_version, &version) {
|
|
(Some(min), Some(v)) => crate::version::compare(v, min)
|
|
.map(|o| o != Ordering::Less)
|
|
.unwrap_or(true),
|
|
_ => true,
|
|
};
|
|
let detail = if ok {
|
|
version.clone().unwrap_or_else(|| "found".to_string())
|
|
} else {
|
|
format!(
|
|
"version {} is too old (need >= {})",
|
|
version.as_deref().unwrap_or("unknown"),
|
|
dep.min_version.as_deref().unwrap_or("?")
|
|
)
|
|
};
|
|
return DepCheck {
|
|
name: dep.name.clone(),
|
|
found: true,
|
|
version,
|
|
min: dep.min_version.clone(),
|
|
ok,
|
|
detail,
|
|
};
|
|
}
|
|
Ok(out) => {
|
|
return DepCheck {
|
|
name: dep.name.clone(),
|
|
found: true,
|
|
version: None,
|
|
min: dep.min_version.clone(),
|
|
ok: false,
|
|
detail: format!("probe failed (exit code {})", out.code),
|
|
};
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
DepCheck {
|
|
name: dep.name.clone(),
|
|
found: false,
|
|
version: None,
|
|
min: dep.min_version.clone(),
|
|
ok: false,
|
|
detail: "not found on PATH".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Check every dependency of an agent.
|
|
pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
|
|
agent
|
|
.dependencies
|
|
.iter()
|
|
.map(|d| check_dep(runner, d))
|
|
.collect()
|
|
}
|
|
|
|
/// Fail with an actionable message when dependencies are missing.
|
|
///
|
|
/// With auto_install_deps enabled (the default), missing tools are offered
|
|
/// for installation right away: an explicit install_cmd from the config
|
|
/// first, otherwise the command detected for the current OS
|
|
/// (scoop/winget on Windows, apt/dnf/pacman/apk on Linux, brew on macOS).
|
|
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
|
|
let mut checks = check_agent(agent, runner);
|
|
if checks.iter().all(|c| c.ok) {
|
|
return Ok(());
|
|
}
|
|
let os = crate::toolchain::OsInfo::detect();
|
|
if app.config.settings.auto_install_deps() && !app.dry_run() {
|
|
for dep in &agent.dependencies {
|
|
if check_dep(runner, dep).ok {
|
|
continue;
|
|
}
|
|
app.log.warn(&format!("dependency '{}' is missing", dep.name));
|
|
// 1) Explicit install command from the configuration.
|
|
if !dep.install_cmd.is_empty() {
|
|
let cmd_line = dep.install_cmd.join(" ");
|
|
if app.confirm(&format!("Run '{cmd_line}' to install it?"))? {
|
|
let prog = dep.install_cmd[0].clone();
|
|
let args: Vec<String> = dep.install_cmd[1..].to_vec();
|
|
let empty = BTreeMap::new();
|
|
let out = runner.run(&prog, &args, &empty, None, false)?;
|
|
if out.code != 0 {
|
|
app.log.warn(&format!(
|
|
"automatic installation of '{}' failed (exit code {})",
|
|
dep.name, out.code
|
|
));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// 2) Command detected for the current OS.
|
|
let cmds = crate::toolchain::install_commands(&dep.name, &os);
|
|
if let Some(cmd) = cmds.first() {
|
|
let proceed = app
|
|
.confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?;
|
|
if proceed {
|
|
let code = crate::toolchain::run_install_command(runner, cmd)?;
|
|
crate::toolchain::refresh_path();
|
|
if code != 0 {
|
|
app.log.warn(&format!(
|
|
"installation of '{}' failed (exit code {code})",
|
|
dep.name
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
checks = check_agent(agent, runner);
|
|
}
|
|
if checks.iter().all(|c| c.ok) {
|
|
return Ok(());
|
|
}
|
|
let mut lines = Vec::new();
|
|
for c in checks.iter().filter(|c| !c.ok) {
|
|
let mut line = format!(" - {}: {}", c.name, c.detail);
|
|
if let Some(min) = &c.min {
|
|
line.push_str(&format!(" (minimum: {min})"));
|
|
}
|
|
let cmds = crate::toolchain::install_commands(&c.name, &os);
|
|
if !cmds.is_empty() {
|
|
line.push_str(&format!(
|
|
"
|
|
on {}: {}",
|
|
os.name,
|
|
cmds.join(" | ")
|
|
));
|
|
} else if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
|
|
if let Some(hint) = &dep.install_hint {
|
|
line.push_str(&format!(" — install via {hint}"));
|
|
}
|
|
}
|
|
lines.push(line);
|
|
}
|
|
bail!(
|
|
"agent '{}' has unmet dependencies:
|
|
{}",
|
|
agent.name,
|
|
lines.join("
|
|
")
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::Dependency;
|
|
use crate::runner::MockRunner;
|
|
|
|
#[test]
|
|
fn missing_tool_reported() {
|
|
let runner = MockRunner::new();
|
|
let dep = Dependency {
|
|
name: "node".to_string(),
|
|
check: None,
|
|
min_version: Some("18.0.0".to_string()),
|
|
install_hint: Some("https://nodejs.org".to_string()),
|
|
install_cmd: vec![],
|
|
};
|
|
let c = check_dep(&runner, &dep);
|
|
assert!(!c.ok);
|
|
assert!(!c.found);
|
|
assert_eq!(c.detail, "not found on PATH");
|
|
}
|
|
|
|
#[test]
|
|
fn python3_preferred() {
|
|
let runner = MockRunner::new().with_found("python3").with_found("python");
|
|
let dep = Dependency {
|
|
name: "python".to_string(),
|
|
check: None,
|
|
min_version: Some("3.9".to_string()),
|
|
install_hint: None,
|
|
install_cmd: vec![],
|
|
};
|
|
let c = check_dep(&runner, &dep);
|
|
// MockRunner returns empty output => version unknown => ok when no min.
|
|
assert!(c.found);
|
|
}
|
|
}
|