Skip to main content

grex_cli/cli/verbs/
doctor.rs

1//! `grex doctor` CLI verb — thin wrapper over `grex_core::doctor`.
2//!
3//! Renders the report as either a table (default) or a JSON document
4//! (`--json`), then exits with the severity-roll-up code.
5
6use crate::cli::args::{DoctorArgs, GlobalFlags};
7use anyhow::Result;
8use grex_core::doctor::{run_doctor, DoctorOpts, DoctorReport, Severity};
9use tokio_util::sync::CancellationToken;
10
11pub fn run(args: DoctorArgs, global: &GlobalFlags, _cancel: &CancellationToken) -> Result<()> {
12    let workspace = std::env::current_dir()?;
13    let opts = DoctorOpts { fix: args.fix, lint_config: args.lint_config, shallow: args.shallow };
14    let report = run_doctor(&workspace, &opts)?;
15
16    if global.json {
17        println!("{}", render_json(&report));
18    } else {
19        print_table(&report);
20    }
21
22    std::process::exit(report.exit_code());
23}
24
25/// Render the report as a table. One row per finding.
26fn print_table(report: &DoctorReport) {
27    println!("{:<18} {:<8} DETAIL", "CHECK", "STATUS");
28    for f in &report.findings {
29        let status = match f.severity {
30            Severity::Ok => "OK",
31            Severity::Warning => "WARN",
32            Severity::Error => "ERROR",
33        };
34        let detail = if f.detail.is_empty() { "-".to_string() } else { f.detail.clone() };
35        let pack = f.pack.as_deref().unwrap_or("");
36        let label = if pack.is_empty() {
37            f.check.label().to_string()
38        } else {
39            format!("{}[{}]", f.check.label(), pack)
40        };
41        println!("{label:<18} {status:<8} {detail}");
42    }
43}
44
45/// Canonical `doctor` JSON shape. Must remain byte-equal to the MCP
46/// handler's output (`crates/grex-mcp/src/tools/doctor.rs::render_report_json`)
47/// and match `man/reference/cli-json.md §doctor`. Any field rename or
48/// addition MUST land in all three places in the same commit.
49fn render_json(report: &DoctorReport) -> String {
50    let findings: Vec<serde_json::Value> = report
51        .findings
52        .iter()
53        .map(|f| {
54            serde_json::json!({
55                "check": f.check.label(),
56                "severity": severity_label(f.severity),
57                "pack": f.pack,
58                "detail": f.detail,
59                "auto_fixable": f.auto_fixable,
60                "synthetic": f.synthetic,
61            })
62        })
63        .collect();
64    let doc = serde_json::json!({
65        "exit_code": report.exit_code(),
66        "worst_severity": severity_label(report.worst()),
67        "findings": findings,
68    });
69    // Compact form so byte-comparison against the MCP surface (which
70    // uses `Value::to_string`, also compact) is trivial.
71    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
72}
73
74fn severity_label(s: Severity) -> &'static str {
75    match s {
76        Severity::Ok => "ok",
77        Severity::Warning => "warning",
78        Severity::Error => "error",
79    }
80}