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