Files
agent-manager/src/deps.rs
T

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);
}
}