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::{
9    run_doctor, scan_undeclared, DoctorOpts, DoctorReport, Severity, UndeclaredRepo,
10};
11use tokio_util::sync::CancellationToken;
12
13pub fn run(args: DoctorArgs, global: &GlobalFlags, _cancel: &CancellationToken) -> Result<()> {
14    let workspace = std::env::current_dir()?;
15    let opts = DoctorOpts { fix: args.fix, lint_config: args.lint_config, shallow: args.shallow };
16    let report = run_doctor(&workspace, &opts)?;
17
18    if global.json {
19        println!("{}", render_json(&report));
20    } else {
21        print_table(&report);
22    }
23
24    // v1.2.1 item 4 — opt-in full-filesystem scan for `.git/` dirs that
25    // are NOT registered in the manifest tree. Report-only — does not
26    // alter the doctor exit code semantics; `report.exit_code()` already
27    // captures every check the scan complements.
28    if args.scan_undeclared {
29        let undeclared = scan_undeclared(&workspace, args.depth)?;
30        if global.json {
31            println!("{}", render_undeclared_json(&workspace, args.depth, &undeclared));
32        } else {
33            print_undeclared(&workspace, args.depth, &undeclared);
34        }
35    }
36
37    std::process::exit(report.exit_code());
38}
39
40/// Render the report as a table. One row per finding.
41fn print_table(report: &DoctorReport) {
42    println!("{:<18} {:<8} DETAIL", "CHECK", "STATUS");
43    for f in &report.findings {
44        let status = match f.severity {
45            Severity::Ok => "OK",
46            Severity::Warning => "WARN",
47            Severity::Error => "ERROR",
48        };
49        let detail = if f.detail.is_empty() { "-".to_string() } else { f.detail.clone() };
50        let pack = f.pack.as_deref().unwrap_or("");
51        let label = if pack.is_empty() {
52            f.check.label().to_string()
53        } else {
54            format!("{}[{}]", f.check.label(), pack)
55        };
56        println!("{label:<18} {status:<8} {detail}");
57    }
58}
59
60/// Canonical `doctor` JSON shape. Must remain byte-equal to the MCP
61/// handler's output (`crates/grex-mcp/src/tools/doctor.rs::render_report_json`)
62/// and match `man/reference/cli-json.md §doctor`. Any field rename or
63/// addition MUST land in all three places in the same commit.
64fn render_json(report: &DoctorReport) -> String {
65    let findings: Vec<serde_json::Value> = report
66        .findings
67        .iter()
68        .map(|f| {
69            serde_json::json!({
70                "check": f.check.label(),
71                "severity": severity_label(f.severity),
72                "pack": f.pack,
73                "detail": f.detail,
74                "auto_fixable": f.auto_fixable,
75                "synthetic": f.synthetic,
76            })
77        })
78        .collect();
79    let doc = serde_json::json!({
80        "exit_code": report.exit_code(),
81        "worst_severity": severity_label(report.worst()),
82        "findings": findings,
83    });
84    // Compact form so byte-comparison against the MCP surface (which
85    // uses `Value::to_string`, also compact) is trivial.
86    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
87}
88
89fn severity_label(s: Severity) -> &'static str {
90    match s {
91        Severity::Ok => "ok",
92        Severity::Warning => "warning",
93        Severity::Error => "error",
94    }
95}
96
97/// Render the `--scan-undeclared` report block to stdout. Always
98/// printed AFTER the standard doctor table so the existing output
99/// remains the first thing operators see.
100fn print_undeclared(workspace: &std::path::Path, depth: Option<usize>, found: &[UndeclaredRepo]) {
101    let depth_str = depth.map_or_else(|| "unlimited".to_string(), |d| d.to_string());
102    println!();
103    println!("Scanning {} for undeclared git repos (depth: {})...", workspace.display(), depth_str,);
104    if found.is_empty() {
105        println!("No undeclared git repos found below {}.", workspace.display());
106        return;
107    }
108    println!();
109    println!(
110        "Found {} undeclared git repo{}:",
111        found.len(),
112        if found.len() == 1 { "" } else { "s" },
113    );
114    for repo in found {
115        let url = match &repo.inferred_url {
116            Some(u) => format!("<{u}>"),
117            None => "[unknown]  (no remote.origin.url)".to_string(),
118        };
119        // Use forward slashes for cross-platform consistency in output.
120        let path = repo.path.to_string_lossy().replace('\\', "/");
121        println!("  ./{path:<40} {url}");
122    }
123    println!();
124    println!("To register: grex add <url> <path>");
125}
126
127/// JSON twin of [`print_undeclared`]. Emits a single-line object
128/// matching the human format's structure so machine consumers can
129/// parse it directly.
130fn render_undeclared_json(
131    workspace: &std::path::Path,
132    depth: Option<usize>,
133    found: &[UndeclaredRepo],
134) -> String {
135    let entries: Vec<serde_json::Value> = found
136        .iter()
137        .map(|r| {
138            let path = r.path.to_string_lossy().replace('\\', "/");
139            serde_json::json!({
140                "path": path,
141                "inferred_url": r.inferred_url,
142            })
143        })
144        .collect();
145    let doc = serde_json::json!({
146        "scan_undeclared": {
147            "workspace": workspace.display().to_string(),
148            "depth": depth,
149            "count": found.len(),
150            "repos": entries,
151        },
152    });
153    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
154}