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
16    // v1.2.5 — `--prune-quarantine` resolves the retention window from
17    // the explicit `--retain-days` flag, falling back to the canonical
18    // crate-level default. `--retain-days` without `--prune-quarantine`
19    // is a no-op at the doctor surface (it's threaded into `grex sync`
20    // separately via `SyncArgs::retain_days`).
21    let prune_quarantine = if args.prune_quarantine {
22        Some(args.retain_days.unwrap_or(grex_core::tree::DEFAULT_RETAIN_DAYS))
23    } else {
24        None
25    };
26    // v1.2.5 — parse `--restore-quarantine TS[:BASENAME]`. The `:`
27    // separator is unambiguous because the on-disk timestamp segment
28    // emitted by `iso8601_utc_now` replaces all colons with hyphens
29    // (so a literal `:` cannot appear inside the TS portion).
30    let restore_quarantine = args.restore_quarantine.as_deref().map(|raw| {
31        if let Some((ts, basename)) = raw.split_once(':') {
32            (ts.to_owned(), Some(basename.to_owned()))
33        } else {
34            (raw.to_owned(), None)
35        }
36    });
37
38    // `DoctorOpts` is `#[non_exhaustive]` (v1.2.5 W1) — external crates
39    // cannot use struct-literal construction even with `..base` per E0639.
40    let mut opts = DoctorOpts::default();
41    opts.fix = args.fix;
42    opts.lint_config = args.lint_config;
43    opts.shallow = args.shallow;
44    opts.prune_quarantine = prune_quarantine;
45    opts.restore_quarantine = restore_quarantine;
46    opts.force = args.force;
47    let report = run_doctor(&workspace, &opts)?;
48
49    if global.json {
50        println!("{}", render_json(&workspace, &report));
51    } else {
52        print_table(&report);
53    }
54
55    // v1.2.1 item 4 — opt-in full-filesystem scan for `.git/` dirs that
56    // are NOT registered in the manifest tree. Report-only — does not
57    // alter the doctor exit code semantics; `report.exit_code()` already
58    // captures every check the scan complements.
59    if args.scan_undeclared {
60        let undeclared = scan_undeclared(&workspace, args.depth)?;
61        if global.json {
62            println!("{}", render_undeclared_json(&workspace, args.depth, &undeclared));
63        } else {
64            print_undeclared(&workspace, args.depth, &undeclared);
65        }
66    }
67
68    std::process::exit(report.exit_code());
69}
70
71/// Render the report as a table. One row per finding.
72fn print_table(report: &DoctorReport) {
73    println!("{:<18} {:<8} DETAIL", "CHECK", "STATUS");
74    for f in &report.findings {
75        let status = match f.severity {
76            Severity::Ok => "OK",
77            Severity::Warning => "WARN",
78            Severity::Error => "ERROR",
79        };
80        let detail = if f.detail.is_empty() { "-".to_string() } else { f.detail.clone() };
81        let pack = f.pack.as_deref().unwrap_or("");
82        let label = if pack.is_empty() {
83            f.check.label().to_string()
84        } else {
85            format!("{}[{}]", f.check.label(), pack)
86        };
87        println!("{label:<18} {status:<8} {detail}");
88    }
89}
90
91/// Canonical `doctor` JSON shape.
92///
93/// v1.3.0: top-level envelope dual-emits `workspace` + `pack`
94/// (identical values, `workspace` first per the diff-friendly stability
95/// contract — see `crates/grex/tests/cli_json.rs`) and nests the
96/// existing report under `report`. The nested `report` object's shape
97/// (exit_code / worst_severity / findings array) remains byte-equal to
98/// the MCP handler's output (`crates/grex-mcp/src/tools/doctor.rs::render_report_json`)
99/// — the dual-emit envelope is additive (CLI-only) and does NOT change
100/// the inner report shape consumed by MCP clients. `man/reference/cli-json.md
101/// §doctor` documents the envelope; the inner shape continues to live
102/// alongside the MCP twin. Any field rename or addition inside `report`
103/// MUST land in BOTH places in the same commit.
104fn render_json(workspace: &std::path::Path, report: &DoctorReport) -> String {
105    let findings: Vec<serde_json::Value> = report
106        .findings
107        .iter()
108        .map(|f| {
109            serde_json::json!({
110                "check": f.check.label(),
111                "severity": severity_label(f.severity),
112                "pack": f.pack,
113                "detail": f.detail,
114                "auto_fixable": f.auto_fixable,
115                "synthetic": f.synthetic,
116            })
117        })
118        .collect();
119    // v1.3.0 dual-emit envelope. `workspace` first, `pack` second
120    // (identical value); `report` carries the byte-stable inner shape
121    // shared with the MCP handler. `serde_json/preserve_order` (enabled
122    // in this crate's Cargo.toml) keeps the source-order intact so the
123    // diff-friendly key ordering survives serialisation.
124    let workspace_str = workspace.display().to_string();
125    let doc = serde_json::json!({
126        "workspace": workspace_str,
127        "pack": workspace_str,
128        "report": {
129            "exit_code": report.exit_code(),
130            "worst_severity": severity_label(report.worst()),
131            "findings": findings,
132        },
133    });
134    // Compact form keeps the inner `report.*` byte-comparison against
135    // the MCP surface trivial (MCP uses `Value::to_string`, also compact).
136    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
137}
138
139fn severity_label(s: Severity) -> &'static str {
140    match s {
141        Severity::Ok => "ok",
142        Severity::Warning => "warning",
143        Severity::Error => "error",
144    }
145}
146
147/// Render the `--scan-undeclared` report block to stdout. Always
148/// printed AFTER the standard doctor table so the existing output
149/// remains the first thing operators see.
150fn print_undeclared(workspace: &std::path::Path, depth: Option<usize>, found: &[UndeclaredRepo]) {
151    let depth_str = depth.map_or_else(|| "unlimited".to_string(), |d| d.to_string());
152    println!();
153    println!("Scanning {} for undeclared git repos (depth: {})...", workspace.display(), depth_str,);
154    if found.is_empty() {
155        println!("No undeclared git repos found below {}.", workspace.display());
156        return;
157    }
158    println!();
159    println!(
160        "Found {} undeclared git repo{}:",
161        found.len(),
162        if found.len() == 1 { "" } else { "s" },
163    );
164    for repo in found {
165        let url = match &repo.inferred_url {
166            Some(u) => format!("<{u}>"),
167            None => "[unknown]  (no remote.origin.url)".to_string(),
168        };
169        // Use forward slashes for cross-platform consistency in output.
170        let path = repo.path.to_string_lossy().replace('\\', "/");
171        println!("  ./{path:<40} {url}");
172    }
173    println!();
174    println!("To register: grex add <url> <path>");
175}
176
177/// JSON twin of [`print_undeclared`]. Emits a single-line object
178/// matching the human format's structure so machine consumers can
179/// parse it directly.
180fn render_undeclared_json(
181    workspace: &std::path::Path,
182    depth: Option<usize>,
183    found: &[UndeclaredRepo],
184) -> String {
185    let entries: Vec<serde_json::Value> = found
186        .iter()
187        .map(|r| {
188            let path = r.path.to_string_lossy().replace('\\', "/");
189            serde_json::json!({
190                "path": path,
191                "inferred_url": r.inferred_url,
192            })
193        })
194        .collect();
195    // v1.3.0: dual-emit "workspace" + "pack". v1.4.0 drops "workspace".
196    // Order pinned: `workspace` first, `pack` second; identical value.
197    let workspace_str = workspace.display().to_string();
198    let doc = serde_json::json!({
199        "scan_undeclared": {
200            "workspace": workspace_str,
201            "pack": workspace_str,
202            "depth": depth,
203            "count": found.len(),
204            "repos": entries,
205        },
206    });
207    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
208}