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