grex-cli 1.3.1

grex — nested meta-repo manager. Pack-based, agent-native, Rust-fast.
Documentation
//! `grex doctor` CLI verb — thin wrapper over `grex_core::doctor`.
//!
//! Renders the report as either a table (default) or a JSON document
//! (`--json`), then exits with the severity-roll-up code.

use crate::cli::args::{DoctorArgs, GlobalFlags};
use anyhow::Result;
use grex_core::doctor::{
    run_doctor, scan_undeclared, DoctorOpts, DoctorReport, Severity, UndeclaredRepo,
};
use tokio_util::sync::CancellationToken;

pub fn run(args: DoctorArgs, global: &GlobalFlags, _cancel: &CancellationToken) -> Result<()> {
    let workspace = std::env::current_dir()?;

    // v1.2.5 — `--prune-quarantine` resolves the retention window from
    // the explicit `--retain-days` flag, falling back to the canonical
    // crate-level default. `--retain-days` without `--prune-quarantine`
    // is a no-op at the doctor surface (it's threaded into `grex sync`
    // separately via `SyncArgs::retain_days`).
    let prune_quarantine = if args.prune_quarantine {
        Some(args.retain_days.unwrap_or(grex_core::tree::DEFAULT_RETAIN_DAYS))
    } else {
        None
    };
    // v1.2.5 — parse `--restore-quarantine TS[:BASENAME]`. The `:`
    // separator is unambiguous because the on-disk timestamp segment
    // emitted by `iso8601_utc_now` replaces all colons with hyphens
    // (so a literal `:` cannot appear inside the TS portion).
    let restore_quarantine = args.restore_quarantine.as_deref().map(|raw| {
        if let Some((ts, basename)) = raw.split_once(':') {
            (ts.to_owned(), Some(basename.to_owned()))
        } else {
            (raw.to_owned(), None)
        }
    });

    // `DoctorOpts` is `#[non_exhaustive]` (v1.2.5 W1) — external crates
    // cannot use struct-literal construction even with `..base` per E0639.
    let mut opts = DoctorOpts::default();
    opts.fix = args.fix;
    opts.lint_config = args.lint_config;
    opts.shallow = args.shallow;
    opts.prune_quarantine = prune_quarantine;
    opts.restore_quarantine = restore_quarantine;
    opts.force = args.force;
    let report = run_doctor(&workspace, &opts)?;

    if global.json {
        println!("{}", render_json(&workspace, &report));
    } else {
        print_table(&report);
    }

    // v1.2.1 item 4 — opt-in full-filesystem scan for `.git/` dirs that
    // are NOT registered in the manifest tree. Report-only — does not
    // alter the doctor exit code semantics; `report.exit_code()` already
    // captures every check the scan complements.
    if args.scan_undeclared {
        let undeclared = scan_undeclared(&workspace, args.depth)?;
        if global.json {
            println!("{}", render_undeclared_json(&workspace, args.depth, &undeclared));
        } else {
            print_undeclared(&workspace, args.depth, &undeclared);
        }
    }

    std::process::exit(report.exit_code());
}

/// Render the report as a table. One row per finding.
fn print_table(report: &DoctorReport) {
    println!("{:<18} {:<8} DETAIL", "CHECK", "STATUS");
    for f in &report.findings {
        let status = match f.severity {
            Severity::Ok => "OK",
            Severity::Warning => "WARN",
            Severity::Error => "ERROR",
        };
        let detail = if f.detail.is_empty() { "-".to_string() } else { f.detail.clone() };
        let pack = f.pack.as_deref().unwrap_or("");
        let label = if pack.is_empty() {
            f.check.label().to_string()
        } else {
            format!("{}[{}]", f.check.label(), pack)
        };
        println!("{label:<18} {status:<8} {detail}");
    }
}

/// Canonical `doctor` JSON shape.
///
/// v1.3.0: top-level envelope dual-emits `workspace` + `pack`
/// (identical values, `workspace` first per the diff-friendly stability
/// contract — see `crates/grex/tests/cli_json.rs`) and nests the
/// existing report under `report`. The nested `report` object's shape
/// (exit_code / worst_severity / findings array) remains byte-equal to
/// the MCP handler's output (`crates/grex-mcp/src/tools/doctor.rs::render_report_json`)
/// — the dual-emit envelope is additive (CLI-only) and does NOT change
/// the inner report shape consumed by MCP clients. `man/reference/cli-json.md
/// §doctor` documents the envelope; the inner shape continues to live
/// alongside the MCP twin. Any field rename or addition inside `report`
/// MUST land in BOTH places in the same commit.
fn render_json(workspace: &std::path::Path, report: &DoctorReport) -> String {
    let findings: Vec<serde_json::Value> = report
        .findings
        .iter()
        .map(|f| {
            serde_json::json!({
                "check": f.check.label(),
                "severity": severity_label(f.severity),
                "pack": f.pack,
                "detail": f.detail,
                "auto_fixable": f.auto_fixable,
                "synthetic": f.synthetic,
            })
        })
        .collect();
    // v1.3.0 dual-emit envelope. `workspace` first, `pack` second
    // (identical value); `report` carries the byte-stable inner shape
    // shared with the MCP handler. `serde_json/preserve_order` (enabled
    // in this crate's Cargo.toml) keeps the source-order intact so the
    // diff-friendly key ordering survives serialisation.
    let workspace_str = workspace.display().to_string();
    let doc = serde_json::json!({
        "workspace": workspace_str,
        "pack": workspace_str,
        "report": {
            "exit_code": report.exit_code(),
            "worst_severity": severity_label(report.worst()),
            "findings": findings,
        },
    });
    // Compact form keeps the inner `report.*` byte-comparison against
    // the MCP surface trivial (MCP uses `Value::to_string`, also compact).
    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
}

fn severity_label(s: Severity) -> &'static str {
    match s {
        Severity::Ok => "ok",
        Severity::Warning => "warning",
        Severity::Error => "error",
    }
}

/// Render the `--scan-undeclared` report block to stdout. Always
/// printed AFTER the standard doctor table so the existing output
/// remains the first thing operators see.
fn print_undeclared(workspace: &std::path::Path, depth: Option<usize>, found: &[UndeclaredRepo]) {
    let depth_str = depth.map_or_else(|| "unlimited".to_string(), |d| d.to_string());
    println!();
    println!("Scanning {} for undeclared git repos (depth: {})...", workspace.display(), depth_str,);
    if found.is_empty() {
        println!("No undeclared git repos found below {}.", workspace.display());
        return;
    }
    println!();
    println!(
        "Found {} undeclared git repo{}:",
        found.len(),
        if found.len() == 1 { "" } else { "s" },
    );
    for repo in found {
        let url = match &repo.inferred_url {
            Some(u) => format!("<{u}>"),
            None => "[unknown]  (no remote.origin.url)".to_string(),
        };
        // Use forward slashes for cross-platform consistency in output.
        let path = repo.path.to_string_lossy().replace('\\', "/");
        println!("  ./{path:<40} {url}");
    }
    println!();
    println!("To register: grex add <url> <path>");
}

/// JSON twin of [`print_undeclared`]. Emits a single-line object
/// matching the human format's structure so machine consumers can
/// parse it directly.
fn render_undeclared_json(
    workspace: &std::path::Path,
    depth: Option<usize>,
    found: &[UndeclaredRepo],
) -> String {
    let entries: Vec<serde_json::Value> = found
        .iter()
        .map(|r| {
            let path = r.path.to_string_lossy().replace('\\', "/");
            serde_json::json!({
                "path": path,
                "inferred_url": r.inferred_url,
            })
        })
        .collect();
    // v1.3.0: dual-emit "workspace" + "pack". v1.4.0 drops "workspace".
    // Order pinned: `workspace` first, `pack` second; identical value.
    let workspace_str = workspace.display().to_string();
    let doc = serde_json::json!({
        "scan_undeclared": {
            "workspace": workspace_str,
            "pack": workspace_str,
            "depth": depth,
            "count": found.len(),
            "repos": entries,
        },
    });
    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
}