//! 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, pub min: Option, pub ok: bool, pub detail: String, } /// Default probe command for a dependency name: (program, args). fn default_probe(name: &str) -> (String, Vec) { 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)> = 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 { 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 = 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); } }