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::{
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        if self.failed_checks() > 0 || !self.errors.is_empty() {
110            1
111        } else {
112            0
113        }
114    }
115
116    /// Render the report as JSON.
117    #[must_use]
118    pub fn to_json_value(&self) -> Value {
119        let mut value = json!({
120            "ok": self.exit_code() == 0,
121            "header": self.header,
122            "checks": self
123                .checks
124                .iter()
125                .map(|check| json!({
126                    "name": check.name,
127                    "passed": check.passed,
128                    "message": check.message,
129                }))
130                .collect::<Vec<_>>(),
131            "errors": self.errors,
132            "warnings": self.warnings,
133            "info": self.info,
134            "version": self.version,
135        });
136
137        let object = value.as_object_mut().expect(
138            "the report is serialized from a struct, so the JSON value is always an object",
139        );
140        for (key, detail) in &self.details {
141            object.insert(key.clone(), detail.clone());
142        }
143        value
144    }
145
146    /// Render the report as plain text.
147    #[must_use]
148    pub fn render_text(&self) -> String {
149        let mut output = String::new();
150        writeln!(&mut output, "{}", self.header).expect("write to String is infallible");
151        writeln!(&mut output, "{}", "=".repeat(self.header.chars().count()))
152            .expect("write to String is infallible");
153        writeln!(&mut output).expect("write to String is infallible");
154
155        if !self.checks.is_empty() {
156            writeln!(&mut output, "Configuration:").expect("write to String is infallible");
157            for check in &self.checks {
158                if check.passed {
159                    writeln!(&mut output, "  ✅ {}", check.name)
160                        .expect("write to String is infallible");
161                } else {
162                    writeln!(&mut output, "  ❌ {}", check.name)
163                        .expect("write to String is infallible");
164                    if let Some(message) = &check.message {
165                        writeln!(&mut output, "     {message}")
166                            .expect("write to String is infallible");
167                    }
168                }
169            }
170            writeln!(&mut output).expect("write to String is infallible");
171        }
172
173        if !self.info.is_empty() {
174            writeln!(&mut output, "Info:").expect("write to String is infallible");
175            for info in &self.info {
176                writeln!(&mut output, "  â„šī¸  {info}").expect("write to String is infallible");
177            }
178            writeln!(&mut output).expect("write to String is infallible");
179        }
180
181        if !self.warnings.is_empty() {
182            writeln!(&mut output, "Warnings:").expect("write to String is infallible");
183            for warning in &self.warnings {
184                writeln!(&mut output, "  âš ī¸  {warning}").expect("write to String is infallible");
185            }
186            writeln!(&mut output).expect("write to String is infallible");
187        }
188
189        if self.exit_code() == 0 {
190            writeln!(&mut output, "✨ Everything looks healthy!")
191                .expect("write to String is infallible");
192        } else {
193            writeln!(&mut output, "❌ Issues found - see above for details")
194                .expect("write to String is infallible");
195        }
196
197        output
198    }
199
200    /// Emit the report in the selected format and return its exit code.
201    #[must_use]
202    pub fn emit_output(&self, output: JsonOutput) -> i32 {
203        if output.is_json() {
204            print_doctor_report_json(self)
205        } else {
206            print_doctor_report_text(self)
207        }
208    }
209}
210
211/// Trait for tools that support doctor health checks.
212///
213/// Implement this trait to provide tool-specific health checks.
214pub trait DoctorChecks {
215    /// Get the repository information for this tool.
216    fn repo_info() -> RepoInfo;
217
218    /// Get the current version of this tool.
219    fn current_version() -> &'static str;
220
221    /// Run tool-specific health checks.
222    ///
223    /// Return a vector of check results. Default implementation returns empty vector.
224    fn tool_checks(&self) -> Vec<DoctorCheck> {
225        Vec::new()
226    }
227}
228
229/// Run doctor command to check health and configuration.
230///
231/// Returns exit code: 0 if healthy, 1 if issues found.
232///
233/// # Type Parameters
234/// * `T` - A type that implements `DoctorChecks`
235pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
236    let header = format!("đŸĨ {} health check", T::repo_info().name);
237    run_doctor_with_output_and_header(tool, &header, JsonOutput::Text)
238}
239
240fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
241    DoctorReport::with_tool_header(tool, header)
242}
243
244fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
245    let report = build_doctor_report(tool, header);
246    (report.render_text(), report.exit_code())
247}
248
249/// Run doctor output with a custom header.
250pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
251    run_doctor_with_output_and_header(tool, header, JsonOutput::Text)
252}
253
254/// Run doctor output in the selected format.
255pub fn run_doctor_with_output<T: DoctorChecks>(tool: &T, output: JsonOutput) -> i32 {
256    let header = format!("đŸĨ {} health check", T::repo_info().name);
257    run_doctor_with_output_and_header(tool, &header, output)
258}
259
260/// Run doctor output with a custom header and output mode.
261pub fn run_doctor_with_output_and_header<T: DoctorChecks>(
262    tool: &T,
263    header: &str,
264    output: JsonOutput,
265) -> i32 {
266    if output.is_json() {
267        let report = build_doctor_report(tool, header);
268        print_doctor_report_json(&report)
269    } else {
270        let (rendered, exit_code) = render_doctor_with_header(tool, header);
271        print!("{rendered}");
272        exit_code
273    }
274}
275
276/// Print a structured doctor report as JSON and return its exit code.
277#[must_use]
278pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
279    println!(
280        "{}",
281        serde_json::to_string_pretty(&report.to_json_value())
282            .expect("DoctorReport contains only serializable fields")
283    );
284    report.exit_code()
285}
286
287/// Print a structured doctor report as plain text and return its exit code.
288#[must_use]
289pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
290    print!("{}", report.render_text());
291    report.exit_code()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    struct TestTool;
299
300    impl DoctorChecks for TestTool {
301        fn repo_info() -> RepoInfo {
302            RepoInfo::new("workhelix", "test-tool")
303        }
304
305        fn current_version() -> &'static str {
306            "1.0.0"
307        }
308
309        fn tool_checks(&self) -> Vec<DoctorCheck> {
310            vec![
311                DoctorCheck::pass("Test check 1"),
312                DoctorCheck::fail("Test check 2", "This is a failure"),
313            ]
314        }
315    }
316
317    #[test]
318    fn test_run_doctor() {
319        let tool = TestTool;
320        let exit_code = run_doctor(&tool);
321        // Should return 1 because we have a failing check
322        assert_eq!(exit_code, 1);
323    }
324
325    #[test]
326    fn test_run_doctor_with_custom_header() {
327        let tool = TestTool;
328        let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
329        assert!(output.contains("Custom Header"));
330        assert_eq!(exit_code, 1);
331    }
332
333    #[test]
334    fn doctor_report_json_includes_details() {
335        let report = DoctorReport::new("Header")
336            .with_checks(vec![DoctorCheck::pass("check")])
337            .with_detail("config_file_exists", json!(true));
338
339        let value = report.to_json_value();
340        assert_eq!(value["config_file_exists"], json!(true));
341        assert_eq!(value["ok"], json!(true));
342    }
343
344    #[test]
345    fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
346        let report = DoctorReport::for_tool(&TestTool);
347        let value = report.to_json_value();
348
349        assert_eq!(value["header"], json!("đŸĨ test-tool health check"));
350        assert_eq!(value["version"], json!("1.0.0"));
351        assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
352    }
353
354    #[test]
355    fn doctor_report_emit_returns_exit_code_for_selected_format() {
356        let report = DoctorReport::for_tool(&TestTool);
357        assert_eq!(report.emit_output(JsonOutput::Json), 1);
358    }
359
360    #[test]
361    fn run_doctor_with_output_supports_json_mode() {
362        let tool = TestTool;
363        let exit_code = run_doctor_with_output(&tool, JsonOutput::Json);
364        assert_eq!(exit_code, 1);
365    }
366}