use std::fmt::Write as _;
use anyhow::Result;
use colored::Colorize;
use serde_json::{Map, Value};
use crate::resolver::ResolutionOverrides;
use crate::schema::Project;
use crate::types::ProjectContext;
pub(crate) fn doctor(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
json: bool,
schema_version: u32,
) -> Result<()> {
let project = Project::build_with_schema(ctx, overrides, schema_version, true);
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
} else {
let report = serde_json::to_value(&project)?;
print_human(&report, overrides);
}
Ok(())
}
#[cfg(test)]
fn build_report(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Value {
serde_json::to_value(Project::build(ctx, overrides))
.expect("Project must serialize for build_report")
}
#[allow(
clippy::too_many_lines,
reason = "linear section-by-section renderer; splitting hurts readability"
)]
fn print_human(report: &Value, overrides: &ResolutionOverrides) {
let root = report["root"].as_str().unwrap_or("?");
println!(
"{} {}",
"runner doctor".bold(),
format!("@ {root}").dimmed()
);
println!();
let detected = &report["detected"];
print_section("Detected", |out| {
let pms = detected["package_managers"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
if !pms.is_empty() {
writeln_field(out, "package managers", &pms);
}
let trs = detected["task_runners"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
if !trs.is_empty() {
writeln_field(out, "task runners", &trs);
}
if let Some(nv) = detected["node_version"].as_object() {
let expected = nv["expected"].as_str().unwrap_or("?");
let source = nv["source"].as_str().unwrap_or("?");
writeln_field(out, "node version", &format!("{expected} ({source})"));
}
if detected["monorepo"].as_bool() == Some(true) {
writeln_field(out, "monorepo", "yes");
}
});
print_section("Overrides", |out| {
if let Some(pm) = report["overrides"]["pm"].as_object() {
writeln_field(
out,
"pm",
&format!(
"{} ({})",
pm["pm"].as_str().unwrap_or("?"),
pm["origin"].as_str().unwrap_or("?")
),
);
}
let empty = Map::new();
for (eco, pm) in report["overrides"]["pm_by_ecosystem"]
.as_object()
.unwrap_or(&empty)
{
writeln_field(
out,
&format!("pm.{eco}"),
&format!(
"{} ({})",
pm["pm"].as_str().unwrap_or("?"),
pm["origin"].as_str().unwrap_or("?")
),
);
}
if let Some(r) = report["overrides"]["runner"].as_object() {
writeln_field(
out,
"runner",
&format!(
"{} ({})",
r["runner"].as_str().unwrap_or("?"),
r["origin"].as_str().unwrap_or("?")
),
);
}
writeln_field(
out,
"fallback",
report["overrides"]["fallback"].as_str().unwrap_or("?"),
);
if overrides.explain {
writeln_field(out, "explain", "on");
}
});
print_section("Signals (Node)", |out| {
let node = &report["signals"]["node"];
if let Some(lp) = node["lockfile_pm"].as_str() {
writeln_field(out, "lockfile pm", lp);
}
if let Some(mp) = node["manifest_pm"].as_object() {
let pm = mp["pm"].as_str().unwrap_or("?");
let source = mp["source"].as_str().unwrap_or("?");
let version = mp["version"]
.as_str()
.map_or(String::new(), |v| format!(" {v}"));
let on_fail = mp["on_fail"].as_str().unwrap_or("?");
writeln_field(
out,
"manifest pm",
&format!("{pm}{version} via {source} (onFail={on_fail})"),
);
}
if let Some(probe) = node["path_probe"].as_object() {
let shims = node["volta_shims"].as_object();
let parts: Vec<String> = probe
.iter()
.map(|(bin, path)| {
format_probe_entry(bin, path.as_str(), shims.and_then(|s| s.get(bin)))
})
.collect();
writeln_field(out, "PATH probe", &parts.join(", "));
}
});
print_section("Decisions", |out| {
if let Some(pm) = report["decisions"]["node_pm"].as_object() {
let via = pm.get("via").and_then(Value::as_str).unwrap_or("?");
writeln_field(out, "node scripts", via);
}
if let Some(err) = report["decisions"]["node_pm_error"].as_str() {
writeln!(out, " {:<20}{}", "node scripts".red(), err.red())
.expect("writeln to String should not fail");
}
});
let warnings = report["warnings"].as_array().cloned().unwrap_or_default();
if !warnings.is_empty() {
println!("{}", "Warnings".bold());
for w in &warnings {
println!(
" {} {}: {}",
"warn:".yellow().bold(),
w["source"].as_str().unwrap_or("?"),
w["detail"].as_str().unwrap_or("?"),
);
}
}
}
fn format_probe_entry(bin: &str, path: Option<&str>, shim: Option<&Value>) -> String {
let Some(path) = path else {
return format!("{bin}={}", "not found".dimmed());
};
match shim.map(|s| s["resolved"].as_str()) {
Some(Some(real)) => format!("{bin}={path} -> {real} {}", "(volta)".dimmed()),
Some(None) => format!("{bin}={path} {}", "(volta shim, not provisioned)".dimmed()),
None => format!("{bin}={path}"),
}
}
fn print_section<F>(title: &str, fill: F)
where
F: FnOnce(&mut String),
{
let mut body = String::new();
fill(&mut body);
if body.is_empty() {
return;
}
println!("{}", title.bold());
print!("{body}");
println!();
}
fn writeln_field(out: &mut String, label: &str, value: &str) {
let _ = writeln!(out, " {:<20}{}", label.dimmed(), value);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{build_report, doctor};
use crate::resolver::ResolutionOverrides;
use crate::types::{PackageManager, ProjectContext};
fn context() -> ProjectContext {
ProjectContext {
root: PathBuf::from("/tmp/test"),
package_managers: vec![PackageManager::Pnpm, PackageManager::Cargo],
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
#[test]
fn format_probe_entry_renders_all_four_cases() {
use serde_json::json;
use super::format_probe_entry;
let not_found = format_probe_entry("npm", None, None);
assert!(not_found.starts_with("npm="), "{not_found}");
assert!(not_found.contains("not found"), "{not_found}");
let plain = format_probe_entry("bun", Some(r"C:\bun\bun.EXE"), None);
assert!(plain.contains(r"bun=C:\bun\bun.EXE"), "{plain}");
assert!(!plain.contains("volta"), "{plain}");
let shim = json!({ "resolved": r"C:\Volta\image\npm\11.6.2\npm.cmd" });
let resolved = format_probe_entry("npm", Some(r"C:\Volta\npm.EXE"), Some(&shim));
assert!(
resolved.contains(r"npm=C:\Volta\npm.EXE -> C:\Volta\image\npm\11.6.2\npm.cmd"),
"{resolved}"
);
assert!(resolved.contains("(volta)"), "{resolved}");
let phantom = json!({ "resolved": null });
let unprovisioned = format_probe_entry("pnpm", Some(r"C:\Volta\pnpm.EXE"), Some(&phantom));
assert!(
unprovisioned.contains("volta shim, not provisioned"),
"{unprovisioned}"
);
}
#[test]
fn build_report_omits_volta_shims_when_not_resolving() {
let ctx = context();
let report = build_report(&ctx, &ResolutionOverrides::default());
assert!(
report["signals"]["node"].get("volta_shims").is_none(),
"volta_shims must be omitted when empty: {}",
report["signals"]["node"],
);
assert!(
report["signals"]["node"].get("path_probe").is_some(),
"path_probe shape must be unchanged",
);
}
#[test]
fn build_report_includes_schema_version() {
let ctx = context();
let report = build_report(&ctx, &ResolutionOverrides::default());
assert_eq!(report["schema_version"], 2);
}
#[test]
fn build_report_enumerates_detected_pms() {
let ctx = context();
let report = build_report(&ctx, &ResolutionOverrides::default());
let pms = report["detected"]["package_managers"]
.as_array()
.expect("array");
let labels: Vec<&str> = pms.iter().filter_map(|v| v.as_str()).collect();
assert!(labels.contains(&"pnpm"));
assert!(labels.contains(&"cargo"));
}
#[test]
fn build_report_reports_ecosystems_from_detected_pms() {
let ctx = context();
let report = build_report(&ctx, &ResolutionOverrides::default());
let ecos = report["ecosystems"].as_array().expect("array");
let labels: Vec<&str> = ecos.iter().filter_map(|v| v.as_str()).collect();
assert!(labels.contains(&"node"));
assert!(labels.contains(&"rust"));
}
#[test]
fn doctor_json_runs_without_panic() {
let ctx = context();
doctor(
&ctx,
&ResolutionOverrides::default(),
true,
crate::schema::CURRENT_VERSION,
)
.expect("json render should succeed");
doctor(
&ctx,
&ResolutionOverrides::default(),
false,
crate::schema::CURRENT_VERSION,
)
.expect("human render should succeed");
}
#[test]
fn build_report_merges_resolver_warnings_with_ctx_warnings() {
use std::fs;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("doctor-merges-warnings");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "yarn@4.3.0" }"#,
)
.expect("package.json should be written");
fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9\n")
.expect("pnpm-lock.yaml should be written");
let ctx = detect(dir.path());
let report = build_report(&ctx, &ResolutionOverrides::default());
let warnings = report["warnings"].as_array().expect("warnings array");
assert!(
warnings.iter().any(|w| w["detail"]
.as_str()
.is_some_and(|d| d.contains("declaration wins"))),
"expected resolver-produced PM mismatch warning to surface in doctor output, got: {warnings:?}",
);
}
}