//! doctor: environment checks — tools, config validity, paths, //! permissions — with an optional --fix mode. use crate::app::App; use crate::runner::Runner; use anyhow::{Context, Result}; use serde::Serialize; use std::collections::BTreeMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Ok, Warn, Error, } #[derive(Debug, Clone, Serialize)] pub struct Check { pub name: String, pub status: Status, pub detail: String, } pub struct DoctorReport { pub checks: Vec, } impl DoctorReport { pub fn ok(&self) -> bool { !self.checks.iter().any(|c| c.status == Status::Error) } pub fn problems(&self) -> usize { self.checks.iter().filter(|c| c.status != Status::Ok).count() } } /// Args for probing a tool's version. fn probe_args(tool: &str) -> Vec { match tool { "go" => vec!["version".to_string()], _ => vec!["--version".to_string()], } } pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport { let mut checks = Vec::new(); // --- tools ---------------------------------------------------------- // (name, required) let tools: &[(&str, bool)] = &[ ("git", true), ("sh", cfg!(unix)), ("curl", true), ("node", false), ("npm", false), ("python3", false), ("python", false), ("cargo", false), ("go", false), ("bun", false), ("uv", false), ("docker", false), ]; let os = crate::toolchain::OsInfo::detect(); for (tool, required) in tools { let found = if *tool == "python" { runner.which("python3").or_else(|| runner.which("python")) } else { runner.which(tool) }; match found { Some(path) => { let mut detail = path.display().to_string(); let empty = BTreeMap::new(); if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) { if out.code == 0 { let combined = format!("{} {}", out.stdout, out.stderr); if let Some(v) = crate::version::find_version(&combined) { detail = format!("{} (v{v})", path.display()); } } } checks.push(Check { name: tool.to_string(), status: Status::Ok, detail, }); } None => { let mut detail = if *required { "required but not found on PATH".to_string() } else { "not installed".to_string() }; let cmds = crate::toolchain::install_commands(tool, &os); if !cmds.is_empty() { detail.push_str(&format!( " — on {}: {}", os.name, cmds.join(" | ") )); } let status = if *required { Status::Error } else { Status::Ok }; checks.push(Check { name: tool.to_string(), status, detail, }); } } } // --- configuration --------------------------------------------------- match &app.config_origin { Some(path) => checks.push(Check { name: "config".to_string(), status: Status::Ok, detail: path.display().to_string(), }), None => checks.push(Check { name: "config".to_string(), status: Status::Warn, detail: "no user config file found; using the embedded default catalog".to_string(), }), } let problems = crate::config::validate(&app.config); if problems.is_empty() { checks.push(Check { name: "config-schema".to_string(), status: Status::Ok, detail: format!("{} agents defined", app.catalog.len()), }); } else { checks.push(Check { name: "config-schema".to_string(), status: Status::Error, detail: problems.join("; "), }); } // --- state file ------------------------------------------------------ match app.state.load() { Ok(sf) => checks.push(Check { name: "state".to_string(), status: Status::Ok, detail: format!("{} installed agent(s)", sf.installed.len()), }), Err(e) => checks.push(Check { name: "state".to_string(), status: Status::Error, detail: format!("{e:#}"), }), } // --- directories ----------------------------------------------------- for (name, dir) in [ ("install-dir", app.paths.install_dir.clone()), ("bin-dir", app.paths.bin_dir.clone()), ("log-dir", app.paths.log_dir.clone()), ] { let status = if dir.is_dir() { Status::Ok } else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) { Status::Warn } else { Status::Warn }; let detail = if dir.is_dir() { dir.display().to_string() } else { format!("{} (missing; 'am doctor --fix' creates it)", dir.display()) }; checks.push(Check { name: name.to_string(), status, detail, }); } // --- bin dir on PATH -------------------------------------------------- let on_path = is_on_path(&app.paths.bin_dir); checks.push(Check { name: "bin-on-path".to_string(), status: if on_path { Status::Ok } else { Status::Warn }, detail: if on_path { format!("{} is on PATH", app.paths.bin_dir.display()) } else { format!( "{} is not on PATH — add it so agents are found from your shell", app.paths.bin_dir.display() ) }, }); DoctorReport { checks } } fn is_on_path(dir: &std::path::Path) -> bool { let Some(path) = std::env::var_os("PATH") else { return false; }; std::env::split_paths(&path).any(|p| { if cfg!(windows) { p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase() } else { p == dir } }) } /// Offer to install the missing optional tools (node, python, ...) with /// the command detected for the current OS. Returns what was repaired. pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result> { let mut installed = Vec::new(); let os = crate::toolchain::OsInfo::detect(); for tool in ["git", "node", "python", "cargo", "go", "bun", "uv", "docker"] { if crate::toolchain::tool_is_present(tool, runner) { continue; } let cmds = crate::toolchain::install_commands(tool, &os); let Some(cmd) = cmds.first() else { continue; }; if !app.confirm(&format!( "'{tool}' is missing — run the detected installer for {}? -> {cmd}", os.name ))? { continue; } let code = crate::toolchain::run_install_command(runner, cmd)?; crate::toolchain::refresh_path(); installed.push(if code == 0 { format!("installed '{tool}' ({cmd})") } else { format!("failed to install '{tool}' (exit code {code})") }); } Ok(installed) } /// Repair what can be repaired: create directories, fix a corrupt state file. pub fn fix(app: &App) -> Result> { let mut repaired = Vec::new(); app.paths.ensure().context("creating directories")?; repaired.push(format!("created {}", app.paths.install_dir.display())); repaired.push(format!("created {}", app.paths.log_dir.display())); // Project cache: always refreshed (it derives from the journal). let cache = crate::projects::cache_file(app); if cache.exists() { std::fs::remove_file(&cache).ok(); repaired.push(format!("project cache {} removed (rebuilt on next 'am projects')", cache.display())); } // Corrupt state file: back it up and start fresh. if app.state.path().exists() && app.state.load().is_err() { let backup = app .state .path() .with_extension("json.corrupt.bak"); std::fs::rename(app.state.path(), &backup).with_context(|| { format!( "cannot back up corrupt state file {} to {}", app.state.path().display(), backup.display() ) })?; app.state.save(&crate::state::StateFile::default())?; repaired.push(format!( "corrupt state file moved to {} and recreated empty", backup.display() )); } Ok(repaired) }