Skip to main content

tftio_cli_common/
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::types::{DoctorCheck, RepoInfo};
7use serde_json::{Map, Value, json};
8use std::fmt::Write as _;
9
10/// Structured doctor report reusable for text and JSON output.
11#[derive(Debug, Clone)]
12pub struct DoctorReport {
13    header: String,
14    checks: Vec<DoctorCheck>,
15    errors: Vec<String>,
16    warnings: Vec<String>,
17    info: Vec<String>,
18    version: Option<String>,
19    details: Map<String, Value>,
20}
21
22impl DoctorReport {
23    /// Create an empty doctor report.
24    #[must_use]
25    pub fn new(header: impl Into<String>) -> Self {
26        Self {
27            header: header.into(),
28            checks: Vec::new(),
29            errors: Vec::new(),
30            warnings: Vec::new(),
31            info: Vec::new(),
32            version: None,
33            details: Map::new(),
34        }
35    }
36
37    /// Create a doctor report scaffold for a tool using the standard header, version, and checks.
38    #[must_use]
39    pub fn for_tool<T: DoctorChecks>(tool: &T) -> Self {
40        Self::with_tool_header(tool, format!("đŸĨ {} health check", T::repo_info().name))
41    }
42
43    /// Create a doctor report scaffold for a tool with a caller-provided header.
44    #[must_use]
45    pub fn with_tool_header<T: DoctorChecks>(tool: &T, header: impl Into<String>) -> Self {
46        Self::new(header)
47            .with_checks(tool.tool_checks())
48            .with_version(T::current_version())
49    }
50
51    /// Set the report checks.
52    #[must_use]
53    pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
54        self.checks = checks;
55        self
56    }
57
58    /// Set the reported version string.
59    #[must_use]
60    pub fn with_version(mut self, version: impl Into<String>) -> Self {
61        self.version = Some(version.into());
62        self
63    }
64
65    /// Add an error line.
66    #[must_use]
67    pub fn with_error(mut self, error: impl Into<String>) -> Self {
68        self.errors.push(error.into());
69        self
70    }
71
72    /// Add a warning line.
73    #[must_use]
74    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
75        self.warnings.push(warning.into());
76        self
77    }
78
79    /// Add an informational line.
80    #[must_use]
81    pub fn with_info(mut self, info: impl Into<String>) -> Self {
82        self.info.push(info.into());
83        self
84    }
85
86    /// Add a custom JSON detail field.
87    #[must_use]
88    pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
89        self.details.insert(key.into(), value);
90        self
91    }
92
93    /// Access the underlying checks.
94    #[must_use]
95    pub fn checks(&self) -> &[DoctorCheck] {
96        &self.checks
97    }
98
99    fn failed_checks(&self) -> usize {
100        self.checks.iter().filter(|check| !check.passed).count()
101    }
102
103    /// Return the process exit code implied by this report.
104    #[must_use]
105    pub fn exit_code(&self) -> i32 {
106        if self.failed_checks() > 0 || !self.errors.is_empty() {
107            1
108        } else {
109            0
110        }
111    }
112
113    /// Render the report as JSON.
114    #[must_use]
115    pub fn to_json_value(&self) -> Value {
116        let mut value = json!({
117            "ok": self.exit_code() == 0,
118            "header": self.header,
119            "checks": self
120                .checks
121                .iter()
122                .map(|check| json!({
123                    "name": check.name,
124                    "passed": check.passed,
125                    "message": check.message,
126                }))
127                .collect::<Vec<_>>(),
128            "errors": self.errors,
129            "warnings": self.warnings,
130            "info": self.info,
131            "version": self.version,
132        });
133
134        let object = value
135            .as_object_mut()
136            .expect("doctor report JSON must be an object");
137        for (key, detail) in &self.details {
138            object.insert(key.clone(), detail.clone());
139        }
140        value
141    }
142
143    /// Render the report as plain text.
144    #[must_use]
145    pub fn render_text(&self) -> String {
146        let mut output = String::new();
147        writeln!(&mut output, "{}", self.header).expect("write to string");
148        writeln!(&mut output, "{}", "=".repeat(self.header.chars().count()))
149            .expect("write to string");
150        writeln!(&mut output).expect("write to string");
151
152        if !self.checks.is_empty() {
153            writeln!(&mut output, "Configuration:").expect("write to string");
154            for check in &self.checks {
155                if check.passed {
156                    writeln!(&mut output, "  ✅ {}", check.name).expect("write to string");
157                } else {
158                    writeln!(&mut output, "  ❌ {}", check.name).expect("write to string");
159                    if let Some(message) = &check.message {
160                        writeln!(&mut output, "     {message}").expect("write to string");
161                    }
162                }
163            }
164            writeln!(&mut output).expect("write to string");
165        }
166
167        if !self.info.is_empty() {
168            writeln!(&mut output, "Info:").expect("write to string");
169            for info in &self.info {
170                writeln!(&mut output, "  â„šī¸  {info}").expect("write to string");
171            }
172            writeln!(&mut output).expect("write to string");
173        }
174
175        if !self.warnings.is_empty() {
176            writeln!(&mut output, "Warnings:").expect("write to string");
177            for warning in &self.warnings {
178                writeln!(&mut output, "  âš ī¸  {warning}").expect("write to string");
179            }
180            writeln!(&mut output).expect("write to string");
181        }
182
183        if self.exit_code() == 0 {
184            writeln!(&mut output, "✨ Everything looks healthy!").expect("write to string");
185        } else {
186            writeln!(&mut output, "❌ Issues found - see above for details")
187                .expect("write to string");
188        }
189
190        output
191    }
192
193    /// Emit the report in the selected format and return its exit code.
194    #[must_use]
195    pub fn emit(&self, json: bool) -> i32 {
196        if json {
197            print_doctor_report_json(self)
198        } else {
199            print_doctor_report_text(self)
200        }
201    }
202}
203
204/// Trait for tools that support doctor health checks.
205///
206/// Implement this trait to provide tool-specific health checks.
207pub trait DoctorChecks {
208    /// Get the repository information for this tool.
209    fn repo_info() -> RepoInfo;
210
211    /// Get the current version of this tool.
212    fn current_version() -> &'static str;
213
214    /// Run tool-specific health checks.
215    ///
216    /// Return a vector of check results. Default implementation returns empty vector.
217    fn tool_checks(&self) -> Vec<DoctorCheck> {
218        Vec::new()
219    }
220}
221
222/// Run doctor command to check health and configuration.
223///
224/// Returns exit code: 0 if healthy, 1 if issues found.
225///
226/// # Type Parameters
227/// * `T` - A type that implements `DoctorChecks`
228pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
229    let header = format!("đŸĨ {} health check", T::repo_info().name);
230    run_doctor_with_header(tool, &header)
231}
232
233fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
234    DoctorReport::with_tool_header(tool, header)
235}
236
237fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
238    let report = build_doctor_report(tool, header);
239    (report.render_text(), report.exit_code())
240}
241
242/// Run doctor output with a custom header.
243pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
244    let (output, exit_code) = render_doctor_with_header(tool, header);
245    print!("{output}");
246    exit_code
247}
248
249/// Print a structured doctor report as JSON and return its exit code.
250#[must_use]
251pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
252    println!(
253        "{}",
254        serde_json::to_string_pretty(&report.to_json_value())
255            .expect("doctor report JSON must serialize")
256    );
257    report.exit_code()
258}
259
260/// Print a structured doctor report as plain text and return its exit code.
261#[must_use]
262pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
263    print!("{}", report.render_text());
264    report.exit_code()
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    struct TestTool;
272
273    impl DoctorChecks for TestTool {
274        fn repo_info() -> RepoInfo {
275            RepoInfo::new("workhelix", "test-tool")
276        }
277
278        fn current_version() -> &'static str {
279            "1.0.0"
280        }
281
282        fn tool_checks(&self) -> Vec<DoctorCheck> {
283            vec![
284                DoctorCheck::pass("Test check 1"),
285                DoctorCheck::fail("Test check 2", "This is a failure"),
286            ]
287        }
288    }
289
290    #[test]
291    fn test_run_doctor() {
292        let tool = TestTool;
293        let exit_code = run_doctor(&tool);
294        // Should return 1 because we have a failing check
295        assert_eq!(exit_code, 1);
296    }
297
298    #[test]
299    fn test_run_doctor_with_custom_header() {
300        let tool = TestTool;
301        let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
302        assert!(output.contains("Custom Header"));
303        assert_eq!(exit_code, 1);
304    }
305
306    #[test]
307    fn doctor_report_json_includes_details() {
308        let report = DoctorReport::new("Header")
309            .with_checks(vec![DoctorCheck::pass("check")])
310            .with_detail("config_file_exists", json!(true));
311
312        let value = report.to_json_value();
313        assert_eq!(value["config_file_exists"], json!(true));
314        assert_eq!(value["ok"], json!(true));
315    }
316
317    #[test]
318    fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
319        let report = DoctorReport::for_tool(&TestTool);
320        let value = report.to_json_value();
321
322        assert_eq!(value["header"], json!("đŸĨ test-tool health check"));
323        assert_eq!(value["version"], json!("1.0.0"));
324        assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
325    }
326
327    #[test]
328    fn doctor_report_emit_returns_exit_code_for_selected_format() {
329        let report = DoctorReport::for_tool(&TestTool);
330        assert_eq!(report.emit(true), 1);
331    }
332}