Skip to main content

auths_cli/commands/
doctor.rs

1//! Comprehensive health check command for Auths.
2
3use crate::adapters::system_diagnostic::PosixDiagnosticAdapter;
4use crate::ux::format::{JsonResponse, Output, is_json_mode};
5use anyhow::Result;
6use auths_core::storage::keychain;
7use auths_sdk::ports::diagnostics::{CheckResult, ConfigIssue};
8use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow;
9use clap::Parser;
10use serde::Serialize;
11
12/// Health check command.
13#[derive(Parser, Debug, Clone)]
14#[command(name = "doctor", about = "Run comprehensive health checks")]
15pub struct DoctorCommand {}
16
17/// A single health check.
18#[derive(Debug, Serialize)]
19pub struct Check {
20    name: String,
21    passed: bool,
22    detail: String,
23    suggestion: Option<String>,
24}
25
26/// Overall doctor report.
27#[derive(Debug, Serialize)]
28pub struct DoctorReport {
29    pub version: String,
30    pub checks: Vec<Check>,
31    pub all_pass: bool,
32}
33
34/// Handle the doctor command.
35pub fn handle_doctor(_cmd: DoctorCommand) -> Result<()> {
36    let checks = run_checks();
37    let all_pass = checks.iter().all(|c| c.passed);
38
39    let report = DoctorReport {
40        version: env!("CARGO_PKG_VERSION").to_string(),
41        checks,
42        all_pass,
43    };
44
45    if is_json_mode() {
46        JsonResponse {
47            success: all_pass,
48            command: "doctor".to_string(),
49            data: Some(report),
50            error: if !all_pass {
51                Some("some health checks failed".to_string())
52            } else {
53                None
54            },
55        }
56        .print()?;
57    } else {
58        print_report(&report);
59    }
60
61    if !all_pass {
62        std::process::exit(1);
63    }
64
65    Ok(())
66}
67
68/// Run all prerequisite checks.
69fn run_checks() -> Vec<Check> {
70    let adapter = PosixDiagnosticAdapter;
71    let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
72
73    let mut checks = Vec::new();
74
75    // Run SDK workflow checks (git version, ssh-keygen, signing config)
76    if let Ok(report) = workflow.run() {
77        for cr in report.checks {
78            let suggestion = if cr.passed {
79                None
80            } else {
81                suggestion_for_check(&cr.name)
82            };
83            checks.push(Check {
84                name: cr.name.clone(),
85                passed: cr.passed,
86                detail: format_check_detail(&cr),
87                suggestion,
88            });
89        }
90    }
91
92    // CLI-only checks that depend on keychain / local state
93    checks.push(check_keychain_accessible());
94    checks.push(check_identity_exists());
95    checks.push(check_allowed_signers_file());
96
97    checks
98}
99
100fn format_check_detail(cr: &CheckResult) -> String {
101    if !cr.config_issues.is_empty() {
102        let parts: Vec<String> = cr
103            .config_issues
104            .iter()
105            .map(|issue| match issue {
106                ConfigIssue::Mismatch {
107                    key,
108                    expected,
109                    actual,
110                } => {
111                    format!("{key} (is '{actual}', expected '{expected}')")
112                }
113                ConfigIssue::Absent(key) => format!("{key} (not set)"),
114            })
115            .collect();
116        return format!("Missing or wrong: {}", parts.join(", "));
117    }
118    cr.message.clone().unwrap_or_default()
119}
120
121fn suggestion_for_check(name: &str) -> Option<String> {
122    match name {
123        "Git installed" => {
124            Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string())
125        }
126        "ssh-keygen installed" => Some("Install OpenSSH for your platform.".to_string()),
127        "Git signing config" => Some("Run: auths init --profile developer".to_string()),
128        _ => None,
129    }
130}
131
132fn check_keychain_accessible() -> Check {
133    let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
134        Ok(keychain) => (
135            true,
136            format!("{} (accessible)", keychain.backend_name()),
137            None,
138        ),
139        Err(e) => (
140            false,
141            format!("inaccessible: {e}"),
142            Some("Run: auths init --profile developer".to_string()),
143        ),
144    };
145    Check {
146        name: "System keychain".to_string(),
147        passed,
148        detail,
149        suggestion,
150    }
151}
152
153fn check_identity_exists() -> Check {
154    let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
155        Ok(keychain) => match keychain.list_aliases() {
156            Ok(aliases) if aliases.is_empty() => (
157                false,
158                "No keys found in keychain".to_string(),
159                Some("Run: auths init --profile developer  (or: auths id init)".to_string()),
160            ),
161            Ok(aliases) => (true, format!("{} key(s) found", aliases.len()), None),
162            Err(e) => (
163                false,
164                format!("Failed to list keys: {e}"),
165                Some("Run: auths doctor  (check keychain is accessible first)".to_string()),
166            ),
167        },
168        Err(_) => (
169            false,
170            "Keychain not accessible".to_string(),
171            Some("Run: auths init --profile developer".to_string()),
172        ),
173    };
174    Check {
175        name: "Auths identity".to_string(),
176        passed,
177        detail,
178        suggestion,
179    }
180}
181
182fn check_allowed_signers_file() -> Check {
183    let path = crate::factories::storage::read_git_config("gpg.ssh.allowedSignersFile")
184        .ok()
185        .flatten();
186
187    let (passed, detail, suggestion) = match path {
188        Some(path_str) => {
189            if std::path::Path::new(&path_str).exists() {
190                (true, format!("Set to: {path_str}"), None)
191            } else {
192                (
193                    false,
194                    format!("Configured but file not found: {path_str}"),
195                    Some(
196                        "Run: auths init --profile developer  (regenerates allowed_signers)"
197                            .to_string(),
198                    ),
199                )
200            }
201        }
202        None => (
203            false,
204            "Not configured".into(),
205            Some("Run: auths init --profile developer".to_string()),
206        ),
207    };
208    Check {
209        name: "Allowed signers file".to_string(),
210        passed,
211        detail,
212        suggestion,
213    }
214}
215
216/// Print the report in human-readable format.
217fn print_report(report: &DoctorReport) {
218    let out = Output::new();
219
220    out.print_heading(&format!("Auths Doctor (v{})", report.version));
221    out.println("--------------------------");
222    out.newline();
223
224    for check in &report.checks {
225        let (icon, name_styled) = if check.passed {
226            (out.success("✓"), out.bold(&check.name))
227        } else {
228            (out.error("✗"), out.error(&check.name))
229        };
230
231        out.println(&format!("[{icon}] {name_styled}: {}", check.detail));
232
233        if let Some(ref suggestion) = check.suggestion {
234            out.println(&format!("      -> {}", out.dim(suggestion)));
235        }
236    }
237
238    out.newline();
239
240    let passed_count = report.checks.iter().filter(|c| c.passed).count();
241    let failed_count = report.checks.len() - passed_count;
242
243    let summary = format!(
244        "Summary: {} passed, {} failed",
245        out.success(&passed_count.to_string()),
246        out.error(&failed_count.to_string())
247    );
248    out.println(&summary);
249    out.newline();
250
251    if report.all_pass {
252        out.print_success("All checks passed! Your system is ready.");
253    } else {
254        out.print_error("Some checks failed. Please review the suggestions above.");
255    }
256}
257
258impl crate::commands::executable::ExecutableCommand for DoctorCommand {
259    fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
260        handle_doctor(self.clone())
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    #[test]
267    fn test_keychain_check_suggestion_is_exact_command() {
268        let suggestion = "Run: auths init --profile developer";
269        assert!(
270            suggestion.starts_with("Run:"),
271            "suggestion must start with 'Run:'"
272        );
273    }
274
275    #[test]
276    fn test_git_signing_config_checks_all_five_configs() {
277        use super::*;
278        let adapter = PosixDiagnosticAdapter;
279        let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
280        let report = workflow.run().unwrap();
281        let signing_check = report
282            .checks
283            .iter()
284            .find(|c| c.name == "Git signing config");
285        assert!(signing_check.is_some(), "signing config check must exist");
286    }
287
288    #[test]
289    fn test_all_failed_checks_have_exact_runnable_suggestions() {
290        let suggestions: Vec<Option<String>> = vec![
291            Some("Run: auths init --profile developer".to_string()),
292            Some("Run: auths id init".to_string()),
293            Some("Run: git config --global gpg.format ssh".to_string()),
294            Some("Run: auths init --profile developer".to_string()),
295        ];
296        for text in suggestions.into_iter().flatten() {
297            assert!(text.starts_with("Run:"), "bad suggestion: {}", text);
298        }
299    }
300}