Skip to main content

pi/
doctor.rs

1//! Comprehensive environment health checker for `pi doctor`.
2//!
3//! When invoked without a path, checks config, directories, auth, shell tools,
4//! and sessions. When invoked with a path, runs extension preflight analysis.
5//! With `--fix`, automatically repairs safe issues (missing dirs, permissions).
6
7use crate::auth::{AuthStorage, CredentialStatus};
8use crate::config::Config;
9use crate::error::Result;
10use crate::provider_metadata::provider_auth_env_keys;
11use crate::session_index::walk_sessions;
12use serde::Serialize;
13use std::collections::HashSet;
14use std::fmt;
15use std::fmt::Write as _;
16use std::io::{BufRead as _, BufReader, Write as _};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20// ── Core Types ──────────────────────────────────────────────────────
21
22/// How severe a finding is.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Severity {
26    Pass,
27    Info,
28    Warn,
29    Fail,
30}
31
32impl fmt::Display for Severity {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Pass => write!(f, "PASS"),
36            Self::Info => write!(f, "INFO"),
37            Self::Warn => write!(f, "WARN"),
38            Self::Fail => write!(f, "FAIL"),
39        }
40    }
41}
42
43/// Whether a finding can be auto-fixed.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Fixability {
47    /// Cannot be auto-fixed.
48    NotFixable,
49    /// Can be auto-fixed with `--fix`.
50    AutoFixable,
51    /// Was auto-fixed in this run.
52    Fixed,
53}
54
55/// Which subsystem a check belongs to.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum CheckCategory {
59    Config,
60    Dirs,
61    Auth,
62    Shell,
63    Sessions,
64    Extensions,
65}
66
67impl CheckCategory {
68    const fn label(self) -> &'static str {
69        match self {
70            Self::Config => "Configuration",
71            Self::Dirs => "Directories",
72            Self::Auth => "Authentication",
73            Self::Shell => "Shell & Tools",
74            Self::Sessions => "Sessions",
75            Self::Extensions => "Extensions",
76        }
77    }
78}
79
80impl fmt::Display for CheckCategory {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.label())
83    }
84}
85
86impl std::str::FromStr for CheckCategory {
87    type Err = String;
88    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
89        match s.to_ascii_lowercase().as_str() {
90            "config" => Ok(Self::Config),
91            "dirs" | "directories" => Ok(Self::Dirs),
92            "auth" | "authentication" => Ok(Self::Auth),
93            "shell" => Ok(Self::Shell),
94            "sessions" => Ok(Self::Sessions),
95            "extensions" | "ext" => Ok(Self::Extensions),
96            other => Err(format!("unknown category: {other}")),
97        }
98    }
99}
100
101/// A single diagnostic finding.
102#[derive(Debug, Clone, Serialize)]
103pub struct Finding {
104    pub category: CheckCategory,
105    pub severity: Severity,
106    pub title: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub detail: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub remediation: Option<String>,
111    pub fixability: Fixability,
112}
113
114impl Finding {
115    fn pass(category: CheckCategory, title: impl Into<String>) -> Self {
116        Self {
117            category,
118            severity: Severity::Pass,
119            title: title.into(),
120            detail: None,
121            remediation: None,
122            fixability: Fixability::NotFixable,
123        }
124    }
125
126    fn info(category: CheckCategory, title: impl Into<String>) -> Self {
127        Self {
128            category,
129            severity: Severity::Info,
130            title: title.into(),
131            detail: None,
132            remediation: None,
133            fixability: Fixability::NotFixable,
134        }
135    }
136
137    fn warn(category: CheckCategory, title: impl Into<String>) -> Self {
138        Self {
139            category,
140            severity: Severity::Warn,
141            title: title.into(),
142            detail: None,
143            remediation: None,
144            fixability: Fixability::NotFixable,
145        }
146    }
147
148    fn fail(category: CheckCategory, title: impl Into<String>) -> Self {
149        Self {
150            category,
151            severity: Severity::Fail,
152            title: title.into(),
153            detail: None,
154            remediation: None,
155            fixability: Fixability::NotFixable,
156        }
157    }
158
159    fn with_detail(mut self, detail: impl Into<String>) -> Self {
160        self.detail = Some(detail.into());
161        self
162    }
163
164    fn with_remediation(mut self, remediation: impl Into<String>) -> Self {
165        self.remediation = Some(remediation.into());
166        self
167    }
168
169    const fn auto_fixable(mut self) -> Self {
170        self.fixability = Fixability::AutoFixable;
171        self
172    }
173
174    const fn fixed(mut self) -> Self {
175        self.fixability = Fixability::Fixed;
176        self.severity = Severity::Pass;
177        self
178    }
179}
180
181/// Summary counters.
182#[derive(Debug, Clone, Default, Serialize)]
183pub struct DoctorSummary {
184    pub pass: usize,
185    pub info: usize,
186    pub warn: usize,
187    pub fail: usize,
188}
189
190/// Full diagnostic report.
191#[derive(Debug, Clone, Serialize)]
192pub struct DoctorReport {
193    pub findings: Vec<Finding>,
194    pub summary: DoctorSummary,
195    pub overall: Severity,
196}
197
198impl DoctorReport {
199    fn from_findings(findings: Vec<Finding>) -> Self {
200        let mut summary = DoctorSummary::default();
201        let mut overall = Severity::Pass;
202        for f in &findings {
203            match f.severity {
204                Severity::Pass => summary.pass += 1,
205                Severity::Info => summary.info += 1,
206                Severity::Warn => {
207                    summary.warn += 1;
208                    if overall < Severity::Warn {
209                        overall = Severity::Warn;
210                    }
211                }
212                Severity::Fail => {
213                    summary.fail += 1;
214                    overall = Severity::Fail;
215                }
216            }
217        }
218        Self {
219            findings,
220            summary,
221            overall,
222        }
223    }
224
225    /// Render human-friendly text output.
226    pub fn render_text(&self) -> String {
227        let mut out = String::with_capacity(2048);
228        out.push_str("Pi Doctor\n=========\n");
229
230        // Group findings by category, preserving insertion order
231        let mut seen_categories: Vec<CheckCategory> = Vec::new();
232        for f in &self.findings {
233            if !seen_categories.contains(&f.category) {
234                seen_categories.push(f.category);
235            }
236        }
237
238        for cat in &seen_categories {
239            let cat_findings: Vec<&Finding> = self
240                .findings
241                .iter()
242                .filter(|f| f.category == *cat)
243                .collect();
244            let cat_worst = cat_findings
245                .iter()
246                .map(|f| f.severity)
247                .max()
248                .unwrap_or(Severity::Pass);
249            let _ = writeln!(out, "\n[{cat_worst}] {cat}");
250            for f in &cat_findings {
251                let _ = writeln!(out, "  [{}] {}", f.severity, f.title);
252                if let Some(detail) = &f.detail {
253                    let _ = writeln!(out, "       {detail}");
254                }
255                if let Some(rem) = &f.remediation {
256                    let _ = writeln!(out, "       Fix: {rem}");
257                }
258                if f.fixability == Fixability::AutoFixable {
259                    out.push_str("       (fixable with --fix)\n");
260                }
261            }
262        }
263
264        let _ = writeln!(
265            out,
266            "\nOverall: {} ({} pass, {} info, {} warn, {} fail)",
267            self.overall,
268            self.summary.pass,
269            self.summary.info,
270            self.summary.warn,
271            self.summary.fail
272        );
273        out
274    }
275
276    /// Render as JSON.
277    pub fn to_json(&self) -> Result<String> {
278        Ok(serde_json::to_string_pretty(self)?)
279    }
280
281    /// Render as markdown.
282    pub fn render_markdown(&self) -> String {
283        let mut out = String::with_capacity(2048);
284        out.push_str("# Pi Doctor Report\n\n");
285
286        let mut seen_categories: Vec<CheckCategory> = Vec::new();
287        for f in &self.findings {
288            if !seen_categories.contains(&f.category) {
289                seen_categories.push(f.category);
290            }
291        }
292
293        for cat in &seen_categories {
294            let _ = writeln!(out, "## {cat}\n");
295            for f in self.findings.iter().filter(|f| f.category == *cat) {
296                let icon = match f.severity {
297                    Severity::Pass => "✅",
298                    Severity::Info => "ℹ️",
299                    Severity::Warn => "⚠️",
300                    Severity::Fail => "❌",
301                };
302                let _ = write!(out, "- {icon} **{}**", f.title);
303                if let Some(detail) = &f.detail {
304                    let _ = write!(out, " — {detail}");
305                }
306                out.push('\n');
307                if let Some(rem) = &f.remediation {
308                    let _ = writeln!(out, "  - Fix: {rem}");
309                }
310            }
311            out.push('\n');
312        }
313
314        let _ = writeln!(
315            out,
316            "**Overall: {}** ({} pass, {} info, {} warn, {} fail)",
317            self.overall,
318            self.summary.pass,
319            self.summary.info,
320            self.summary.warn,
321            self.summary.fail
322        );
323        out
324    }
325}
326
327// ── Options ─────────────────────────────────────────────────────────
328
329/// Options for `run_doctor`.
330pub struct DoctorOptions<'a> {
331    pub cwd: &'a Path,
332    pub extension_path: Option<&'a str>,
333    pub policy_override: Option<&'a str>,
334    pub fix: bool,
335    pub only: Option<HashSet<CheckCategory>>,
336}
337
338// ── Entry Point ─────────────────────────────────────────────────────
339
340/// Run all applicable doctor checks and return a report.
341#[allow(clippy::too_many_lines)]
342pub fn run_doctor(opts: &DoctorOptions<'_>) -> Result<DoctorReport> {
343    let mut findings = Vec::new();
344    let extension_only_default = opts.extension_path.is_some() && opts.only.is_none();
345
346    let should_run = |cat: CheckCategory| -> bool {
347        if extension_only_default {
348            return false;
349        }
350        opts.only.as_ref().is_none_or(|set| set.contains(&cat))
351    };
352
353    if let Some(ext_path) = opts.extension_path {
354        if opts
355            .only
356            .as_ref()
357            .is_none_or(|set| set.contains(&CheckCategory::Extensions))
358        {
359            check_extension(opts.cwd, ext_path, opts.policy_override, &mut findings)?;
360        }
361    } else if opts
362        .only
363        .as_ref()
364        .is_some_and(|set| set.contains(&CheckCategory::Extensions))
365    {
366        findings.push(
367            Finding::fail(
368                CheckCategory::Extensions,
369                "Extensions check requires an extension path",
370            )
371            .with_remediation(
372                "Run `pi doctor <path-to-extension>` to evaluate extension compatibility",
373            ),
374        );
375    }
376
377    if should_run(CheckCategory::Config) {
378        check_config(opts.cwd, &mut findings);
379    }
380    if should_run(CheckCategory::Dirs) {
381        check_dirs(opts.fix, &mut findings);
382    }
383    if should_run(CheckCategory::Auth) {
384        check_auth(opts.fix, &mut findings);
385    }
386    if should_run(CheckCategory::Shell) {
387        check_shell(&mut findings);
388    }
389    if should_run(CheckCategory::Sessions) {
390        check_sessions(&mut findings);
391    }
392
393    Ok(DoctorReport::from_findings(findings))
394}
395
396// ── Check: Config ───────────────────────────────────────────────────
397
398fn check_config(cwd: &Path, findings: &mut Vec<Finding>) {
399    let cat = CheckCategory::Config;
400
401    // Global settings
402    let global_path = Config::global_dir().join("settings.json");
403    check_settings_file(cat, &global_path, "Global settings", findings);
404
405    // Project settings
406    let project_path = cwd.join(Config::project_dir()).join("settings.json");
407    if project_path.exists() {
408        check_settings_file(
409            cat,
410            &project_path,
411            "Project settings (.pi/settings.json)",
412            findings,
413        );
414    } else {
415        findings.push(Finding::pass(cat, "No project settings (OK)"));
416    }
417}
418
419fn check_settings_file(cat: CheckCategory, path: &Path, label: &str, findings: &mut Vec<Finding>) {
420    if !path.exists() {
421        findings.push(Finding::pass(cat, format!("{label}: not present (OK)")));
422        return;
423    }
424    match std::fs::read_to_string(path) {
425        Ok(content) => {
426            let value: serde_json::Value = match serde_json::from_str(&content) {
427                Ok(value) => value,
428                Err(e) => {
429                    findings.push(
430                        Finding::fail(cat, format!("{label}: JSON parse error"))
431                            .with_detail(e.to_string())
432                            .with_remediation(format!("Fix the JSON syntax in {}", path.display())),
433                    );
434                    return;
435                }
436            };
437
438            let serde_json::Value::Object(map) = value else {
439                findings.push(
440                    Finding::fail(
441                        cat,
442                        format!("{label}: top-level value must be a JSON object"),
443                    )
444                    .with_detail(format!("Found non-object JSON in {}", path.display()))
445                    .with_remediation(format!("Wrap settings in {{ ... }} in {}", path.display())),
446                );
447                return;
448            };
449
450            let unknown: Vec<&String> = map.keys().filter(|k| !is_known_config_key(k)).collect();
451            if unknown.is_empty() {
452                findings.push(Finding::pass(cat, label.to_string()));
453            } else {
454                findings.push(
455                    Finding::warn(cat, format!("{label}: unknown keys"))
456                        .with_detail(format!(
457                            "Unknown keys: {}",
458                            unknown
459                                .iter()
460                                .map(|k| k.as_str())
461                                .collect::<Vec<_>>()
462                                .join(", ")
463                        ))
464                        .with_remediation("Check for typos in settings key names"),
465                );
466            }
467        }
468        Err(e) => {
469            findings.push(
470                Finding::fail(cat, format!("{label}: read error"))
471                    .with_detail(e.to_string())
472                    .with_remediation(format!("Check file permissions on {}", path.display())),
473            );
474        }
475    }
476}
477
478/// Known top-level config keys (from `Config` struct fields + their camelCase aliases).
479fn is_known_config_key(key: &str) -> bool {
480    matches!(
481        key,
482        "theme"
483            | "hideThinkingBlock"
484            | "hide_thinking_block"
485            | "showHardwareCursor"
486            | "show_hardware_cursor"
487            | "defaultProvider"
488            | "default_provider"
489            | "defaultModel"
490            | "default_model"
491            | "defaultThinkingLevel"
492            | "default_thinking_level"
493            | "enabledModels"
494            | "enabled_models"
495            | "steeringMode"
496            | "steering_mode"
497            | "followUpMode"
498            | "follow_up_mode"
499            | "quietStartup"
500            | "quiet_startup"
501            | "collapseChangelog"
502            | "collapse_changelog"
503            | "lastChangelogVersion"
504            | "last_changelog_version"
505            | "doubleEscapeAction"
506            | "double_escape_action"
507            | "editorPaddingX"
508            | "editor_padding_x"
509            | "autocompleteMaxVisible"
510            | "autocomplete_max_visible"
511            | "sessionPickerInput"
512            | "session_picker_input"
513            | "sessionStore"
514            | "sessionBackend"
515            | "session_store"
516            | "compaction"
517            | "branchSummary"
518            | "branch_summary"
519            | "retry"
520            | "shellPath"
521            | "shell_path"
522            | "shellCommandPrefix"
523            | "shell_command_prefix"
524            | "ghPath"
525            | "gh_path"
526            | "images"
527            | "terminal"
528            | "thinkingBudgets"
529            | "thinking_budgets"
530            | "packages"
531            | "extensions"
532            | "skills"
533            | "prompts"
534            | "themes"
535            | "enableSkillCommands"
536            | "enable_skill_commands"
537            | "extensionPolicy"
538            | "extension_policy"
539            | "repairPolicy"
540            | "repair_policy"
541            | "extensionRisk"
542            | "extension_risk"
543            | "checkForUpdates"
544            | "check_for_updates"
545            | "sessionDurability"
546            | "session_durability"
547            | "markdown"
548            | "queueMode"
549    )
550}
551
552// ── Check: Dirs ─────────────────────────────────────────────────────
553
554fn check_dirs(fix: bool, findings: &mut Vec<Finding>) {
555    let cat = CheckCategory::Dirs;
556    let dirs = [
557        ("Agent directory", Config::global_dir()),
558        ("Sessions directory", Config::sessions_dir()),
559        ("Packages directory", Config::package_dir()),
560    ];
561
562    for (label, dir) in &dirs {
563        check_dir(cat, label, dir, fix, findings);
564    }
565}
566
567fn check_dir(cat: CheckCategory, label: &str, dir: &Path, fix: bool, findings: &mut Vec<Finding>) {
568    if dir.is_dir() {
569        // Check write permission
570        match tempfile::NamedTempFile::new_in(dir) {
571            Ok(mut probe_file) => match probe_file.write_all(b"probe") {
572                Ok(()) => {
573                    findings.push(Finding::pass(cat, format!("{label} ({})", dir.display())));
574                }
575                Err(e) => {
576                    findings.push(
577                        Finding::fail(cat, format!("{label}: not writable"))
578                            .with_detail(format!("{}: {e}", dir.display()))
579                            .with_remediation(format!("chmod u+w {}", dir.display())),
580                    );
581                }
582            },
583            Err(e) => {
584                findings.push(
585                    Finding::fail(cat, format!("{label}: not writable"))
586                        .with_detail(format!("{}: {e}", dir.display()))
587                        .with_remediation(format!("chmod u+w {}", dir.display())),
588                );
589            }
590        }
591    } else if fix {
592        match std::fs::create_dir_all(dir) {
593            Ok(()) => {
594                findings.push(
595                    Finding::pass(cat, format!("{label}: created ({})", dir.display())).fixed(),
596                );
597            }
598            Err(e) => {
599                findings.push(
600                    Finding::fail(cat, format!("{label}: could not create"))
601                        .with_detail(format!("{}: {e}", dir.display()))
602                        .with_remediation(format!("mkdir -p {}", dir.display())),
603                );
604            }
605        }
606    } else {
607        findings.push(
608            Finding::warn(cat, format!("{label}: missing"))
609                .with_detail(format!("{} does not exist", dir.display()))
610                .with_remediation(format!("mkdir -p {}", dir.display()))
611                .auto_fixable(),
612        );
613    }
614}
615
616// ── Check: Auth ─────────────────────────────────────────────────────
617
618#[allow(clippy::too_many_lines)]
619fn check_auth(fix: bool, findings: &mut Vec<Finding>) {
620    let cat = CheckCategory::Auth;
621    let auth_path = Config::auth_path();
622
623    if !auth_path.exists() {
624        findings.push(
625            Finding::info(cat, "auth.json: not present")
626                .with_detail("No credentials stored yet")
627                .with_remediation("Run `pi` and follow the login prompt, or set ANTHROPIC_API_KEY"),
628        );
629        // Still check env vars
630        check_auth_env_vars(cat, findings);
631        return;
632    }
633
634    // Check if auth.json parses
635    let auth = match AuthStorage::load(auth_path.clone()) {
636        Ok(auth) => {
637            findings.push(Finding::pass(cat, "auth.json parses correctly"));
638            Some(auth)
639        }
640        Err(e) => {
641            findings.push(
642                Finding::fail(cat, "auth.json: parse error")
643                    .with_detail(e.to_string())
644                    .with_remediation("Check auth.json syntax or delete and re-authenticate"),
645            );
646            None
647        }
648    };
649
650    // Check file permissions (Unix only)
651    #[cfg(unix)]
652    {
653        use std::os::unix::fs::PermissionsExt;
654        if let Ok(meta) = std::fs::metadata(&auth_path) {
655            let mode = meta.permissions().mode() & 0o777;
656            if mode == 0o600 {
657                findings.push(Finding::pass(cat, "auth.json permissions (600)"));
658            } else if fix {
659                match std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600)) {
660                    Ok(()) => {
661                        findings.push(
662                            Finding::pass(
663                                cat,
664                                format!("auth.json permissions fixed (was {mode:o}, now 600)"),
665                            )
666                            .fixed(),
667                        );
668                    }
669                    Err(e) => {
670                        findings.push(
671                            Finding::fail(cat, "auth.json: could not fix permissions")
672                                .with_detail(e.to_string()),
673                        );
674                    }
675                }
676            } else {
677                findings.push(
678                    Finding::warn(
679                        cat,
680                        format!("auth.json permissions are {mode:o}, should be 600"),
681                    )
682                    .with_remediation(format!("chmod 600 {}", auth_path.display()))
683                    .auto_fixable(),
684                );
685            }
686        }
687    }
688
689    // Check stored credentials
690    if let Some(auth) = &auth {
691        let providers = auth.provider_names();
692        if providers.is_empty() {
693            findings.push(
694                Finding::info(cat, "No stored credentials")
695                    .with_remediation("Run `pi` to authenticate or set an API key env var"),
696            );
697        } else {
698            for provider in &providers {
699                let status = auth.credential_status(provider);
700                match status {
701                    CredentialStatus::ApiKey => {
702                        findings.push(Finding::pass(
703                            cat,
704                            format!("{provider}: API key configured"),
705                        ));
706                    }
707                    CredentialStatus::OAuthValid { .. } => {
708                        findings.push(Finding::pass(cat, format!("{provider}: OAuth token valid")));
709                    }
710                    CredentialStatus::OAuthExpired { .. } => {
711                        findings.push(
712                            Finding::warn(cat, format!("{provider}: OAuth token expired"))
713                                .with_remediation(format!("Run `pi /login {provider}` to refresh")),
714                        );
715                    }
716                    CredentialStatus::BearerToken => {
717                        findings.push(Finding::pass(
718                            cat,
719                            format!("{provider}: bearer token configured"),
720                        ));
721                    }
722                    CredentialStatus::AwsCredentials => {
723                        findings.push(Finding::pass(
724                            cat,
725                            format!("{provider}: AWS credentials configured"),
726                        ));
727                    }
728                    CredentialStatus::ServiceKey => {
729                        findings.push(Finding::pass(
730                            cat,
731                            format!("{provider}: service key configured"),
732                        ));
733                    }
734                    CredentialStatus::Missing => {
735                        // Shouldn't happen since we're iterating stored providers
736                        findings.push(Finding::info(cat, format!("{provider}: no credentials")));
737                    }
738                }
739            }
740        }
741    }
742
743    check_auth_env_vars(cat, findings);
744}
745
746/// Check common auth-related environment variables.
747fn check_auth_env_vars(cat: CheckCategory, findings: &mut Vec<Finding>) {
748    let key_providers = [
749        ("anthropic", "ANTHROPIC_API_KEY"),
750        ("openai", "OPENAI_API_KEY"),
751        ("google", "GOOGLE_API_KEY"),
752    ];
753
754    for (provider, env_key) in &key_providers {
755        let env_keys = provider_auth_env_keys(provider);
756        let has_env = env_keys.iter().any(|k| std::env::var(k).is_ok());
757        if has_env {
758            findings.push(Finding::pass(
759                cat,
760                format!("{provider}: env var set ({env_key})"),
761            ));
762        } else {
763            findings.push(
764                Finding::info(cat, format!("{provider}: no env var"))
765                    .with_detail(format!("Set {env_key} or run `pi /login {provider}`")),
766            );
767        }
768    }
769}
770
771// ── Check: Shell ────────────────────────────────────────────────────
772
773fn check_shell(findings: &mut Vec<Finding>) {
774    let cat = CheckCategory::Shell;
775
776    // Required tools (Fail if missing)
777    check_tool(
778        cat,
779        "bash",
780        &["--version"],
781        Severity::Fail,
782        ToolCheckMode::PresenceOnly,
783        findings,
784    );
785    check_tool(
786        cat,
787        "sh",
788        &["--version"],
789        Severity::Fail,
790        ToolCheckMode::PresenceOnly,
791        findings,
792    );
793
794    // Important tools (Warn if missing)
795    check_tool(
796        cat,
797        "git",
798        &["--version"],
799        Severity::Warn,
800        ToolCheckMode::PresenceOnly,
801        findings,
802    );
803    check_tool(
804        cat,
805        "rg",
806        &["--version"],
807        Severity::Warn,
808        ToolCheckMode::PresenceOnly,
809        findings,
810    );
811
812    let fd_bin = if which_tool("fd").is_some() {
813        "fd"
814    } else {
815        "fdfind"
816    };
817    check_tool(
818        cat,
819        fd_bin,
820        &["--version"],
821        Severity::Warn,
822        ToolCheckMode::PresenceOnly,
823        findings,
824    );
825
826    // Optional tools (Info if missing)
827    check_tool(
828        cat,
829        "gh",
830        &["--version"],
831        Severity::Info,
832        ToolCheckMode::PresenceOnly,
833        findings,
834    );
835}
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq)]
838enum ToolCheckMode {
839    PresenceOnly,
840    ProbeExecution,
841}
842
843fn check_tool(
844    cat: CheckCategory,
845    tool: &str,
846    args: &[&str],
847    missing_severity: Severity,
848    mode: ToolCheckMode,
849    findings: &mut Vec<Finding>,
850) {
851    let discovered_path = which_tool(tool);
852    if mode == ToolCheckMode::PresenceOnly {
853        if let Some(path) = discovered_path {
854            findings.push(Finding::pass(cat, format!("{tool} ({path})")));
855            return;
856        }
857        report_missing_tool(cat, tool, missing_severity, findings);
858        return;
859    }
860
861    let command_target = discovered_path.as_deref().unwrap_or(tool);
862
863    match Command::new(command_target).args(args).output() {
864        Ok(output) if output.status.success() => {
865            // Extract version from first line of stdout
866            let version = String::from_utf8_lossy(&output.stdout);
867            let first_line = version.lines().next().unwrap_or("").trim();
868            let label = discovered_path.as_ref().map_or_else(
869                || {
870                    if first_line.is_empty() {
871                        tool.to_string()
872                    } else {
873                        format!("{tool}: {first_line}")
874                    }
875                },
876                |path| format!("{tool} ({path})"),
877            );
878            findings.push(Finding::pass(cat, label));
879        }
880        Ok(output)
881            if discovered_path.is_some()
882                && probe_failure_is_known_nonfatal(tool, args, &output) =>
883        {
884            // Some shells (e.g. dash as /bin/sh) do not support --version.
885            // If this is the known non-fatal probe case, treat tool as present.
886            let path = discovered_path.unwrap_or_default();
887            findings.push(Finding::pass(cat, format!("{tool} ({path})")));
888        }
889        Ok(output) => {
890            let suffix = if missing_severity == Severity::Info {
891                " (optional)"
892            } else {
893                ""
894            };
895            let detail = {
896                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
897                if stderr.is_empty() {
898                    format!("Exit status: {:?}", output.status.code())
899                } else {
900                    stderr
901                }
902            };
903            findings.push(Finding {
904                category: cat,
905                severity: missing_severity,
906                title: format!("{tool}: invocation failed{suffix}"),
907                detail: Some(detail),
908                remediation: discovered_path
909                    .as_ref()
910                    .map(|path| format!("Verify this executable is healthy: {path}")),
911                fixability: Fixability::NotFixable,
912            });
913        }
914        Err(err) => {
915            if discovered_path.is_some() || err.kind() != std::io::ErrorKind::NotFound {
916                let suffix = if missing_severity == Severity::Info {
917                    " (optional)"
918                } else {
919                    ""
920                };
921                findings.push(Finding {
922                    category: cat,
923                    severity: missing_severity,
924                    title: format!("{tool}: invocation failed{suffix}"),
925                    detail: Some(err.to_string()),
926                    remediation: discovered_path
927                        .as_ref()
928                        .map(|path| format!("Verify this executable is healthy: {path}")),
929                    fixability: Fixability::NotFixable,
930                });
931            } else {
932                report_missing_tool(cat, tool, missing_severity, findings);
933            }
934        }
935    }
936}
937
938fn report_missing_tool(
939    cat: CheckCategory,
940    tool: &str,
941    missing_severity: Severity,
942    findings: &mut Vec<Finding>,
943) {
944    let suffix = if missing_severity == Severity::Info {
945        " (optional)"
946    } else {
947        ""
948    };
949    let mut f = Finding {
950        category: cat,
951        severity: missing_severity,
952        title: format!("{tool}: not found{suffix}"),
953        detail: None,
954        remediation: None,
955        fixability: Fixability::NotFixable,
956    };
957    if tool == "gh" {
958        f.remediation = Some("Install: https://cli.github.com/".to_string());
959    }
960    findings.push(f);
961}
962
963fn probe_failure_is_known_nonfatal(
964    tool: &str,
965    args: &[&str],
966    output: &std::process::Output,
967) -> bool {
968    if tool != "sh" || args != ["--version"] {
969        return false;
970    }
971    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
972    stderr.contains("illegal option")
973        || stderr.contains("unknown option")
974        || stderr.contains("invalid option")
975}
976
977fn which_tool(tool: &str) -> Option<String> {
978    let tool_path = Path::new(tool);
979    if tool_path.components().count() > 1 {
980        return is_executable(tool_path).then(|| tool_path.display().to_string());
981    }
982
983    let path_var = std::env::var_os("PATH")?;
984    for dir in std::env::split_paths(&path_var) {
985        if let Some(path) = resolve_executable_in_dir(&dir, tool) {
986            return Some(path.display().to_string());
987        }
988    }
989    None
990}
991
992fn resolve_executable_in_dir(dir: &Path, tool: &str) -> Option<PathBuf> {
993    #[cfg(windows)]
994    {
995        let candidate = dir.join(tool);
996        if is_executable(&candidate) {
997            return Some(candidate);
998        }
999        let pathext = std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1000        for ext in std::env::split_paths(&pathext) {
1001            let ext = ext.to_string_lossy();
1002            let suffix = ext.trim_matches('.');
1003            if suffix.is_empty() {
1004                continue;
1005            }
1006            let candidate = dir.join(format!("{tool}.{suffix}"));
1007            if is_executable(&candidate) {
1008                return Some(candidate);
1009            }
1010        }
1011        None
1012    }
1013
1014    #[cfg(not(windows))]
1015    {
1016        let candidate = dir.join(tool);
1017        is_executable(&candidate).then_some(candidate)
1018    }
1019}
1020
1021fn is_executable(path: &Path) -> bool {
1022    if !path.is_file() {
1023        return false;
1024    }
1025
1026    #[cfg(unix)]
1027    {
1028        use std::os::unix::fs::PermissionsExt as _;
1029        std::fs::metadata(path)
1030            .ok()
1031            .is_some_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
1032    }
1033
1034    #[cfg(not(unix))]
1035    {
1036        true
1037    }
1038}
1039
1040// ── Check: Sessions ─────────────────────────────────────────────────
1041
1042fn check_sessions(findings: &mut Vec<Finding>) {
1043    let cat = CheckCategory::Sessions;
1044    let sessions_dir = Config::sessions_dir();
1045
1046    if !sessions_dir.is_dir() {
1047        findings.push(Finding::info(
1048            cat,
1049            "Sessions directory does not exist (no sessions yet)",
1050        ));
1051        return;
1052    }
1053
1054    let entries = walk_sessions(&sessions_dir);
1055    let total = entries.len().min(500); // Cap scan
1056    let mut corrupt = 0u32;
1057
1058    for entry in entries.into_iter().take(500) {
1059        let Ok(path) = entry else {
1060            corrupt += 1;
1061            continue;
1062        };
1063        if !is_session_healthy(&path) {
1064            corrupt += 1;
1065        }
1066    }
1067
1068    if corrupt == 0 {
1069        findings.push(Finding::pass(cat, format!("{total} sessions, 0 corrupt")));
1070    } else {
1071        findings.push(
1072            Finding::warn(cat, format!("{total} sessions, {corrupt} corrupt"))
1073                .with_detail("Some session files are empty or have invalid headers")
1074                .with_remediation("Corrupt sessions can be safely deleted"),
1075        );
1076    }
1077}
1078
1079/// Quick health check: non-empty and first line parses as JSON.
1080fn is_session_healthy(path: &Path) -> bool {
1081    let Ok(file) = std::fs::File::open(path) else {
1082        return false;
1083    };
1084    let mut reader = BufReader::new(file);
1085    let mut line = String::new();
1086    match reader.read_line(&mut line) {
1087        Ok(0) | Err(_) => false, // empty or unreadable
1088        Ok(_) => serde_json::from_str::<serde_json::Value>(&line).is_ok(),
1089    }
1090}
1091
1092// ── Check: Extension ────────────────────────────────────────────────
1093
1094fn check_extension(
1095    cwd: &Path,
1096    path: &str,
1097    policy_override: Option<&str>,
1098    findings: &mut Vec<Finding>,
1099) -> Result<()> {
1100    use crate::extension_preflight::{FindingSeverity, PreflightAnalyzer, PreflightVerdict};
1101
1102    let cat = CheckCategory::Extensions;
1103    let ext_path = if Path::new(path).is_absolute() {
1104        PathBuf::from(path)
1105    } else {
1106        cwd.join(path)
1107    };
1108
1109    if !ext_path.exists() {
1110        findings.push(
1111            Finding::fail(
1112                cat,
1113                format!("Extension path not found: {}", ext_path.display()),
1114            )
1115            .with_remediation("Check the path and try again"),
1116        );
1117        return Ok(());
1118    }
1119
1120    let config = Config::load()?;
1121    let resolved = config.resolve_extension_policy_with_metadata(policy_override);
1122    let ext_id = ext_path
1123        .file_name()
1124        .and_then(|n| n.to_str())
1125        .unwrap_or("unknown");
1126
1127    let analyzer = PreflightAnalyzer::new(&resolved.policy, Some(ext_id));
1128    let report = analyzer.analyze(&ext_path);
1129
1130    // Convert preflight verdict to a top-level finding
1131    match report.verdict {
1132        PreflightVerdict::Pass => {
1133            findings.push(Finding::pass(
1134                cat,
1135                format!("Extension {ext_id}: compatible"),
1136            ));
1137        }
1138        PreflightVerdict::Warn => {
1139            findings.push(
1140                Finding::warn(cat, format!("Extension {ext_id}: partial compatibility"))
1141                    .with_detail(format!(
1142                        "{} warning(s), {} info",
1143                        report.summary.warnings, report.summary.info
1144                    )),
1145            );
1146        }
1147        PreflightVerdict::Fail => {
1148            findings.push(
1149                Finding::fail(cat, format!("Extension {ext_id}: incompatible"))
1150                    .with_detail(format!(
1151                        "{} error(s), {} warning(s)",
1152                        report.summary.errors, report.summary.warnings
1153                    ))
1154                    .with_remediation(format!("Try: pi doctor {path} --policy permissive")),
1155            );
1156        }
1157    }
1158
1159    // Convert individual preflight findings
1160    for pf in &report.findings {
1161        let severity = match pf.severity {
1162            FindingSeverity::Error => Severity::Fail,
1163            FindingSeverity::Warning => Severity::Warn,
1164            FindingSeverity::Info => Severity::Info,
1165        };
1166        let mut f = Finding {
1167            category: cat,
1168            severity,
1169            title: pf.message.clone(),
1170            detail: pf.file.as_ref().map(|file| {
1171                pf.line
1172                    .map_or_else(|| format!("at {file}"), |line| format!("at {file}:{line}"))
1173            }),
1174            remediation: pf.remediation.clone(),
1175            fixability: Fixability::NotFixable,
1176        };
1177        // Ensure we don't lose location info
1178        if f.detail.is_none() && pf.file.is_some() {
1179            f.detail.clone_from(&pf.file);
1180        }
1181        findings.push(f);
1182    }
1183
1184    Ok(())
1185}
1186
1187// ── Tests ───────────────────────────────────────────────────────────
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn severity_ordering() {
1195        assert!(Severity::Pass < Severity::Info);
1196        assert!(Severity::Info < Severity::Warn);
1197        assert!(Severity::Warn < Severity::Fail);
1198    }
1199
1200    #[test]
1201    fn severity_display() {
1202        assert_eq!(Severity::Pass.to_string(), "PASS");
1203        assert_eq!(Severity::Fail.to_string(), "FAIL");
1204    }
1205
1206    #[test]
1207    fn check_category_parse() {
1208        assert_eq!(
1209            "config".parse::<CheckCategory>().unwrap(),
1210            CheckCategory::Config
1211        );
1212        assert_eq!(
1213            "dirs".parse::<CheckCategory>().unwrap(),
1214            CheckCategory::Dirs
1215        );
1216        assert_eq!(
1217            "directories".parse::<CheckCategory>().unwrap(),
1218            CheckCategory::Dirs
1219        );
1220        assert_eq!(
1221            "auth".parse::<CheckCategory>().unwrap(),
1222            CheckCategory::Auth
1223        );
1224        assert_eq!(
1225            "shell".parse::<CheckCategory>().unwrap(),
1226            CheckCategory::Shell
1227        );
1228        assert_eq!(
1229            "sessions".parse::<CheckCategory>().unwrap(),
1230            CheckCategory::Sessions
1231        );
1232        assert_eq!(
1233            "extensions".parse::<CheckCategory>().unwrap(),
1234            CheckCategory::Extensions
1235        );
1236        assert_eq!(
1237            "ext".parse::<CheckCategory>().unwrap(),
1238            CheckCategory::Extensions
1239        );
1240        assert!("unknown".parse::<CheckCategory>().is_err());
1241    }
1242
1243    #[test]
1244    fn finding_builders() {
1245        let f = Finding::pass(CheckCategory::Config, "test")
1246            .with_detail("detail")
1247            .with_remediation("fix it");
1248        assert_eq!(f.severity, Severity::Pass);
1249        assert_eq!(f.detail.as_deref(), Some("detail"));
1250        assert_eq!(f.remediation.as_deref(), Some("fix it"));
1251
1252        let f = Finding::warn(CheckCategory::Auth, "warn test").auto_fixable();
1253        assert_eq!(f.fixability, Fixability::AutoFixable);
1254
1255        let f = Finding::fail(CheckCategory::Dirs, "fail test").fixed();
1256        assert_eq!(f.severity, Severity::Pass); // fixed downgrades to pass
1257        assert_eq!(f.fixability, Fixability::Fixed);
1258    }
1259
1260    #[test]
1261    fn report_summary() {
1262        let findings = vec![
1263            Finding::pass(CheckCategory::Config, "ok"),
1264            Finding::info(CheckCategory::Auth, "info"),
1265            Finding::warn(CheckCategory::Shell, "warn"),
1266            Finding::fail(CheckCategory::Dirs, "fail"),
1267        ];
1268        let report = DoctorReport::from_findings(findings);
1269        assert_eq!(report.summary.pass, 1);
1270        assert_eq!(report.summary.info, 1);
1271        assert_eq!(report.summary.warn, 1);
1272        assert_eq!(report.summary.fail, 1);
1273        assert_eq!(report.overall, Severity::Fail);
1274    }
1275
1276    #[test]
1277    fn report_all_pass() {
1278        let findings = vec![
1279            Finding::pass(CheckCategory::Config, "a"),
1280            Finding::pass(CheckCategory::Dirs, "b"),
1281        ];
1282        let report = DoctorReport::from_findings(findings);
1283        assert_eq!(report.overall, Severity::Pass);
1284    }
1285
1286    #[test]
1287    fn render_text_includes_header() {
1288        let report =
1289            DoctorReport::from_findings(vec![Finding::pass(CheckCategory::Config, "all good")]);
1290        let text = report.render_text();
1291        assert!(text.contains("Pi Doctor"));
1292        assert!(text.contains("[PASS] Configuration"));
1293        assert!(text.contains("[PASS] all good"));
1294    }
1295
1296    #[test]
1297    fn render_json_valid() {
1298        let report = DoctorReport::from_findings(vec![Finding::pass(CheckCategory::Config, "ok")]);
1299        let json = report.to_json().unwrap();
1300        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1301        assert!(parsed.get("findings").is_some());
1302        assert!(parsed.get("summary").is_some());
1303        assert!(parsed.get("overall").is_some());
1304    }
1305
1306    #[test]
1307    fn render_markdown_includes_header() {
1308        let report =
1309            DoctorReport::from_findings(vec![Finding::warn(CheckCategory::Auth, "expired")]);
1310        let md = report.render_markdown();
1311        assert!(md.contains("# Pi Doctor Report"));
1312        assert!(md.contains("## Authentication"));
1313    }
1314
1315    #[test]
1316    fn known_config_keys_includes_common() {
1317        assert!(is_known_config_key("theme"));
1318        assert!(is_known_config_key("defaultModel"));
1319        assert!(is_known_config_key("extensionPolicy"));
1320        assert!(!is_known_config_key("nonexistent_key_xyz"));
1321    }
1322
1323    #[test]
1324    fn session_healthy_empty_file() {
1325        let dir = tempfile::tempdir().unwrap();
1326        let path = dir.path().join("empty.jsonl");
1327        std::fs::write(&path, "").unwrap();
1328        assert!(!is_session_healthy(&path));
1329    }
1330
1331    #[test]
1332    fn session_healthy_valid_json() {
1333        let dir = tempfile::tempdir().unwrap();
1334        let path = dir.path().join("valid.jsonl");
1335        std::fs::write(&path, r#"{"type":"header","version":1}"#).unwrap();
1336        assert!(is_session_healthy(&path));
1337    }
1338
1339    #[test]
1340    fn session_healthy_invalid_json() {
1341        let dir = tempfile::tempdir().unwrap();
1342        let path = dir.path().join("invalid.jsonl");
1343        std::fs::write(&path, "not json at all\n").unwrap();
1344        assert!(!is_session_healthy(&path));
1345    }
1346
1347    #[test]
1348    fn check_dir_creates_missing_with_fix() {
1349        let dir = tempfile::tempdir().unwrap();
1350        let missing = dir.path().join("sub/nested");
1351        let mut findings = Vec::new();
1352        check_dir(CheckCategory::Dirs, "test", &missing, true, &mut findings);
1353        assert_eq!(findings.len(), 1);
1354        assert_eq!(findings[0].severity, Severity::Pass);
1355        assert_eq!(findings[0].fixability, Fixability::Fixed);
1356        assert!(missing.is_dir());
1357    }
1358
1359    #[test]
1360    fn check_dir_warns_missing_without_fix() {
1361        let dir = tempfile::tempdir().unwrap();
1362        let missing = dir.path().join("sub/nested");
1363        let mut findings = Vec::new();
1364        check_dir(CheckCategory::Dirs, "test", &missing, false, &mut findings);
1365        assert_eq!(findings.len(), 1);
1366        assert_eq!(findings[0].severity, Severity::Warn);
1367        assert_eq!(findings[0].fixability, Fixability::AutoFixable);
1368        assert!(!missing.exists());
1369    }
1370
1371    #[test]
1372    fn check_shell_finds_bash() {
1373        let mut findings = Vec::new();
1374        check_tool(
1375            CheckCategory::Shell,
1376            "bash",
1377            &["--version"],
1378            Severity::Fail,
1379            ToolCheckMode::ProbeExecution,
1380            &mut findings,
1381        );
1382        // bash should be available in CI/dev environments
1383        assert_eq!(findings.len(), 1);
1384        assert_eq!(findings[0].severity, Severity::Pass);
1385    }
1386
1387    #[cfg(unix)]
1388    #[test]
1389    fn check_tool_falls_back_when_probe_args_are_unsupported() {
1390        let mut findings = Vec::new();
1391        check_tool(
1392            CheckCategory::Shell,
1393            "sh",
1394            &["--version"],
1395            Severity::Fail,
1396            ToolCheckMode::ProbeExecution,
1397            &mut findings,
1398        );
1399        assert_eq!(findings.len(), 1);
1400        assert_eq!(findings[0].severity, Severity::Pass);
1401    }
1402
1403    #[cfg(unix)]
1404    #[test]
1405    fn check_tool_reports_invocation_failure_for_broken_executable() {
1406        use std::os::unix::fs::PermissionsExt;
1407
1408        let dir = tempfile::tempdir().unwrap();
1409        let script = dir.path().join("broken_tool.sh");
1410        // Mark a non-binary, non-script blob as executable so spawn fails with
1411        // "exec format error" rather than "not found".
1412        std::fs::write(&script, "not an executable format").unwrap();
1413        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1414        perms.set_mode(0o755);
1415        std::fs::set_permissions(&script, perms).unwrap();
1416
1417        let mut findings = Vec::new();
1418        check_tool(
1419            CheckCategory::Shell,
1420            script.to_str().unwrap(),
1421            &["--version"],
1422            Severity::Fail,
1423            ToolCheckMode::ProbeExecution,
1424            &mut findings,
1425        );
1426
1427        assert_eq!(findings.len(), 1);
1428        assert_eq!(findings[0].severity, Severity::Fail);
1429        assert!(findings[0].title.contains("invocation failed"));
1430    }
1431
1432    #[test]
1433    fn check_settings_file_rejects_non_object_json() {
1434        let dir = tempfile::tempdir().unwrap();
1435        let path = dir.path().join("settings.json");
1436        std::fs::write(&path, "[1,2,3]").unwrap();
1437        let mut findings = Vec::new();
1438        check_settings_file(CheckCategory::Config, &path, "Settings", &mut findings);
1439        assert_eq!(findings.len(), 1);
1440        assert_eq!(findings[0].severity, Severity::Fail);
1441        assert!(
1442            findings[0]
1443                .title
1444                .contains("top-level value must be a JSON object")
1445        );
1446    }
1447
1448    #[test]
1449    fn fixability_display() {
1450        // Ensure serialization works
1451        let json = serde_json::to_string(&Fixability::AutoFixable).unwrap();
1452        assert!(json.contains("autoFixable") || json.contains("auto"));
1453    }
1454
1455    #[test]
1456    fn run_doctor_path_mode_defaults_to_extension_checks_only() {
1457        let dir = tempfile::tempdir().unwrap();
1458        let opts = DoctorOptions {
1459            cwd: dir.path(),
1460            extension_path: Some("missing-ext"),
1461            policy_override: None,
1462            fix: false,
1463            only: None,
1464        };
1465        let report = run_doctor(&opts).unwrap();
1466        assert!(
1467            !report.findings.is_empty(),
1468            "missing extension path should produce at least one finding"
1469        );
1470        assert!(
1471            report
1472                .findings
1473                .iter()
1474                .all(|f| f.category == CheckCategory::Extensions),
1475            "path mode should not run unrelated environment categories by default"
1476        );
1477    }
1478
1479    #[test]
1480    fn run_doctor_only_extensions_without_path_reports_error_finding() {
1481        let mut only = HashSet::new();
1482        only.insert(CheckCategory::Extensions);
1483        let dir = tempfile::tempdir().unwrap();
1484        let opts = DoctorOptions {
1485            cwd: dir.path(),
1486            extension_path: None,
1487            policy_override: None,
1488            fix: false,
1489            only: Some(only),
1490        };
1491        let report = run_doctor(&opts).unwrap();
1492        assert!(
1493            report
1494                .findings
1495                .iter()
1496                .any(|f| f.category == CheckCategory::Extensions && f.severity == Severity::Fail),
1497            "extensions-only mode without a path should emit a clear failure finding"
1498        );
1499    }
1500
1501    mod proptest_doctor {
1502        use super::*;
1503        use proptest::prelude::*;
1504
1505        const ALL_SEVERITIES: &[Severity] = &[
1506            Severity::Pass,
1507            Severity::Info,
1508            Severity::Warn,
1509            Severity::Fail,
1510        ];
1511
1512        const CATEGORY_ALIASES: &[&str] = &[
1513            "config",
1514            "dirs",
1515            "directories",
1516            "auth",
1517            "authentication",
1518            "shell",
1519            "sessions",
1520            "extensions",
1521            "ext",
1522        ];
1523
1524        proptest! {
1525            /// Severity ordering is total: Pass < Info < Warn < Fail.
1526            #[test]
1527            fn severity_ordering_total(a in 0..4usize, b in 0..4usize) {
1528                let sa = ALL_SEVERITIES[a];
1529                let sb = ALL_SEVERITIES[b];
1530                match a.cmp(&b) {
1531                    std::cmp::Ordering::Less => assert!(sa < sb),
1532                    std::cmp::Ordering::Equal => assert!(sa == sb),
1533                    std::cmp::Ordering::Greater => assert!(sa > sb),
1534                }
1535            }
1536
1537            /// Severity display produces uppercase 4-char labels.
1538            #[test]
1539            fn severity_display_uppercase(idx in 0..4usize) {
1540                let s = ALL_SEVERITIES[idx];
1541                let display = s.to_string();
1542                assert_eq!(display.len(), 4);
1543                assert!(display.chars().all(|c| c.is_ascii_uppercase()));
1544            }
1545
1546            /// `CheckCategory::from_str` accepts all known aliases.
1547            #[test]
1548            fn check_category_known_aliases(idx in 0..CATEGORY_ALIASES.len()) {
1549                let alias = CATEGORY_ALIASES[idx];
1550                assert!(alias.parse::<CheckCategory>().is_ok());
1551            }
1552
1553            /// `CheckCategory::from_str` is case-insensitive.
1554            #[test]
1555            fn check_category_case_insensitive(idx in 0..CATEGORY_ALIASES.len()) {
1556                let alias = CATEGORY_ALIASES[idx];
1557                let upper = alias.to_uppercase();
1558                let lower_result = alias.parse::<CheckCategory>();
1559                let upper_result = upper.parse::<CheckCategory>();
1560                assert_eq!(lower_result, upper_result);
1561            }
1562
1563            /// Unknown category names are rejected.
1564            #[test]
1565            fn check_category_unknown_rejected(s in "[a-z]{10,20}") {
1566                assert!(s.parse::<CheckCategory>().is_err());
1567            }
1568
1569            /// `CheckCategory::label` returns non-empty strings.
1570            #[test]
1571            fn check_category_label_non_empty(idx in 0..6usize) {
1572                let cats = [
1573                    CheckCategory::Config,
1574                    CheckCategory::Dirs,
1575                    CheckCategory::Auth,
1576                    CheckCategory::Shell,
1577                    CheckCategory::Sessions,
1578                    CheckCategory::Extensions,
1579                ];
1580                let label = cats[idx].label();
1581                assert!(!label.is_empty());
1582                // Label starts with uppercase
1583                assert!(label.starts_with(|c: char| c.is_uppercase()));
1584            }
1585
1586            /// `DoctorReport::from_findings` summary counts match input.
1587            #[test]
1588            fn from_findings_counts_match(
1589                pass in 0..5usize,
1590                info in 0..5usize,
1591                warn in 0..5usize,
1592                fail in 0..5usize
1593            ) {
1594                let mut findings = Vec::new();
1595                for _ in 0..pass {
1596                    findings.push(Finding::pass(CheckCategory::Config, "test"));
1597                }
1598                for _ in 0..info {
1599                    findings.push(Finding::info(CheckCategory::Config, "test"));
1600                }
1601                for _ in 0..warn {
1602                    findings.push(Finding::warn(CheckCategory::Config, "test"));
1603                }
1604                for _ in 0..fail {
1605                    findings.push(Finding::fail(CheckCategory::Config, "test"));
1606                }
1607
1608                let report = DoctorReport::from_findings(findings);
1609                assert_eq!(report.summary.pass, pass);
1610                assert_eq!(report.summary.info, info);
1611                assert_eq!(report.summary.warn, warn);
1612                assert_eq!(report.summary.fail, fail);
1613            }
1614
1615            /// `DoctorReport::from_findings` overall severity is max of inputs.
1616            #[test]
1617            fn from_findings_overall_severity(
1618                pass in 0..3usize,
1619                info in 0..3usize,
1620                warn in 0..3usize,
1621                fail in 0..3usize
1622            ) {
1623                let mut findings = Vec::new();
1624                for _ in 0..pass {
1625                    findings.push(Finding::pass(CheckCategory::Config, "test"));
1626                }
1627                for _ in 0..info {
1628                    findings.push(Finding::info(CheckCategory::Config, "test"));
1629                }
1630                for _ in 0..warn {
1631                    findings.push(Finding::warn(CheckCategory::Config, "test"));
1632                }
1633                for _ in 0..fail {
1634                    findings.push(Finding::fail(CheckCategory::Config, "test"));
1635                }
1636
1637                let report = DoctorReport::from_findings(findings);
1638
1639                if fail > 0 {
1640                    assert_eq!(report.overall, Severity::Fail);
1641                } else if warn > 0 {
1642                    assert_eq!(report.overall, Severity::Warn);
1643                } else {
1644                    assert_eq!(report.overall, Severity::Pass);
1645                }
1646            }
1647
1648            /// `is_known_config_key` accepts both camelCase and snake_case forms.
1649            #[test]
1650            fn config_key_pairs(idx in 0..10usize) {
1651                let pairs = [
1652                    ("hideThinkingBlock", "hide_thinking_block"),
1653                    ("showHardwareCursor", "show_hardware_cursor"),
1654                    ("defaultProvider", "default_provider"),
1655                    ("defaultModel", "default_model"),
1656                    ("defaultThinkingLevel", "default_thinking_level"),
1657                    ("enabledModels", "enabled_models"),
1658                    ("steeringMode", "steering_mode"),
1659                    ("followUpMode", "follow_up_mode"),
1660                    ("quietStartup", "quiet_startup"),
1661                    ("collapseChangelog", "collapse_changelog"),
1662                ];
1663                let (camel, snake) = pairs[idx];
1664                assert!(is_known_config_key(camel), "camelCase key {camel} should be known");
1665                assert!(is_known_config_key(snake), "snake_case key {snake} should be known");
1666            }
1667
1668            /// `is_known_config_key` rejects garbage keys.
1669            #[test]
1670            fn config_key_rejects_garbage(s in "[A-Z]{20,30}") {
1671                assert!(!is_known_config_key(&s));
1672            }
1673
1674            /// Severity serde roundtrip is lowercase.
1675            #[test]
1676            fn severity_serde_lowercase(idx in 0..4usize) {
1677                let s = ALL_SEVERITIES[idx];
1678                let json = serde_json::to_string(&s).unwrap();
1679                let expected = format!("\"{}\"", s.to_string().to_lowercase());
1680                assert_eq!(json, expected);
1681            }
1682
1683            /// Finding builder chain preserves fields.
1684            #[test]
1685            fn finding_builder_chain(title in "[a-z ]{1,20}", detail in "[a-z ]{1,20}") {
1686                let f = Finding::warn(CheckCategory::Shell, title.clone())
1687                    .with_detail(detail.clone())
1688                    .with_remediation("fix it")
1689                    .auto_fixable();
1690                assert_eq!(f.title, title);
1691                assert_eq!(f.detail.as_deref(), Some(detail.as_str()));
1692                assert_eq!(f.remediation.as_deref(), Some("fix it"));
1693                assert_eq!(f.fixability, Fixability::AutoFixable);
1694                assert_eq!(f.severity, Severity::Warn);
1695            }
1696
1697            /// `fixed()` resets severity to Pass.
1698            #[test]
1699            fn finding_fixed_resets_severity(idx in 0..4usize) {
1700                let builders = [
1701                    Finding::pass(CheckCategory::Config, "t"),
1702                    Finding::info(CheckCategory::Config, "t"),
1703                    Finding::warn(CheckCategory::Config, "t"),
1704                    Finding::fail(CheckCategory::Config, "t"),
1705                ];
1706                let fixed = builders[idx].clone().fixed();
1707                assert_eq!(fixed.severity, Severity::Pass);
1708                assert_eq!(fixed.fixability, Fixability::Fixed);
1709            }
1710        }
1711    }
1712}