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