1use crate::{
7 JsonOutput,
8 types::{DoctorCheck, RepoInfo},
9};
10use serde_json::{Map, Value, json};
11use std::fmt::Write as _;
12
13#[derive(Debug, Clone)]
15pub struct DoctorReport {
16 header: String,
17 checks: Vec<DoctorCheck>,
18 errors: Vec<String>,
19 warnings: Vec<String>,
20 info: Vec<String>,
21 version: Option<String>,
22 details: Map<String, Value>,
23}
24
25impl DoctorReport {
26 #[must_use]
28 pub fn new(header: impl Into<String>) -> Self {
29 Self {
30 header: header.into(),
31 checks: Vec::new(),
32 errors: Vec::new(),
33 warnings: Vec::new(),
34 info: Vec::new(),
35 version: None,
36 details: Map::new(),
37 }
38 }
39
40 #[must_use]
42 pub fn for_tool<T: DoctorChecks>(tool: &T) -> Self {
43 Self::with_tool_header(tool, format!("đĨ {} health check", T::repo_info().name))
44 }
45
46 #[must_use]
48 pub fn with_tool_header<T: DoctorChecks>(tool: &T, header: impl Into<String>) -> Self {
49 Self::new(header)
50 .with_checks(tool.tool_checks())
51 .with_version(T::current_version())
52 }
53
54 #[must_use]
56 pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
57 self.checks = checks;
58 self
59 }
60
61 #[must_use]
63 pub fn with_version(mut self, version: impl Into<String>) -> Self {
64 self.version = Some(version.into());
65 self
66 }
67
68 #[must_use]
70 pub fn with_error(mut self, error: impl Into<String>) -> Self {
71 self.errors.push(error.into());
72 self
73 }
74
75 #[must_use]
77 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
78 self.warnings.push(warning.into());
79 self
80 }
81
82 #[must_use]
84 pub fn with_info(mut self, info: impl Into<String>) -> Self {
85 self.info.push(info.into());
86 self
87 }
88
89 #[must_use]
91 pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
92 self.details.insert(key.into(), value);
93 self
94 }
95
96 #[must_use]
98 pub fn checks(&self) -> &[DoctorCheck] {
99 &self.checks
100 }
101
102 fn failed_checks(&self) -> usize {
103 self.checks.iter().filter(|check| !check.passed).count()
104 }
105
106 #[must_use]
108 pub fn exit_code(&self) -> i32 {
109 i32::from(self.failed_checks() > 0 || !self.errors.is_empty())
110 }
111
112 #[must_use]
114 pub fn to_json_value(&self) -> Value {
115 let mut value = json!({
116 "ok": self.exit_code() == 0,
117 "header": self.header,
118 "checks": self
119 .checks
120 .iter()
121 .map(|check| json!({
122 "name": check.name,
123 "passed": check.passed,
124 "message": check.message,
125 }))
126 .collect::<Vec<_>>(),
127 "errors": self.errors,
128 "warnings": self.warnings,
129 "info": self.info,
130 "version": self.version,
131 });
132
133 if let Some(object) = value.as_object_mut() {
134 for (key, detail) in &self.details {
135 object.insert(key.clone(), detail.clone());
136 }
137 }
138 value
139 }
140
141 #[must_use]
143 pub fn render_text(&self) -> String {
144 let mut output = String::new();
145 output.push_str(&self.header);
146 output.push('\n');
147 output.push_str(&"=".repeat(self.header.chars().count()));
148 output.push_str("\n\n");
149
150 if !self.checks.is_empty() {
151 output.push_str("Configuration:\n");
152 for check in &self.checks {
153 if check.passed {
154 writeln!(&mut output, " â
{}", check.name).unwrap_or_default();
155 } else {
156 writeln!(&mut output, " â {}", check.name).unwrap_or_default();
157 if let Some(message) = &check.message {
158 writeln!(&mut output, " {message}").unwrap_or_default();
159 }
160 }
161 }
162 output.push('\n');
163 }
164
165 if !self.info.is_empty() {
166 output.push_str("Info:\n");
167 for info in &self.info {
168 writeln!(&mut output, " âšī¸ {info}").unwrap_or_default();
169 }
170 output.push('\n');
171 }
172
173 if !self.warnings.is_empty() {
174 output.push_str("Warnings:\n");
175 for warning in &self.warnings {
176 writeln!(&mut output, " â ī¸ {warning}").unwrap_or_default();
177 }
178 output.push('\n');
179 }
180
181 if self.exit_code() == 0 {
182 output.push_str("⨠Everything looks healthy!\n");
183 } else {
184 output.push_str("â Issues found - see above for details\n");
185 }
186
187 output
188 }
189
190 #[must_use]
192 pub fn emit_output(&self, output: JsonOutput) -> i32 {
193 if output.is_json() {
194 print_doctor_report_json(self)
195 } else {
196 print_doctor_report_text(self)
197 }
198 }
199}
200
201pub trait DoctorChecks {
205 fn repo_info() -> RepoInfo;
207
208 fn current_version() -> &'static str;
210
211 fn tool_checks(&self) -> Vec<DoctorCheck> {
215 Vec::new()
216 }
217}
218
219pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
226 let header = format!("đĨ {} health check", T::repo_info().name);
227 run_doctor_with_output_and_header(tool, &header, JsonOutput::Text)
228}
229
230fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
231 DoctorReport::with_tool_header(tool, header)
232}
233
234fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
235 let report = build_doctor_report(tool, header);
236 (report.render_text(), report.exit_code())
237}
238
239pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
241 run_doctor_with_output_and_header(tool, header, JsonOutput::Text)
242}
243
244pub fn run_doctor_with_output<T: DoctorChecks>(tool: &T, output: JsonOutput) -> i32 {
246 let header = format!("đĨ {} health check", T::repo_info().name);
247 run_doctor_with_output_and_header(tool, &header, output)
248}
249
250pub fn run_doctor_with_output_and_header<T: DoctorChecks>(
252 tool: &T,
253 header: &str,
254 output: JsonOutput,
255) -> i32 {
256 if output.is_json() {
257 let report = build_doctor_report(tool, header);
258 print_doctor_report_json(&report)
259 } else {
260 let (rendered, exit_code) = render_doctor_with_header(tool, header);
261 print!("{rendered}");
262 exit_code
263 }
264}
265
266#[must_use]
268pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
269 let value = report.to_json_value();
270 match serde_json::to_string_pretty(&value) {
271 Ok(rendered) => println!("{rendered}"),
272 Err(error) => println!("{}", json!({ "ok": false, "error": error.to_string() })),
273 }
274 report.exit_code()
275}
276
277#[must_use]
279pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
280 print!("{}", report.render_text());
281 report.exit_code()
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 struct TestTool;
289
290 impl DoctorChecks for TestTool {
291 fn repo_info() -> RepoInfo {
292 RepoInfo::new("workhelix", "test-tool")
293 }
294
295 fn current_version() -> &'static str {
296 "1.0.0"
297 }
298
299 fn tool_checks(&self) -> Vec<DoctorCheck> {
300 vec![
301 DoctorCheck::pass("Test check 1"),
302 DoctorCheck::fail("Test check 2", "This is a failure"),
303 ]
304 }
305 }
306
307 #[test]
308 fn test_run_doctor() {
309 let tool = TestTool;
310 let exit_code = run_doctor(&tool);
311 assert_eq!(exit_code, 1);
313 }
314
315 #[test]
316 fn test_run_doctor_with_custom_header() {
317 let tool = TestTool;
318 let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
319 assert!(output.contains("Custom Header"));
320 assert_eq!(exit_code, 1);
321 }
322
323 #[test]
324 fn doctor_report_json_includes_details() {
325 let report = DoctorReport::new("Header")
326 .with_checks(vec![DoctorCheck::pass("check")])
327 .with_detail("config_file_exists", json!(true));
328
329 let value = report.to_json_value();
330 assert_eq!(value["config_file_exists"], json!(true));
331 assert_eq!(value["ok"], json!(true));
332 }
333
334 #[test]
335 fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
336 let report = DoctorReport::for_tool(&TestTool);
337 let value = report.to_json_value();
338
339 assert_eq!(value["header"], json!("đĨ test-tool health check"));
340 assert_eq!(value["version"], json!("1.0.0"));
341 assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
342 }
343
344 #[test]
345 fn doctor_report_emit_returns_exit_code_for_selected_format() {
346 let report = DoctorReport::for_tool(&TestTool);
347 assert_eq!(report.emit_output(JsonOutput::Json), 1);
348 }
349
350 #[test]
351 fn run_doctor_with_output_supports_json_mode() {
352 let tool = TestTool;
353 let exit_code = run_doctor_with_output(&tool, JsonOutput::Json);
354 assert_eq!(exit_code, 1);
355 }
356
357 #[test]
358 fn doctor_report_accumulates_errors_warnings_and_info() {
359 let report = DoctorReport::new("Header")
360 .with_checks(vec![
361 DoctorCheck::pass("c1"),
362 DoctorCheck::fail("c2", "boom"),
363 ])
364 .with_error("an error")
365 .with_warning("a warning")
366 .with_info("some info");
367
368 assert_eq!(report.checks().len(), 2);
369
370 let value = report.to_json_value();
371 assert_eq!(value["errors"], json!(["an error"]));
372 assert_eq!(value["warnings"], json!(["a warning"]));
373 assert_eq!(value["info"], json!(["some info"]));
374 assert_eq!(value["ok"], json!(false));
375 }
376
377 #[test]
378 fn doctor_report_exit_code_reflects_errors_without_failing_checks() {
379 let clean = DoctorReport::new("Header").with_checks(vec![DoctorCheck::pass("ok")]);
380 assert_eq!(clean.exit_code(), 0);
381
382 let with_error = clean.with_error("something went wrong");
383 assert_eq!(with_error.exit_code(), 1);
384 }
385
386 #[test]
387 fn render_text_renders_checks_info_and_warnings() {
388 let report = DoctorReport::new("Doctor Report")
389 .with_checks(vec![
390 DoctorCheck::pass("passing check"),
391 DoctorCheck::fail("failing check", "the failure detail"),
392 ])
393 .with_info("informational note")
394 .with_warning("cautionary note");
395
396 let text = report.render_text();
397 assert!(text.contains("Doctor Report"), "header missing: {text}");
398 assert!(text.contains("Configuration:"), "checks section missing");
399 assert!(text.contains("â
passing check"), "passing mark missing");
400 assert!(text.contains("â failing check"), "failing mark missing");
401 assert!(
402 text.contains("the failure detail"),
403 "failure message missing"
404 );
405 assert!(text.contains("Info:"), "info section missing");
406 assert!(text.contains("informational note"), "info line missing");
407 assert!(text.contains("Warnings:"), "warnings section missing");
408 assert!(text.contains("cautionary note"), "warning line missing");
409 assert!(text.contains("Issues found"), "unhealthy footer missing");
410 }
411
412 #[test]
413 fn render_text_reports_healthy_when_all_checks_pass() {
414 let report = DoctorReport::new("Healthy").with_checks(vec![DoctorCheck::pass("all good")]);
415 let text = report.render_text();
416 assert!(
417 text.contains("Everything looks healthy"),
418 "healthy footer missing"
419 );
420 assert!(!text.contains("Issues found"), "should not report issues");
421 }
422
423 #[test]
424 fn run_doctor_with_header_returns_failure_exit_code() {
425 let tool = TestTool;
426 let exit_code = run_doctor_with_header(&tool, "Custom Doctor Header");
427 assert_eq!(exit_code, 1);
428 }
429}