workhelix_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//! including update checking and tool-specific diagnostics.
5
6use crate::types::{DoctorCheck, RepoInfo};
7
8/// Trait for tools that support doctor health checks.
9///
10/// Implement this trait to provide tool-specific health checks.
11pub trait DoctorChecks {
12    /// Get the repository information for this tool.
13    fn repo_info() -> RepoInfo;
14
15    /// Get the current version of this tool.
16    fn current_version() -> &'static str;
17
18    /// Run tool-specific health checks.
19    ///
20    /// Return a vector of check results. Default implementation returns empty vector.
21    fn tool_checks(&self) -> Vec<DoctorCheck> {
22        Vec::new()
23    }
24}
25
26/// Run doctor command to check health and configuration.
27///
28/// Returns exit code: 0 if healthy, 1 if issues found.
29///
30/// # Type Parameters
31/// * `T` - A type that implements `DoctorChecks`
32pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
33    let tool_name = T::repo_info().name;
34    println!("🏥 {tool_name} health check");
35    println!("{}", "=".repeat(tool_name.len() + 14));
36    println!();
37
38    let mut has_errors = false;
39    let mut has_warnings = false;
40
41    // Run tool-specific checks
42    let tool_checks = tool.tool_checks();
43    if !tool_checks.is_empty() {
44        println!("Configuration:");
45        for check in tool_checks {
46            if check.passed {
47                println!("  ✅ {}", check.name);
48            } else {
49                println!("  ❌ {}", check.name);
50                if let Some(msg) = check.message {
51                    println!("     {msg}");
52                }
53                has_errors = true;
54            }
55        }
56        println!();
57    }
58
59    // Check for updates
60    println!("Updates:");
61    match check_for_updates(&T::repo_info(), T::current_version()) {
62        Ok(Some(latest)) => {
63            let current = T::current_version();
64            println!("  ⚠️  Update available: v{latest} (current: v{current})");
65            println!("  💡 Run '{tool_name} update' to install the latest version");
66            has_warnings = true;
67        }
68        Ok(None) => {
69            println!("  ✅ Running latest version (v{})", T::current_version());
70        }
71        Err(e) => {
72            println!("  ⚠️  Failed to check for updates: {e}");
73            has_warnings = true;
74        }
75    }
76
77    println!();
78
79    // Summary
80    if has_errors {
81        println!("❌ Issues found - see above for details");
82        1
83    } else if has_warnings {
84        println!("⚠️  Warnings found");
85        0 // Warnings don't cause failure
86    } else {
87        println!("✨ Everything looks healthy!");
88        0
89    }
90}
91
92/// Check for updates from GitHub releases.
93///
94/// Returns `Ok(Some(version))` if an update is available, `Ok(None)` if up-to-date,
95/// or `Err` if the check failed.
96///
97/// # Errors
98/// Returns an error if the HTTP request fails or the response cannot be parsed.
99pub fn check_for_updates(
100    repo_info: &RepoInfo,
101    current_version: &str,
102) -> Result<Option<String>, String> {
103    let client = reqwest::blocking::Client::builder()
104        .user_agent(format!("{}-doctor", repo_info.name))
105        .timeout(std::time::Duration::from_secs(5))
106        .build()
107        .map_err(|e| e.to_string())?;
108
109    let response: serde_json::Value = client
110        .get(repo_info.latest_release_url())
111        .send()
112        .map_err(|e| e.to_string())?
113        .json()
114        .map_err(|e| e.to_string())?;
115
116    let tag_name = response["tag_name"]
117        .as_str()
118        .ok_or_else(|| "No tag_name in response".to_string())?;
119
120    let latest = tag_name
121        .trim_start_matches(repo_info.tag_prefix)
122        .trim_start_matches('v');
123
124    if latest == current_version {
125        Ok(None)
126    } else {
127        Ok(Some(latest.to_string()))
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    struct TestTool;
136
137    impl DoctorChecks for TestTool {
138        fn repo_info() -> RepoInfo {
139            RepoInfo::new("workhelix", "test-tool", "test-tool-v")
140        }
141
142        fn current_version() -> &'static str {
143            "1.0.0"
144        }
145
146        fn tool_checks(&self) -> Vec<DoctorCheck> {
147            vec![
148                DoctorCheck::pass("Test check 1"),
149                DoctorCheck::fail("Test check 2", "This is a failure"),
150            ]
151        }
152    }
153
154    #[test]
155    fn test_run_doctor() {
156        let tool = TestTool;
157        let exit_code = run_doctor(&tool);
158        // Should return 1 because we have a failing check
159        assert_eq!(exit_code, 1);
160    }
161
162    #[test]
163    fn test_check_for_updates_handles_errors() {
164        let repo = RepoInfo::new("nonexistent", "repo", "v");
165        let result = check_for_updates(&repo, "1.0.0");
166        // Either succeeds or fails, both are acceptable in tests
167        assert!(result.is_ok() || result.is_err());
168    }
169}