Skip to main content

lean_ctx/doctor/
report.rs

1//! Structured installation-health report for the dashboard doctor signal (#466).
2//!
3//! `lean-ctx doctor`'s terminal renderer ([`super::run`]) emits ANSI-coloured
4//! lines that are unfit for JSON. This module re-derives the same pass/fail
5//! predicates that [`super::compact_score`] counts into a clean, serializable
6//! shape the dashboard renders as a three-level health badge
7//! (good / warnings / issues) with a per-check breakdown — without shelling out
8//! or scraping coloured stdout, so the CLI and the dashboard stay in lockstep
9//! from a single source of truth.
10
11use serde::Serialize;
12
13use super::checks::{
14    capacity_warnings, mcp_config_outcome, shell_aliases_outcome, skill_files_outcome,
15};
16use super::common::{path_in_path_env, resolve_lean_ctx_binary};
17use super::deprecations::deprecations_outcome;
18
19/// Three-level health signal mirroring the issue's badge states (#466).
20#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
21#[serde(rename_all = "lowercase")]
22pub enum HealthLevel {
23    /// Every scored check passed and no advisories fired.
24    Good,
25    /// All scored checks pass, but non-critical advisories exist.
26    Warnings,
27    /// At least one scored check failed — needs attention.
28    Issues,
29}
30
31/// One scored install check, dashboard-ready (plain text, no ANSI).
32#[derive(Serialize)]
33pub struct HealthCheck {
34    pub id: &'static str,
35    pub ok: bool,
36    pub detail: String,
37}
38
39/// The structured payload served at `GET /api/doctor`.
40#[derive(Serialize)]
41pub struct HealthReport {
42    pub level: HealthLevel,
43    pub passed: u32,
44    pub total: u32,
45    pub checks: Vec<HealthCheck>,
46    pub warnings: Vec<String>,
47}
48
49impl HealthReport {
50    /// Map scored checks + advisories onto the three-level badge: a failed check
51    /// is always `Issues`; otherwise advisories (capacity, deprecations) demote a
52    /// clean install to `Warnings`; a spotless install is `Good`.
53    fn classify(passed: u32, total: u32, has_warnings: bool) -> HealthLevel {
54        if passed < total {
55            HealthLevel::Issues
56        } else if has_warnings {
57            HealthLevel::Warnings
58        } else {
59            HealthLevel::Good
60        }
61    }
62}
63
64fn check(id: &'static str, ok: bool, pass: &str, fail: &str) -> HealthCheck {
65    HealthCheck {
66        id,
67        ok,
68        detail: if ok { pass } else { fail }.to_string(),
69    }
70}
71
72/// Strip ANSI SGR sequences (`ESC[…m`) and collapse whitespace so a
73/// terminal-formatted doctor line becomes a single clean JSON/text string.
74fn strip_ansi(s: &str) -> String {
75    let mut out = String::with_capacity(s.len());
76    let mut chars = s.chars();
77    while let Some(c) = chars.next() {
78        if c == '\u{1b}' {
79            // Skip the CSI sequence up to and including its final 'm'.
80            for next in chars.by_ref() {
81                if next == 'm' {
82                    break;
83                }
84            }
85        } else {
86            out.push(c);
87        }
88    }
89    out.split_whitespace().collect::<Vec<_>>().join(" ")
90}
91
92/// Build the structured installation-health report.
93#[must_use]
94pub fn health_report() -> HealthReport {
95    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
96
97    let binary_ok = resolve_lean_ctx_binary().is_some() || path_in_path_env();
98    let data_dir_ok = data_dir.as_ref().is_some_and(|p| p.is_dir());
99    let stats_ok = data_dir
100        .as_ref()
101        .map(|d| d.join("stats.json"))
102        .and_then(|p| std::fs::metadata(p).ok())
103        .is_some_and(|m| m.is_file());
104
105    // These three are the authoritative pass/fail predicates the terminal doctor
106    // and `compact_score` already use — reuse `.ok` so the badge can never drift
107    // from `lean-ctx doctor`; only the human-readable text is dashboard-specific.
108    let shell_ok = shell_aliases_outcome().ok;
109    let mcp_ok = mcp_config_outcome().ok;
110    let skills_ok = skill_files_outcome().ok;
111
112    let checks = vec![
113        check(
114            "binary",
115            binary_ok,
116            "lean-ctx is on your PATH",
117            "lean-ctx is not on your PATH — run `lean-ctx init`",
118        ),
119        check(
120            "data_dir",
121            data_dir_ok,
122            "data directory present",
123            "data directory missing — it is created on first use",
124        ),
125        check(
126            "stats",
127            stats_ok,
128            "usage statistics are being recorded",
129            "no usage statistics yet — route a few commands through lean-ctx",
130        ),
131        check(
132            "shell",
133            shell_ok,
134            "shell integration active",
135            "shell integration not detected — run `lean-ctx init --global`",
136        ),
137        check(
138            "mcp",
139            mcp_ok,
140            "MCP server registered",
141            "MCP server not registered — click Fix or run `lean-ctx doctor --fix`",
142        ),
143        check(
144            "skills",
145            skills_ok,
146            "agent skill files installed",
147            "agent skill files missing — click Fix or run `lean-ctx doctor --fix`",
148        ),
149    ];
150
151    let passed = u32::try_from(checks.iter().filter(|c| c.ok).count()).unwrap_or(u32::MAX);
152    let total = u32::try_from(checks.len()).unwrap_or(u32::MAX);
153
154    // Advisories never fail the install (the checks above are green) but warrant
155    // a ⚠ badge: memory stores under capacity pressure and active deprecations.
156    let mut warnings: Vec<String> = capacity_warnings()
157        .into_iter()
158        .filter(|o| !o.ok)
159        .map(|o| strip_ansi(&o.line))
160        .collect();
161    let dep = deprecations_outcome();
162    if !dep.ok {
163        warnings.push(strip_ansi(&dep.line));
164    }
165
166    let level = HealthReport::classify(passed, total, !warnings.is_empty());
167
168    HealthReport {
169        level,
170        passed,
171        total,
172        checks,
173        warnings,
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn strip_ansi_removes_colour_and_collapses_whitespace() {
183        let raw = "\u{1b}[1mShell\u{1b}[0m  \u{1b}[32mconfigured\u{1b}[0m\n      in ~/.zshrc";
184        assert_eq!(strip_ansi(raw), "Shell configured in ~/.zshrc");
185    }
186
187    #[test]
188    fn strip_ansi_is_idempotent_on_plain_text() {
189        assert_eq!(strip_ansi("already clean"), "already clean");
190    }
191
192    #[test]
193    fn classify_issues_when_any_check_failed() {
194        assert_eq!(
195            HealthReport::classify(5, 6, false),
196            HealthLevel::Issues,
197            "a failed scored check always wins over advisories"
198        );
199        assert_eq!(HealthReport::classify(5, 6, true), HealthLevel::Issues);
200    }
201
202    #[test]
203    fn classify_warnings_when_clean_but_advisories() {
204        assert_eq!(HealthReport::classify(6, 6, true), HealthLevel::Warnings);
205    }
206
207    #[test]
208    fn classify_good_when_spotless() {
209        assert_eq!(HealthReport::classify(6, 6, false), HealthLevel::Good);
210    }
211
212    /// Read-only invariant: whatever the host state, the report is internally
213    /// consistent (counts match the checks, level matches the predicates) and
214    /// every detail string is ANSI-free.
215    #[test]
216    fn health_report_is_self_consistent() {
217        let r = health_report();
218        assert_eq!(r.total, 6, "six scored install checks");
219        assert_eq!(
220            r.passed,
221            u32::try_from(r.checks.iter().filter(|c| c.ok).count()).unwrap(),
222            "passed must equal the number of green checks"
223        );
224        let expected = HealthReport::classify(r.passed, r.total, !r.warnings.is_empty());
225        assert_eq!(
226            r.level, expected,
227            "level must follow the classification rule"
228        );
229        for c in &r.checks {
230            assert!(!c.detail.contains('\u{1b}'), "no ANSI in check detail");
231            assert!(!c.detail.is_empty());
232        }
233        for w in &r.warnings {
234            assert!(!w.contains('\u{1b}'), "no ANSI in warnings");
235        }
236    }
237
238    #[test]
239    fn report_serializes_with_lowercase_level() {
240        let report = HealthReport {
241            level: HealthLevel::Warnings,
242            passed: 6,
243            total: 6,
244            checks: vec![check("binary", true, "ok", "bad")],
245            warnings: vec!["facts: 206/200 (103%)".to_string()],
246        };
247        let json = serde_json::to_string(&report).unwrap();
248        assert!(json.contains(r#""level":"warnings""#));
249        assert!(json.contains(r#""id":"binary""#));
250        assert!(json.contains(r#""passed":6"#));
251    }
252}