Skip to main content

auths_cli/commands/
doctor.rs

1//! Comprehensive health check command for Auths.
2
3use crate::adapters::doctor_fixes::GitSigningConfigFix;
4use crate::adapters::system_diagnostic::PosixDiagnosticAdapter;
5use crate::ux::format::{JsonResponse, Output, is_json_mode};
6use anyhow::Result;
7use auths_sdk::keychain;
8use auths_sdk::paths::resolve_registry_path;
9use auths_sdk::ports::diagnostics::{
10    CheckCategory, CheckResult, ConfigIssue, DiagnosticFix, FixApplied,
11};
12use auths_sdk::workflows::diagnostics::DiagnosticsWorkflow;
13use chrono::{DateTime, Utc};
14use clap::Parser;
15use serde::Serialize;
16use std::io::IsTerminal;
17use std::path::PathBuf;
18
19/// Health check command.
20#[derive(Parser, Debug, Clone)]
21#[command(
22    name = "doctor",
23    about = "Run comprehensive health checks",
24    after_help = "Examples:
25  auths doctor              # Check all health aspects
26  auths doctor --fix        # Auto-fix identified issues
27  auths doctor --json       # JSON output
28
29Exit Codes:
30  0 — All checks pass
31  1 — Critical check failed (Auths is non-functional)
32  2 — Critical checks pass, advisory checks fail (environment could be better)
33
34Related:
35  auths status  — Show identity and device status
36  auths init    — Initialize a new identity"
37)]
38pub struct DoctorCommand {
39    /// Auto-fix issues where possible
40    #[clap(long)]
41    pub fix: bool,
42}
43
44/// A single health check.
45#[derive(Debug, Serialize)]
46pub struct Check {
47    name: String,
48    passed: bool,
49    detail: String,
50    suggestion: Option<String>,
51    #[serde(skip_serializing_if = "is_advisory")]
52    category: CheckCategory,
53}
54
55fn is_advisory(cat: &CheckCategory) -> bool {
56    *cat == CheckCategory::Advisory
57}
58
59/// Overall doctor report.
60#[derive(Debug, Serialize)]
61pub struct DoctorReport {
62    pub version: String,
63    pub checks: Vec<Check>,
64    pub all_pass: bool,
65    #[serde(skip_serializing_if = "Vec::is_empty")]
66    pub fixes_applied: Vec<FixApplied>,
67}
68
69/// Handle the doctor command.
70///
71/// Args:
72/// * `cmd`: the parsed [`DoctorCommand`].
73/// * `repo_override`: the resolved storage-root override (`--repo`/`AUTHS_REPO`/
74///   `AUTHS_HOME`), so every check reports the same root `init`/`sign` wrote to.
75///
76/// Usage:
77/// ```ignore
78/// handle_doctor(cmd, ctx.repo_path.clone())?;
79/// ```
80pub fn handle_doctor(cmd: DoctorCommand, repo_override: Option<PathBuf>) -> Result<()> {
81    let checks = run_checks(repo_override.clone());
82    let all_pass = checks.iter().all(|c| c.passed);
83
84    let (final_checks, fixes_applied) = if cmd.fix && !all_pass {
85        let out = if !is_json_mode() {
86            Some(Output::new())
87        } else {
88            None
89        };
90        let fixes = apply_fixes(&checks, out.as_ref());
91        if !fixes.is_empty() {
92            let rechecked = run_checks(repo_override.clone());
93            (rechecked, fixes)
94        } else {
95            (checks, fixes)
96        }
97    } else {
98        (checks, Vec::new())
99    };
100
101    let all_pass = final_checks.iter().all(|c| c.passed);
102
103    // Compute exit code based on check categories
104    let exit_code = compute_exit_code(&final_checks);
105
106    let report = DoctorReport {
107        version: env!("CARGO_PKG_VERSION").to_string(),
108        checks: final_checks,
109        all_pass,
110        fixes_applied,
111    };
112
113    if is_json_mode() {
114        JsonResponse {
115            success: all_pass,
116            command: "doctor".to_string(),
117            data: Some(report),
118            error: if !all_pass {
119                Some("some health checks failed".to_string())
120            } else {
121                None
122            },
123        }
124        .print()?;
125    } else {
126        print_report(&report);
127    }
128
129    if exit_code != 0 {
130        std::process::exit(exit_code);
131    }
132
133    Ok(())
134}
135
136/// Compute exit code based on check categories.
137///
138/// Returns:
139/// * 0 — all checks pass
140/// * 1 — at least one Critical check fails (Auths is non-functional)
141/// * 2 — all Critical checks pass, at least one Advisory check fails
142fn compute_exit_code(checks: &[Check]) -> i32 {
143    let critical_failures = checks
144        .iter()
145        .any(|c| !c.passed && c.category == CheckCategory::Critical);
146
147    if critical_failures {
148        return 1;
149    }
150
151    let advisory_failures = checks
152        .iter()
153        .any(|c| !c.passed && c.category == CheckCategory::Advisory);
154
155    if advisory_failures {
156        return 2;
157    }
158
159    0
160}
161
162/// Run all prerequisite checks.
163#[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() injected here
164fn run_checks(repo_override: Option<PathBuf>) -> Vec<Check> {
165    let now = Utc::now();
166    let adapter = PosixDiagnosticAdapter;
167    let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
168
169    let mut checks = Vec::new();
170
171    if let Ok(report) = workflow.run() {
172        for cr in report.checks {
173            let suggestion = if cr.passed {
174                None
175            } else {
176                suggestion_for_check(&cr.name)
177            };
178            checks.push(Check {
179                name: cr.name.clone(),
180                passed: cr.passed,
181                detail: format_check_detail(&cr),
182                suggestion,
183                category: cr.category,
184            });
185        }
186    }
187
188    // Domain checks are all Critical
189    checks.push(check_keychain_accessible());
190    checks.push(check_identity_home(repo_override.clone()));
191    checks.push(check_auths_repo(repo_override.clone()));
192    checks.push(check_identity_valid(now, repo_override.clone()));
193
194    // Advisory: commit-trailer hook wiring (plain `git commit` verifiability)
195    checks.push(check_commit_hook_installed(repo_override));
196    checks.push(check_repo_hooks_path_override());
197
198    // Advisory: network connectivity
199    checks.push(check_registry_connectivity());
200
201    checks
202}
203
204/// Advisory: the prepare-commit-msg hook is installed, current, and wired via
205/// the global `core.hooksPath` — what makes a plain `git commit` verifiable.
206fn check_commit_hook_installed(repo_override: Option<PathBuf>) -> Check {
207    let (passed, detail) = match resolve_registry_path(repo_override) {
208        Ok(home) if !home.join("commit-trailers").exists() => {
209            // No identity / pre-hook install — not a failure, just not set up yet.
210            (
211                true,
212                "no identity initialized (hook not applicable)".to_string(),
213            )
214        }
215        Ok(home) => {
216            let hook_current = auths_sdk::workflows::commit_hooks::hook_is_current(&home);
217            // Resolve the *effective* core.hooksPath rather than the global one:
218            // `auths init` scopes signing config to the repo by default (a scripted
219            // init must not rewrite ~/.gitconfig), so a working local setup would
220            // otherwise be reported as broken. This is also the value git itself
221            // will use, which is the thing worth checking.
222            let hooks_path_set = crate::subprocess::git_command(&["config", "core.hooksPath"])
223                .output()
224                .ok()
225                .filter(|o| o.status.success())
226                .map(|o| {
227                    String::from_utf8_lossy(&o.stdout).trim()
228                        == home.join("githooks").to_string_lossy()
229                })
230                .unwrap_or(false);
231            match (hook_current, hooks_path_set) {
232                (true, true) => (true, "prepare-commit-msg hook installed".to_string()),
233                (false, _) => (false, "hook file missing or stale".to_string()),
234                (true, false) => (false, "core.hooksPath not pointing at the hook".to_string()),
235            }
236        }
237        Err(e) => (false, format!("could not locate ~/.auths: {e}")),
238    };
239    Check {
240        name: "Commit trailer hook".to_string(),
241        passed,
242        detail,
243        suggestion: if passed {
244            None
245        } else {
246            Some("Re-run `auths init` to reinstall the commit hook.".to_string())
247        },
248        category: CheckCategory::Advisory,
249    }
250}
251
252/// Advisory: a repo-local `core.hooksPath` (husky-style hook managers) bypasses
253/// the global auths hook — commits in this repo won't carry trailers unless the
254/// trailer hook is added to that directory.
255fn check_repo_hooks_path_override() -> Check {
256    let local_override = crate::subprocess::git_command(&["config", "--local", "core.hooksPath"])
257        .output()
258        .ok()
259        .filter(|o| o.status.success())
260        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
261        .filter(|path| !path.is_empty());
262    let (passed, detail, suggestion) = match local_override {
263        None => (
264            true,
265            "no repo-local core.hooksPath override".to_string(),
266            None,
267        ),
268        Some(path) => (
269            false,
270            format!(
271                "this repo overrides core.hooksPath to '{path}' — the auths trailer hook is bypassed"
272            ),
273            Some(format!(
274                "Copy the prepare-commit-msg hook from ~/.auths/githooks/ into '{path}' (or chain it from your hook manager)."
275            )),
276        ),
277    };
278    Check {
279        name: "Repo hook override".to_string(),
280        passed,
281        detail,
282        suggestion,
283        category: CheckCategory::Advisory,
284    }
285}
286
287fn apply_fixes(checks: &[Check], out: Option<&Output>) -> Vec<FixApplied> {
288    let failed: Vec<CheckResult> = checks
289        .iter()
290        .filter(|c| !c.passed)
291        .map(|c| CheckResult {
292            name: c.name.clone(),
293            passed: c.passed,
294            message: Some(c.detail.clone()),
295            config_issues: Vec::new(),
296            category: c.category,
297        })
298        .collect();
299
300    let fixes = build_available_fixes();
301    let interactive = std::io::stdin().is_terminal();
302    let mut applied = Vec::new();
303
304    for fix in &fixes {
305        let applicable: Vec<&CheckResult> = failed.iter().filter(|c| fix.can_fix(c)).collect();
306        if applicable.is_empty() {
307            continue;
308        }
309
310        if !fix.is_safe() && !interactive {
311            if let Some(o) = out {
312                o.print_warn(&format!(
313                    "Skipping unsafe fix '{}' (non-interactive mode)",
314                    fix.name()
315                ));
316            }
317            continue;
318        }
319
320        if !fix.is_safe() && interactive {
321            let confirm = dialoguer::Confirm::new()
322                .with_prompt(format!(
323                    "Apply fix '{}'? (may overwrite existing git config)",
324                    fix.name()
325                ))
326                .default(true)
327                .interact()
328                .unwrap_or(false);
329            if !confirm {
330                continue;
331            }
332        }
333
334        match fix.apply() {
335            Ok(message) => {
336                if let Some(o) = out {
337                    o.print_success(&format!("Fixed: {}", message));
338                }
339                applied.push(FixApplied {
340                    name: fix.name().to_string(),
341                    message,
342                });
343            }
344            Err(e) => {
345                if let Some(o) = out {
346                    o.print_error(&format!("Fix '{}' failed: {}", fix.name(), e));
347                }
348            }
349        }
350    }
351
352    applied
353}
354
355fn build_available_fixes() -> Vec<Box<dyn DiagnosticFix>> {
356    let mut fixes: Vec<Box<dyn DiagnosticFix>> = Vec::new();
357
358    if let Ok(sign_path) = which::which("auths-sign") {
359        let key_alias = resolve_key_alias().unwrap_or_else(|| "main".to_string());
360        fixes.push(Box::new(GitSigningConfigFix::new(sign_path, key_alias)));
361    }
362
363    fixes
364}
365
366fn resolve_key_alias() -> Option<String> {
367    let keychain = keychain::get_platform_keychain().ok()?;
368    let aliases = keychain.list_aliases().ok()?;
369    aliases
370        .into_iter()
371        .find(|a| !a.to_string().contains("--next-"))
372        .map(|a| a.to_string())
373}
374
375fn format_check_detail(cr: &CheckResult) -> String {
376    if !cr.config_issues.is_empty() {
377        let parts: Vec<String> = cr
378            .config_issues
379            .iter()
380            .map(|issue| match issue {
381                ConfigIssue::Mismatch {
382                    key,
383                    expected,
384                    actual,
385                } => {
386                    format!("{key} (is '{actual}', expected '{expected}')")
387                }
388                ConfigIssue::Absent(key) => format!("{key} (not set)"),
389            })
390            .collect();
391        return format!("Missing or wrong: {}", parts.join(", "));
392    }
393    cr.message.clone().unwrap_or_default()
394}
395
396fn suggestion_for_check(name: &str) -> Option<String> {
397    match name {
398        "Git installed" => {
399            Some("Install Git for your platform (see: https://git-scm.com/downloads)".to_string())
400        }
401        "Git version" => Some(
402            "Upgrade Git to 2.34.0+ for SSH signing: https://git-scm.com/downloads".to_string(),
403        ),
404        "Git user identity" => Some(
405            "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(),
406        ),
407        "ssh-keygen installed" => {
408            let hint = if cfg!(target_os = "macos") {
409                "ssh-keygen is normally pre-installed on macOS. Check your PATH."
410            } else if cfg!(target_os = "windows") {
411                "Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
412            } else {
413                "Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
414            };
415            Some(hint.to_string())
416        }
417        "SSH version" => Some(
418            "Upgrade OpenSSH to 8.2+ for -Y find-principals support. Check with: ssh -V".to_string(),
419        ),
420        "Git signing config" => Some("Run: auths doctor --fix".to_string()),
421        "Auths directory" => Some("Run: auths init --profile developer".to_string()),
422        "Allowed signers file" => Some("Run: auths doctor --fix".to_string()),
423        "Registry connectivity" => {
424            Some("Check your internet connection or try again later.".to_string())
425        }
426        _ => None,
427    }
428}
429
430fn check_keychain_accessible() -> Check {
431    let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
432        Ok(keychain) => (
433            true,
434            format!("{} (accessible)", keychain.backend_name()),
435            None,
436        ),
437        Err(e) => (
438            false,
439            format!("inaccessible: {e}"),
440            Some("Run: auths init --profile developer".to_string()),
441        ),
442    };
443    Check {
444        name: "System keychain".to_string(),
445        passed,
446        detail,
447        suggestion,
448        category: CheckCategory::Critical,
449    }
450}
451
452/// Advisory: report which identity storage root is in effect and why.
453///
454/// Resolution is the shared override (`--repo`/`AUTHS_REPO`/`AUTHS_HOME`) then the
455/// `~/.auths` default. Surfacing the winner and its source turns "why can't it
456/// find my identity" into a one-glance answer — and reports the *same* root
457/// `init`/`sign` wrote to, not a default the other commands never used.
458fn check_identity_home(repo_override: Option<PathBuf>) -> Check {
459    let overridden = repo_override.is_some();
460    let (passed, detail, suggestion) = match resolve_registry_path(repo_override) {
461        Ok(path) => {
462            let source = if overridden {
463                "from --repo/AUTHS_REPO"
464            } else {
465                "default ~/.auths"
466            };
467            (true, format!("{} ({source})", path.display()), None)
468        }
469        Err(e) => (
470            false,
471            format!("cannot resolve: {e}"),
472            Some("Set AUTHS_REPO, or ensure a home directory is available".to_string()),
473        ),
474    };
475    Check {
476        name: "Identity home".to_string(),
477        passed,
478        detail,
479        suggestion,
480        category: CheckCategory::Advisory,
481    }
482}
483
484fn check_auths_repo(repo_override: Option<PathBuf>) -> Check {
485    let (passed, detail, suggestion) = match resolve_registry_path(repo_override) {
486        Ok(path) => {
487            if !path.exists() {
488                (
489                    false,
490                    format!("{} (not found)", path.display()),
491                    Some("Run: auths init --profile developer".to_string()),
492                )
493            } else {
494                match crate::factories::storage::open_git_repo(&path) {
495                    Ok(_) => (
496                        true,
497                        format!("{} (valid git repository)", path.display()),
498                        None,
499                    ),
500                    Err(_) => (
501                        false,
502                        format!("{} (exists but not a valid git repo)", path.display()),
503                        Some("Run: auths init --profile developer".to_string()),
504                    ),
505                }
506            }
507        }
508        Err(e) => (
509            false,
510            format!("Cannot resolve path: {e}"),
511            Some("Run: auths init --profile developer".to_string()),
512        ),
513    };
514    Check {
515        name: "Auths directory".to_string(),
516        passed,
517        detail,
518        suggestion,
519        category: CheckCategory::Critical,
520    }
521}
522
523fn check_identity_valid(now: DateTime<Utc>, repo_override: Option<PathBuf>) -> Check {
524    let (passed, detail, suggestion) = match keychain::get_platform_keychain() {
525        Ok(keychain) => match keychain.list_aliases() {
526            Ok(aliases) if aliases.is_empty() => (
527                false,
528                "No keys found in keychain".to_string(),
529                Some("Run: auths init --profile developer  (or: auths id create)".to_string()),
530            ),
531            Ok(aliases) => {
532                let key_count = aliases.len();
533                let expiry_info = check_attestation_expiry(now, repo_override);
534                match expiry_info {
535                    ExpiryStatus::AllExpired(msg) => (
536                        false,
537                        format!("{key_count} key(s) found, but {msg}"),
538                        Some("Run: auths device extend".to_string()),
539                    ),
540                    ExpiryStatus::ExpiringSoon(msg) => {
541                        (true, format!("{key_count} key(s) found ({msg})"), None)
542                    }
543                    ExpiryStatus::Ok | ExpiryStatus::NoAttestations => {
544                        (true, format!("{key_count} key(s) found"), None)
545                    }
546                }
547            }
548            Err(e) => (
549                false,
550                format!("Failed to list keys: {e}"),
551                Some("Run: auths doctor  (check keychain is accessible first)".to_string()),
552            ),
553        },
554        Err(_) => (
555            false,
556            "Keychain not accessible".to_string(),
557            Some("Run: auths init --profile developer".to_string()),
558        ),
559    };
560    Check {
561        name: "Auths identity".to_string(),
562        passed,
563        detail,
564        suggestion,
565        category: CheckCategory::Critical,
566    }
567}
568
569enum ExpiryStatus {
570    Ok,
571    NoAttestations,
572    ExpiringSoon(String),
573    AllExpired(String),
574}
575
576fn check_attestation_expiry(now: DateTime<Utc>, repo_override: Option<PathBuf>) -> ExpiryStatus {
577    use auths_sdk::storage::RegistryAttestationStorage;
578
579    let repo_path = match resolve_registry_path(repo_override) {
580        Ok(p) if p.exists() => p,
581        _ => return ExpiryStatus::NoAttestations,
582    };
583
584    let storage = RegistryAttestationStorage::new(&repo_path);
585    let attestations = match storage
586        .load_all_enriched()
587        .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())
588    {
589        Ok(a) => a,
590        Err(_) => return ExpiryStatus::NoAttestations,
591    };
592
593    if attestations.is_empty() {
594        return ExpiryStatus::NoAttestations;
595    }
596
597    let active: Vec<_> = attestations
598        .iter()
599        .filter(|a| a.revoked_at.is_none())
600        .collect();
601
602    if active.is_empty() {
603        return ExpiryStatus::AllExpired("all attestations revoked".to_string());
604    }
605
606    let with_expiry: Vec<_> = active.iter().filter(|a| a.expires_at.is_some()).collect();
607
608    if with_expiry.is_empty() {
609        return ExpiryStatus::Ok;
610    }
611
612    let all_expired = with_expiry
613        .iter()
614        .all(|a| a.expires_at.is_some_and(|exp| exp < now));
615
616    if all_expired {
617        return ExpiryStatus::AllExpired("all attestations expired".to_string());
618    }
619
620    let warn_threshold = now + chrono::Duration::days(7);
621    let expiring_soon = with_expiry
622        .iter()
623        .any(|a| a.expires_at.is_some_and(|exp| exp < warn_threshold));
624
625    if expiring_soon {
626        return ExpiryStatus::ExpiringSoon("some attestations expiring within 7 days".to_string());
627    }
628
629    ExpiryStatus::Ok
630}
631
632/// Report on the configured registry, if there is one.
633///
634/// There is no default registry, so with none configured this passes with
635/// "not configured" rather than failing: a registry is optional, and auths is
636/// fully functional — identity, signing, verification — without one. Previously
637/// this probed a baked-in default that returned 404, so every `auths doctor` run
638/// reported a failing check for a service the user neither had nor needed.
639fn check_registry_connectivity() -> Check {
640    let Ok(base) = std::env::var("AUTHS_REGISTRY_URL") else {
641        return Check {
642            name: "Registry connectivity".to_string(),
643            passed: true,
644            detail: "not configured (optional — signing and verification need no registry)"
645                .to_string(),
646            suggestion: None,
647            category: CheckCategory::Advisory,
648        };
649    };
650
651    let url = format!("{base}/health");
652    let client = reqwest::blocking::Client::builder()
653        .timeout(std::time::Duration::from_secs(5))
654        .build();
655
656    let (passed, detail) = match client {
657        Ok(client) => match client.get(&url).send() {
658            Ok(resp) if resp.status().is_success() => (true, format!("{base} (reachable)")),
659            Ok(resp) => (false, format!("{base} (HTTP {})", resp.status())),
660            Err(e) => (false, format!("unreachable: {e}")),
661        },
662        Err(e) => (false, format!("HTTP client error: {e}")),
663    };
664
665    Check {
666        name: "Registry connectivity".to_string(),
667        passed,
668        detail,
669        suggestion: if passed {
670            None
671        } else {
672            suggestion_for_check("Registry connectivity")
673        },
674        category: CheckCategory::Advisory,
675    }
676}
677
678/// Print the report in human-readable format.
679fn print_report(report: &DoctorReport) {
680    let out = Output::new();
681
682    out.print_heading(&format!("Auths Doctor (v{})", report.version));
683    out.println("--------------------------");
684    out.newline();
685
686    for check in &report.checks {
687        let (icon, name_styled) = if check.passed {
688            (out.success("✓"), out.bold(&check.name))
689        } else {
690            (out.error("✗"), out.error(&check.name))
691        };
692
693        out.println(&format!("[{icon}] {name_styled}: {}", check.detail));
694
695        if let Some(ref suggestion) = check.suggestion {
696            out.println(&format!("      -> {}", out.dim(suggestion)));
697        }
698    }
699
700    if !report.fixes_applied.is_empty() {
701        out.newline();
702        out.print_heading("Fixes applied:");
703        for fix in &report.fixes_applied {
704            out.println(&format!("  {} — {}", out.success(&fix.name), fix.message));
705        }
706    }
707
708    out.newline();
709
710    let passed_count = report.checks.iter().filter(|c| c.passed).count();
711    let failed_count = report.checks.len() - passed_count;
712
713    let summary = format!(
714        "Summary: {} passed, {} failed",
715        out.success(&passed_count.to_string()),
716        out.error(&failed_count.to_string())
717    );
718    out.println(&summary);
719    out.newline();
720
721    if report.all_pass {
722        out.print_success("All checks passed! Your system is ready.");
723    } else {
724        out.print_error("Some checks failed. Please review the suggestions above.");
725    }
726}
727
728impl crate::commands::executable::ExecutableCommand for DoctorCommand {
729    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
730        handle_doctor(self.clone(), ctx.repo_path.clone())
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    #[test]
737    fn test_keychain_check_suggestion_is_exact_command() {
738        let suggestion = "Run: auths init --profile developer";
739        assert!(
740            suggestion.starts_with("Run:"),
741            "suggestion must start with 'Run:'"
742        );
743    }
744
745    #[test]
746    fn test_workflow_includes_version_and_user_checks() {
747        use super::*;
748        let adapter = PosixDiagnosticAdapter;
749        let workflow = DiagnosticsWorkflow::new(&adapter, &adapter);
750        let report = workflow.run().unwrap();
751
752        let check_names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
753        assert!(
754            check_names.contains(&"Git signing config"),
755            "signing config check must exist"
756        );
757        assert!(
758            check_names.contains(&"Git version"),
759            "git version check must exist"
760        );
761        assert!(
762            check_names.contains(&"Git user identity"),
763            "git user identity check must exist"
764        );
765    }
766
767    #[test]
768    fn test_all_failed_checks_have_exact_runnable_suggestions() {
769        let suggestions: Vec<Option<String>> = vec![
770            Some("Run: auths init --profile developer".to_string()),
771            Some("Run: auths id init".to_string()),
772            Some("Run: git config --global gpg.format ssh".to_string()),
773            Some("Run: auths init --profile developer".to_string()),
774        ];
775        for text in suggestions.into_iter().flatten() {
776            assert!(text.starts_with("Run:"), "bad suggestion: {}", text);
777        }
778    }
779
780    #[test]
781    fn test_suggestion_for_all_new_checks() {
782        use super::suggestion_for_check;
783        assert!(suggestion_for_check("Git version").is_some());
784        assert!(suggestion_for_check("Git user identity").is_some());
785        assert!(suggestion_for_check("Auths directory").is_some());
786        assert!(suggestion_for_check("Registry connectivity").is_some());
787    }
788}