Skip to main content

tftio_lib/
doctor.rs

1//! Health check and diagnostics module.
2//!
3//! This module provides a framework for running health checks on CLI tools
4//! with tool-specific diagnostics.
5
6use crate::{
7    JsonOutput,
8    types::{DoctorCheck, RepoInfo},
9};
10use serde_json::{Map, Value, json};
11use std::fmt::Write as _;
12
13/// Structured doctor report reusable for text and JSON output.
14#[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    /// Create an empty doctor report.
27    #[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    /// Create a doctor report scaffold for a tool using the standard header, version, and checks.
41    #[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    /// Create a doctor report scaffold for a tool with a caller-provided header.
47    #[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    /// Set the report checks.
55    #[must_use]
56    pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
57        self.checks = checks;
58        self
59    }
60
61    /// Set the reported version string.
62    #[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    /// Add an error line.
69    #[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    /// Add a warning line.
76    #[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    /// Add an informational line.
83    #[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    /// Add a custom JSON detail field.
90    #[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    /// Access the underlying checks.
97    #[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    /// Return the process exit code implied by this report.
107    #[must_use]
108    pub fn exit_code(&self) -> i32 {
109        i32::from(self.failed_checks() > 0 || !self.errors.is_empty())
110    }
111
112    /// Render the report as JSON.
113    #[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    /// Render the report as plain text.
142    #[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    /// Emit the report in the selected format and return its exit code.
191    #[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
201/// Trait for tools that support doctor health checks.
202///
203/// Implement this trait to provide tool-specific health checks.
204pub trait DoctorChecks {
205    /// Get the repository information for this tool.
206    fn repo_info() -> RepoInfo;
207
208    /// Get the current version of this tool.
209    fn current_version() -> &'static str;
210
211    /// Run tool-specific health checks.
212    ///
213    /// Return a vector of check results. Default implementation returns empty vector.
214    fn tool_checks(&self) -> Vec<DoctorCheck> {
215        Vec::new()
216    }
217}
218
219/// Run doctor command to check health and configuration.
220///
221/// Returns exit code: 0 if healthy, 1 if issues found.
222///
223/// # Type Parameters
224/// * `T` - A type that implements `DoctorChecks`
225pub 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
239/// Run doctor output with a custom header.
240pub 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
244/// Run doctor output in the selected format.
245pub 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
250/// Run doctor output with a custom header and output mode.
251pub 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/// Print a structured doctor report as JSON and return its exit code.
267#[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/// Print a structured doctor report as plain text and return its exit code.
278#[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        // Should return 1 because we have a failing check
312        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}