1use 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#[derive(Parser, Debug, Clone)]
14#[command(name = "doctor", about = "Run comprehensive health checks")]
15pub struct DoctorCommand {}
16
17#[derive(Debug, Serialize)]
19pub struct Check {
20 name: String,
21 passed: bool,
22 detail: String,
23 suggestion: Option<String>,
24}
25
26#[derive(Debug, Serialize)]
28pub struct DoctorReport {
29 pub version: String,
30 pub checks: Vec<Check>,
31 pub all_pass: bool,
32}
33
34pub 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
68fn run_checks() -> Vec<Check> {
70 let adapter = PosixDiagnosticAdapter;
71 let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
72
73 let mut checks = Vec::new();
74
75 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 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
216fn 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}