1use 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#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
21#[serde(rename_all = "lowercase")]
22pub enum HealthLevel {
23 Good,
25 Warnings,
27 Issues,
29}
30
31#[derive(Serialize)]
33pub struct HealthCheck {
34 pub id: &'static str,
35 pub ok: bool,
36 pub detail: String,
37}
38
39#[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 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
72fn 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 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#[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 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 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 #[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}