1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Fixability {
48 NotFixable,
50 AutoFixable,
52 Fixed,
54}
55
56#[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#[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#[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#[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 pub fn render_text(&self) -> String {
228 let mut out = String::with_capacity(2048);
229 out.push_str("Pi Doctor\n=========\n");
230
231 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 pub fn to_json(&self) -> Result<String> {
279 Ok(serde_json::to_string_pretty(self)?)
280 }
281
282 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
328pub 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#[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
397fn check_config(cwd: &Path, findings: &mut Vec<Finding>) {
400 let cat = CheckCategory::Config;
401
402 let global_path = Config::global_dir().join("settings.json");
404 check_settings_file(cat, &global_path, "Global settings", findings);
405
406 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
479fn 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
553fn 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 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#[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 check_auth_env_vars(cat, findings);
633 return;
634 }
635
636 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 #[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 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 findings.push(Finding::info(cat, format!("{provider}: no credentials")));
739 }
740 }
741 }
742 }
743 }
744
745 check_auth_env_vars(cat, findings);
746}
747
748fn 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
773fn check_shell(findings: &mut Vec<Finding>) {
776 let cat = CheckCategory::Shell;
777
778 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 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 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 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 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
1046fn 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); 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
1085fn 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, Ok(_) => serde_json::from_str::<SessionHeader>(&line).is_ok_and(|header| header.is_valid()),
1104 }
1105}
1106
1107fn 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 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 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 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 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#[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); 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 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 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 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 #[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 #[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 #[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 #[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 #[test]
1823 fn check_category_unknown_rejected(s in "[a-z]{10,20}") {
1824 assert!(s.parse::<CheckCategory>().is_err());
1825 }
1826
1827 #[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 assert!(label.starts_with(|c: char| c.is_uppercase()));
1842 }
1843
1844 #[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 #[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 #[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 #[test]
1928 fn config_key_rejects_garbage(s in "[A-Z]{20,30}") {
1929 assert!(!is_known_config_key(&s));
1930 }
1931
1932 #[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 #[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 #[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}