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