use std::process::Command;
use anstyle::AnsiColor;
use crate::project::build::find_project_root;
use crate::project::manifest::Manifest;
use crate::project::registry::RegistryClient;
use crate::ui::CliError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status {
Ok,
Warn,
Fail,
}
struct Check {
status: Status,
name: &'static str,
detail: String,
}
impl Check {
fn parts(&self) -> (&'static str, String) {
let symbol = match self.status {
Status::Ok => "✓",
Status::Warn => "!",
Status::Fail => "✗",
};
(symbol, format!(" {:<14} {}", self.name, self.detail))
}
}
#[cfg(test)]
fn render_check(check: &Check) -> String {
let (symbol, rest) = check.parts();
format!("{symbol}{rest}")
}
fn version_is_newer(candidate: &str, current: &str) -> bool {
let parse = |v: &str| -> Option<[u64; 3]> {
let mut it = v.trim().trim_start_matches('v').splitn(3, '.');
let maj = it.next()?.parse().ok()?;
let min = it.next()?.parse().ok()?;
let pat: u64 = it
.next()?
.split(|c: char| !c.is_ascii_digit())
.next()?
.parse()
.ok()?;
Some([maj, min, pat])
};
match (parse(candidate), parse(current)) {
(Some(c), Some(cur)) => c > cur,
_ => false,
}
}
fn tool_version(cmd: &str, arg: &str) -> Option<String> {
let out = Command::new(cmd).arg(arg).output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.map(|l| l.trim().to_string())
}
pub(crate) fn cmd_doctor(registry: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut checks: Vec<Check> = Vec::new();
let current = env!("CARGO_PKG_VERSION");
let freshness = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(2))
.build()
.get("https://crates.io/api/v1/crates/logicaffeine-cli")
.call()
.ok()
.and_then(|r| r.into_json::<serde_json::Value>().ok())
.and_then(|v| v["crate"]["max_version"].as_str().map(String::from));
checks.push(match freshness {
Some(latest) if version_is_newer(&latest, current) => Check {
status: Status::Warn,
name: "largo",
detail: format!("{current} (v{latest} is available — re-run the installer to update)"),
},
Some(_) => Check { status: Status::Ok, name: "largo", detail: format!("{current} (latest)") },
None => Check {
status: Status::Warn,
name: "largo",
detail: format!("{current} (could not check crates.io for updates)"),
},
});
checks.push(match tool_version("cargo", "--version") {
Some(v) => Check { status: Status::Ok, name: "cargo", detail: v },
None => Check {
status: Status::Warn,
name: "cargo",
detail: "not found — `largo build/run` need a Rust toolchain (https://rustup.rs); \
`largo run --interpret` and `--emit wasm` work without one"
.to_string(),
},
});
let wasm_target = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("wasm32-unknown-unknown"))
.unwrap_or(false);
checks.push(if wasm_target {
Check { status: Status::Ok, name: "wasm32", detail: "wasm32-unknown-unknown installed".into() }
} else {
Check {
status: Status::Warn,
name: "wasm32",
detail: "target not installed — `--target wasm` / `--emit wasm-linked` need it \
(rustup target add wasm32-unknown-unknown)"
.to_string(),
}
});
checks.push(match tool_version("node", "--version") {
Some(v) => Check { status: Status::Ok, name: "node", detail: v },
None => Check {
status: Status::Warn,
name: "node",
detail: "not found — `largo run --emit wasm` executes through node".to_string(),
},
});
checks.push(if cfg!(feature = "verification") {
Check { status: Status::Ok, name: "verification", detail: "compiled in (full build)".into() }
} else {
Check {
status: Status::Warn,
name: "verification",
detail: "lean build — `largo verify` needs the full flavor \
(install.sh --full) or `--features verification`"
.to_string(),
}
});
let registry_url = registry.as_deref().unwrap_or(RegistryClient::default_url());
let reachable = {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(2))
.build();
match agent.get(&format!("{registry_url}/auth/me")).call() {
Ok(_) => true,
Err(ureq::Error::Status(_, _)) => true,
Err(ureq::Error::Transport(_)) => false,
}
};
let token = crate::project::credentials::get_token(registry_url).is_some();
checks.push(if reachable {
Check {
status: Status::Ok,
name: "registry",
detail: format!(
"{registry_url} reachable ({})",
if token { "token stored" } else { "not logged in" }
),
}
} else {
Check {
status: Status::Warn,
name: "registry",
detail: format!("{registry_url} unreachable (offline? publish/login need it)"),
}
});
if let Ok(cwd) = std::env::current_dir() {
if find_project_root(&cwd).is_some() || cwd.join("Largo.toml").exists() {
let root = find_project_root(&cwd).unwrap_or(cwd);
match Manifest::load(&root) {
Ok(manifest) => {
let entry = root.join(&manifest.package.entry);
let md_fallback = entry.with_extension("md");
if entry.exists() || md_fallback.exists() {
checks.push(Check {
status: Status::Ok,
name: "project",
detail: format!("{} v{}", manifest.package.name, manifest.package.version),
});
} else {
checks.push(Check {
status: Status::Fail,
name: "project",
detail: format!("entry point missing: {}", entry.display()),
});
}
}
Err(e) => checks.push(Check {
status: Status::Fail,
name: "project",
detail: format!("Largo.toml does not parse: {e}"),
}),
}
}
}
let green = AnsiColor::Green.on_default().bold();
let yellow = AnsiColor::Yellow.on_default().bold();
let red = AnsiColor::Red.on_default().bold();
for check in &checks {
let (symbol, rest) = check.parts();
match check.status {
Status::Ok => anstream::println!("{green}{symbol}{green:#}{rest}"),
Status::Warn => anstream::println!("{yellow}{symbol}{yellow:#}{rest}"),
Status::Fail => anstream::println!("{red}{symbol}{red:#}{rest}"),
}
}
let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
if failures > 0 {
return Err(CliError::new(format!("{failures} check(s) failed")).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newer_semver_is_detected() {
assert!(version_is_newer("0.10.1", "0.10.0"));
assert!(version_is_newer("0.11.0", "0.10.9"));
assert!(version_is_newer("1.0.0", "0.99.99"));
assert!(!version_is_newer("0.10.0", "0.10.0"));
assert!(!version_is_newer("0.9.9", "0.10.0"));
assert!(!version_is_newer("garbage", "0.10.0"));
}
#[test]
fn check_lines_align_and_carry_symbols() {
let ok = render_check(&Check { status: Status::Ok, name: "cargo", detail: "1.80".into() });
assert!(ok.starts_with("✓ cargo"), "{ok}");
let fail = render_check(&Check { status: Status::Fail, name: "project", detail: "broken".into() });
assert!(fail.starts_with("✗ project"), "{fail}");
let warn = render_check(&Check { status: Status::Warn, name: "node", detail: "x".into() });
let rest_ok: String = ok.chars().skip(1).collect();
let rest_warn: String = warn.chars().skip(1).collect();
assert_eq!(rest_ok.find("1.80"), rest_warn.find('x'), "aligned detail column");
}
}