1use std::path::{Path, PathBuf};
4
5use fallow_engine::project_config::{
6 ProjectConfig, ProjectConfigOptions, config_for_project_readiness,
7};
8use fallow_output::{
9 DOCTOR_SCHEMA_VERSION, DoctorCheck, DoctorCheckCategory, DoctorCheckId, DoctorCheckStatus,
10 DoctorOutput, DoctorRemediation, DoctorStatus, DoctorSummary,
11};
12use fallow_types::envelope::{SchemaVersion, ToolVersion};
13
14pub struct DoctorOptions<'a> {
16 pub root: &'a Path,
18 pub config_path: Option<&'a Path>,
20}
21
22#[must_use]
25pub fn run_doctor(options: &DoctorOptions<'_>) -> DoctorOutput {
26 run_doctor_with_discovery(options, &crate::type_aware::discover_companion)
27}
28
29fn run_doctor_with_discovery<F>(options: &DoctorOptions<'_>, discover_companion: &F) -> DoctorOutput
30where
31 F: Fn(&Path) -> Result<(), String>,
32{
33 let mut checks = Vec::with_capacity(5);
34 let root = match fallow_engine::validate::validate_root(options.root) {
35 Ok(root) => {
36 checks.push(check(
37 DoctorCheckId::Root,
38 DoctorCheckCategory::Project,
39 DoctorCheckStatus::Pass,
40 true,
41 "Project root is an accessible directory.",
42 None,
43 ));
44 root
45 }
46 Err(_) => {
47 checks.push(check(
48 DoctorCheckId::Root,
49 DoctorCheckCategory::Project,
50 DoctorCheckStatus::Fail,
51 true,
52 "Project root is not accessible. Set --root to an existing, readable directory.",
53 None,
54 ));
55 push_prerequisite_skips(&mut checks, "Project root readiness failed.");
56 return build_output(checks);
57 }
58 };
59
60 let project = config_for_project_readiness(
61 &root,
62 options.config_path,
63 ProjectConfigOptions {
64 output: fallow_config::OutputFormat::Json,
65 no_cache: true,
66 threads: 1,
67 production_override: None,
68 quiet: true,
69 analysis: fallow_config::ProductionAnalysis::DeadCode,
70 allow_remote_extends: false,
71 },
72 );
73
74 match project {
75 Ok(readiness) => push_ready_project_checks(
76 &mut checks,
77 &root,
78 &readiness.project,
79 &readiness.configured_plugin_diagnostics,
80 discover_companion,
81 ),
82 Err(error) => {
83 push_project_failure_checks(&mut checks, error.message(), &root, options.config_path);
84 }
85 }
86
87 build_output(checks)
88}
89
90fn push_ready_project_checks<F>(
91 checks: &mut Vec<DoctorCheck>,
92 root: &Path,
93 project: &ProjectConfig,
94 configured_plugin_diagnostics: &[fallow_config::ConfiguredPluginDiagnostic],
95 discover_companion: &F,
96) where
97 F: Fn(&Path) -> Result<(), String>,
98{
99 let config_message = project.path.as_ref().map_or_else(
100 || "Zero-config defaults resolved successfully.".to_string(),
101 |path| match safe_config_argument(root, path) {
102 Some(path) => format!("Configuration resolved from {path}."),
103 None => "The explicitly selected configuration resolved successfully.".to_string(),
104 },
105 );
106 checks.push(check(
107 DoctorCheckId::Config,
108 DoctorCheckCategory::Configuration,
109 DoctorCheckStatus::Pass,
110 true,
111 config_message,
112 None,
113 ));
114
115 let workspace_status = if project.workspace_diagnostics.is_empty() {
116 DoctorCheckStatus::Pass
117 } else {
118 DoctorCheckStatus::Warn
119 };
120 let workspace_count = project.workspaces.len();
121 let workspace_noun = if workspace_count == 1 {
122 "workspace package"
123 } else {
124 "workspace packages"
125 };
126 let mut workspace_message = if project.workspace_diagnostics.is_empty() {
127 format!("Workspace discovery completed ({workspace_count} {workspace_noun}).")
128 } else {
129 let diagnostic_count = project.workspace_diagnostics.len();
130 let diagnostic_noun = if diagnostic_count == 1 {
131 "diagnostic"
132 } else {
133 "diagnostics"
134 };
135 format!(
136 "Workspace discovery completed with {diagnostic_count} {diagnostic_noun}; {workspace_count} {workspace_noun} retained."
137 )
138 };
139 if workspace_status == DoctorCheckStatus::Warn {
140 append_external_config_note(&mut workspace_message, root, project.path.as_deref());
141 }
142 checks.push(check(
143 DoctorCheckId::Workspaces,
144 DoctorCheckCategory::Workspace,
145 workspace_status,
146 false,
147 workspace_message,
148 (workspace_status == DoctorCheckStatus::Warn)
149 .then(|| {
150 remediation_with_config(
151 "fallow workspaces --format json --quiet",
152 root,
153 project.path.as_deref(),
154 )
155 })
156 .flatten(),
157 ));
158
159 checks.push(plugin_check(root, project, configured_plugin_diagnostics));
160
161 checks.push(type_aware_check(
162 root,
163 &project.config.type_aware,
164 discover_companion,
165 ));
166}
167
168fn plugin_check(
169 root: &Path,
170 project: &ProjectConfig,
171 configured_plugin_diagnostics: &[fallow_config::ConfiguredPluginDiagnostic],
172) -> DoctorCheck {
173 if !configured_plugin_diagnostics.is_empty() {
174 let diagnostic_count = configured_plugin_diagnostics.len();
175 let resource_noun = if diagnostic_count == 1 {
176 "resource"
177 } else {
178 "resources"
179 };
180 let mut message = format!(
181 "External plugin configuration contains {diagnostic_count} unresolved configured {resource_noun}."
182 );
183 append_external_config_note(&mut message, root, project.path.as_deref());
184 return check(
185 DoctorCheckId::Plugins,
186 DoctorCheckCategory::Plugin,
187 DoctorCheckStatus::Fail,
188 true,
189 message,
190 remediation_with_config(
191 "fallow plugin-check --format json --quiet",
192 root,
193 project.path.as_deref(),
194 ),
195 );
196 }
197
198 let configured = &project.config.external_plugins;
199 if configured.is_empty() {
200 return check(
201 DoctorCheckId::Plugins,
202 DoctorCheckCategory::Plugin,
203 DoctorCheckStatus::Pass,
204 true,
205 "No external plugins are configured; built-in detection remains available.",
206 None,
207 );
208 }
209
210 let active = configured
211 .iter()
212 .filter(|plugin| external_plugin_is_active(plugin, root, &project.workspaces))
213 .count();
214 let configured_count = configured.len();
215 let status = if active == configured_count {
216 DoctorCheckStatus::Pass
217 } else {
218 DoctorCheckStatus::Warn
219 };
220 let mut message = format!(
221 "External plugin activation evaluated ({active} active of {configured_count} configured)."
222 );
223 if status == DoctorCheckStatus::Warn {
224 append_external_config_note(&mut message, root, project.path.as_deref());
225 }
226 check(
227 DoctorCheckId::Plugins,
228 DoctorCheckCategory::Plugin,
229 status,
230 false,
231 message,
232 (status == DoctorCheckStatus::Warn)
233 .then(|| {
234 remediation_with_config(
235 "fallow plugin-check --format json --quiet",
236 root,
237 project.path.as_deref(),
238 )
239 })
240 .flatten(),
241 )
242}
243
244fn external_plugin_is_active(
245 plugin: &fallow_config::ExternalPluginDef,
246 root: &Path,
247 workspaces: &[fallow_config::WorkspaceInfo],
248) -> bool {
249 std::iter::once(root)
250 .chain(workspaces.iter().map(|workspace| workspace.root.as_path()))
251 .any(|package_root| {
252 let Some(package) = fallow_config::load_dir_package_json(package_root) else {
253 return false;
254 };
255 fallow_engine::plugins::is_external_plugin_active(
256 plugin,
257 &package.all_dependency_names(),
258 package_root,
259 &[],
260 )
261 })
262}
263
264fn push_project_failure_checks(
265 checks: &mut Vec<DoctorCheck>,
266 error: &str,
267 root: &Path,
268 config_path: Option<&Path>,
269) {
270 if error.starts_with("invalid external plugin definition") {
271 checks.push(check(
272 DoctorCheckId::Config,
273 DoctorCheckCategory::Configuration,
274 DoctorCheckStatus::Pass,
275 true,
276 "Configuration parsed, but external plugin validation failed.",
277 None,
278 ));
279 checks.push(skipped(
280 DoctorCheckId::Workspaces,
281 DoctorCheckCategory::Workspace,
282 "Plugin validation failed before workspace discovery.",
283 ));
284 let mut message = "External plugin configuration is invalid.".to_string();
285 append_external_config_note(&mut message, root, config_path);
286 checks.push(check(
287 DoctorCheckId::Plugins,
288 DoctorCheckCategory::Plugin,
289 DoctorCheckStatus::Fail,
290 true,
291 message,
292 remediation_with_config(
293 "fallow plugin-check --format json --quiet",
294 root,
295 config_path,
296 ),
297 ));
298 } else if error.starts_with("root package.json") || error.starts_with("root Deno config") {
299 checks.push(check(
300 DoctorCheckId::Config,
301 DoctorCheckCategory::Configuration,
302 DoctorCheckStatus::Pass,
303 true,
304 "Fallow configuration resolved successfully.",
305 None,
306 ));
307 let mut message = "Root workspace manifest discovery failed.".to_string();
308 append_external_config_note(&mut message, root, config_path);
309 checks.push(check(
310 DoctorCheckId::Workspaces,
311 DoctorCheckCategory::Workspace,
312 DoctorCheckStatus::Fail,
313 true,
314 message,
315 remediation_with_config("fallow workspaces --format json --quiet", root, config_path),
316 ));
317 checks.push(skipped(
318 DoctorCheckId::Plugins,
319 DoctorCheckCategory::Plugin,
320 "Workspace discovery failed before readiness collection completed.",
321 ));
322 } else {
323 let mut message = "Fallow configuration could not be resolved.".to_string();
324 append_external_config_note(&mut message, root, config_path);
325 checks.push(check(
326 DoctorCheckId::Config,
327 DoctorCheckCategory::Configuration,
328 DoctorCheckStatus::Fail,
329 true,
330 message,
331 remediation_with_config("fallow config", root, config_path),
332 ));
333 checks.push(skipped(
334 DoctorCheckId::Workspaces,
335 DoctorCheckCategory::Workspace,
336 "Configuration readiness failed.",
337 ));
338 checks.push(skipped(
339 DoctorCheckId::Plugins,
340 DoctorCheckCategory::Plugin,
341 "Configuration readiness failed.",
342 ));
343 }
344 checks.push(skipped(
345 DoctorCheckId::TypeAware,
346 DoctorCheckCategory::Companion,
347 "Configuration readiness did not establish whether type-aware analysis is enabled.",
348 ));
349}
350
351fn type_aware_check<F>(
352 root: &Path,
353 config: &fallow_config::TypeAwareConfig,
354 discover_companion: &F,
355) -> DoctorCheck
356where
357 F: Fn(&Path) -> Result<(), String>,
358{
359 let (enabled, require) = match effective_type_aware_config(config) {
360 Ok(effective) => effective,
361 Err(message) => {
362 return check(
363 DoctorCheckId::TypeAware,
364 DoctorCheckCategory::Companion,
365 DoctorCheckStatus::Fail,
366 true,
367 message,
368 None,
369 );
370 }
371 };
372 if !enabled {
373 return skipped(
374 DoctorCheckId::TypeAware,
375 DoctorCheckCategory::Companion,
376 "Type-aware analysis is not enabled.",
377 );
378 }
379
380 match discover_companion(root) {
381 Ok(()) => check(
382 DoctorCheckId::TypeAware,
383 DoctorCheckCategory::Companion,
384 DoctorCheckStatus::Pass,
385 require == fallow_config::TypeAwareRequire::Complete,
386 "A trusted type-aware companion is discoverable without starting it.",
387 None,
388 ),
389 Err(_) => {
390 let required = require == fallow_config::TypeAwareRequire::Complete;
391 check(
392 DoctorCheckId::TypeAware,
393 DoctorCheckCategory::Companion,
394 if required {
395 DoctorCheckStatus::Fail
396 } else {
397 DoctorCheckStatus::Warn
398 },
399 required,
400 "Type-aware analysis is enabled, but no trusted companion is discoverable.",
401 Some(remediation(
402 &format!(
403 "npm install --save-dev fallow-type-aware@{}",
404 env!("CARGO_PKG_VERSION")
405 ),
406 true,
407 )),
408 )
409 }
410 }
411}
412
413fn effective_type_aware_config(
414 config: &fallow_config::TypeAwareConfig,
415) -> Result<(bool, fallow_config::TypeAwareRequire), &'static str> {
416 let enabled = match std::env::var("FALLOW_TYPE_AWARE") {
417 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
418 "1" | "true" | "yes" | "on" => true,
419 "0" | "false" | "no" | "off" => false,
420 _ => return Err("FALLOW_TYPE_AWARE must contain a supported boolean value."),
421 },
422 Err(std::env::VarError::NotPresent) => config.enabled,
423 Err(std::env::VarError::NotUnicode(_)) => {
424 return Err("FALLOW_TYPE_AWARE must contain valid UTF-8.");
425 }
426 };
427 let require = match std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
428 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
429 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
430 "complete" => fallow_config::TypeAwareRequire::Complete,
431 _ => return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete."),
432 },
433 Err(std::env::VarError::NotPresent) => config.require,
434 Err(std::env::VarError::NotUnicode(_)) => {
435 return Err("FALLOW_TYPE_AWARE_REQUIRE must contain valid UTF-8.");
436 }
437 };
438 Ok((enabled, require))
439}
440
441fn remediation(command: &str, mutating: bool) -> DoctorRemediation {
442 DoctorRemediation {
443 command: command.to_string(),
444 cwd: ".".to_string(),
445 mutating,
446 }
447}
448
449fn remediation_with_config(
450 command: &str,
451 root: &Path,
452 config_path: Option<&Path>,
453) -> Option<DoctorRemediation> {
454 let command = match config_path {
455 None => command.to_string(),
456 Some(path) => format!("{command} --config={}", safe_config_argument(root, path)?),
457 };
458 Some(remediation(&command, false))
459}
460
461fn safe_config_argument(root: &Path, config_path: &Path) -> Option<String> {
462 let canonical_root = dunce::canonicalize(root).ok()?;
463 let canonical = canonicalize_with_missing_suffix(config_path)?;
464 let relative = relative_path(&canonical_root, &canonical)?;
465 relative
466 .bytes()
467 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
468 .then_some(relative)
469}
470
471fn canonicalize_with_missing_suffix(path: &Path) -> Option<PathBuf> {
472 let mut ancestor = path;
473 let mut suffix = Vec::new();
474
475 loop {
476 match dunce::canonicalize(ancestor) {
477 Ok(mut canonical) => {
478 if !suffix.is_empty() && !canonical.is_dir() {
479 return None;
480 }
481 for component in suffix.into_iter().rev() {
482 canonical.push(component);
483 }
484 return Some(canonical);
485 }
486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
487 match std::fs::symlink_metadata(ancestor) {
488 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
489 _ => return None,
490 }
491 let std::path::Component::Normal(component) = ancestor.components().next_back()?
492 else {
493 return None;
494 };
495 suffix.push(component.to_os_string());
496 ancestor = ancestor.parent()?;
497 }
498 Err(_) => return None,
499 }
500 }
501}
502
503fn append_external_config_note(message: &mut String, root: &Path, config_path: Option<&Path>) {
504 if config_path.is_some_and(|path| safe_config_argument(root, path).is_none()) {
505 message.push_str(" Repeat this diagnostic with the same explicit --config value.");
506 }
507}
508
509fn push_prerequisite_skips(checks: &mut Vec<DoctorCheck>, message: &str) {
510 for (id, category) in [
511 (DoctorCheckId::Config, DoctorCheckCategory::Configuration),
512 (DoctorCheckId::Workspaces, DoctorCheckCategory::Workspace),
513 (DoctorCheckId::Plugins, DoctorCheckCategory::Plugin),
514 (DoctorCheckId::TypeAware, DoctorCheckCategory::Companion),
515 ] {
516 checks.push(skipped(id, category, message));
517 }
518}
519
520fn skipped(id: DoctorCheckId, category: DoctorCheckCategory, message: &str) -> DoctorCheck {
521 check(
522 id,
523 category,
524 DoctorCheckStatus::Skipped,
525 false,
526 message,
527 None,
528 )
529}
530
531fn check(
532 id: DoctorCheckId,
533 category: DoctorCheckCategory,
534 status: DoctorCheckStatus,
535 required: bool,
536 message: impl Into<String>,
537 remediation: Option<DoctorRemediation>,
538) -> DoctorCheck {
539 DoctorCheck {
540 id,
541 category,
542 status,
543 required,
544 message: message.into(),
545 remediation,
546 }
547}
548
549fn build_output(checks: Vec<DoctorCheck>) -> DoctorOutput {
550 let summary = checks
551 .iter()
552 .fold(DoctorSummary::default(), |mut summary, check| {
553 match check.status {
554 DoctorCheckStatus::Pass => summary.pass += 1,
555 DoctorCheckStatus::Warn => summary.warn += 1,
556 DoctorCheckStatus::Fail => summary.fail += 1,
557 DoctorCheckStatus::Skipped => summary.skipped += 1,
558 }
559 summary
560 });
561 let status = if checks
562 .iter()
563 .any(|check| check.required && check.status == DoctorCheckStatus::Fail)
564 {
565 DoctorStatus::Fail
566 } else if summary.warn > 0 {
567 DoctorStatus::Warn
568 } else {
569 DoctorStatus::Pass
570 };
571 DoctorOutput {
572 schema_version: SchemaVersion(DOCTOR_SCHEMA_VERSION),
573 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
574 root: ".".to_string(),
575 status,
576 summary,
577 checks,
578 }
579}
580
581fn relative_path(root: &Path, path: &Path) -> Option<String> {
582 path.strip_prefix(root).ok().map(|path| {
583 let relative = path.to_string_lossy().replace('\\', "/");
584 if relative.is_empty() {
585 ".".to_string()
586 } else {
587 relative
588 }
589 })
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn zero_config_is_ready_with_stable_order() {
598 let root = tempfile::tempdir().expect("temp root");
599 let output = run_doctor_with_discovery(
600 &DoctorOptions {
601 root: root.path(),
602 config_path: None,
603 },
604 &|_| Err("missing companion".to_string()),
605 );
606
607 assert_eq!(output.status, DoctorStatus::Pass);
608 assert_eq!(output.root, ".");
609 assert_eq!(
610 output
611 .checks
612 .iter()
613 .map(|check| check.id)
614 .collect::<Vec<_>>(),
615 vec![
616 DoctorCheckId::Root,
617 DoctorCheckId::Config,
618 DoctorCheckId::Workspaces,
619 DoctorCheckId::Plugins,
620 DoctorCheckId::TypeAware,
621 ]
622 );
623 assert_eq!(
624 output.checks[1].message,
625 "Zero-config defaults resolved successfully."
626 );
627 assert_eq!(output.checks[4].status, DoctorCheckStatus::Skipped);
628 }
629
630 #[test]
631 fn invalid_config_returns_complete_failed_report() {
632 let root = tempfile::tempdir().expect("temp root");
633 std::fs::write(root.path().join(".fallowrc.json"), "{").expect("write invalid config");
634
635 let output = run_doctor_with_discovery(
636 &DoctorOptions {
637 root: root.path(),
638 config_path: None,
639 },
640 &|_| Err("missing companion".to_string()),
641 );
642
643 assert_eq!(output.status, DoctorStatus::Fail);
644 assert_eq!(output.checks.len(), 5);
645 assert_eq!(output.checks[1].status, DoctorCheckStatus::Fail);
646 assert_eq!(output.checks[2].status, DoctorCheckStatus::Skipped);
647 assert!(
648 !output.checks[1]
649 .message
650 .contains(&root.path().display().to_string())
651 );
652 }
653
654 #[test]
655 fn optional_missing_type_aware_companion_warns() {
656 let root = tempfile::tempdir().expect("temp root");
657 std::fs::write(
658 root.path().join(".fallowrc.json"),
659 r#"{"typeAware":{"enabled":true}}"#,
660 )
661 .expect("write config");
662
663 let output = run_doctor_with_discovery(
664 &DoctorOptions {
665 root: root.path(),
666 config_path: None,
667 },
668 &|_| Err("missing companion".to_string()),
669 );
670
671 assert_eq!(output.status, DoctorStatus::Warn);
672 assert_eq!(output.checks[4].status, DoctorCheckStatus::Warn);
673 assert!(!output.checks[4].required);
674 }
675
676 #[test]
677 fn required_missing_type_aware_companion_fails() {
678 let root = tempfile::tempdir().expect("temp root");
679 std::fs::write(
680 root.path().join(".fallowrc.json"),
681 r#"{"typeAware":{"enabled":true,"require":"complete"}}"#,
682 )
683 .expect("write config");
684
685 let output = run_doctor_with_discovery(
686 &DoctorOptions {
687 root: root.path(),
688 config_path: None,
689 },
690 &|_| Err("missing companion".to_string()),
691 );
692
693 assert_eq!(output.status, DoctorStatus::Fail);
694 assert_eq!(output.checks[4].status, DoctorCheckStatus::Fail);
695 assert!(output.checks[4].required);
696 assert_eq!(
697 output.checks[4]
698 .remediation
699 .as_ref()
700 .map(|remediation| remediation.mutating),
701 Some(true)
702 );
703 }
704
705 #[test]
706 fn invalid_root_does_not_echo_the_host_path() {
707 let missing = Path::new("/definitely/missing/fallow-doctor-private-root");
708 let output = run_doctor(&DoctorOptions {
709 root: missing,
710 config_path: None,
711 });
712
713 assert_eq!(output.status, DoctorStatus::Fail);
714 assert_eq!(output.root, ".");
715 assert!(output.checks[0].message.contains("--root"));
716 assert_eq!(
717 output.checks[0].message,
718 "Project root is not accessible. Set --root to an existing, readable directory."
719 );
720 assert!(
721 output
722 .checks
723 .iter()
724 .all(|check| !check.message.contains("fallow-doctor-private-root"))
725 );
726 }
727
728 #[test]
729 fn external_config_does_not_echo_its_host_path() {
730 let root = tempfile::tempdir().expect("temp root");
731 let config_dir = tempfile::tempdir().expect("temp config dir");
732 let config_path = config_dir.path().join("external.fallowrc.json");
733 std::fs::write(&config_path, "{}").expect("write config");
734
735 let output = run_doctor(&DoctorOptions {
736 root: root.path(),
737 config_path: Some(&config_path),
738 });
739
740 assert_eq!(output.status, DoctorStatus::Pass);
741 assert_eq!(
742 output.checks[1].message,
743 "The explicitly selected configuration resolved successfully."
744 );
745 assert!(
746 !output.checks[1]
747 .message
748 .contains(&config_dir.path().display().to_string())
749 );
750 }
751
752 #[test]
753 fn parent_relative_external_config_stays_private() {
754 let sandbox = tempfile::tempdir().expect("temp sandbox");
755 let root = sandbox.path().join("project");
756 std::fs::create_dir(&root).expect("create project root");
757 let config_path = root.join("../customer-secret.json");
758 std::fs::write(&config_path, "{}").expect("write config");
759
760 let output = run_doctor(&DoctorOptions {
761 root: &root,
762 config_path: Some(&config_path),
763 });
764
765 assert_eq!(output.status, DoctorStatus::Pass);
766 assert_eq!(
767 output.checks[1].message,
768 "The explicitly selected configuration resolved successfully."
769 );
770 assert!(!output.checks[1].message.contains("customer-secret"));
771 }
772
773 #[cfg(unix)]
774 #[test]
775 fn successful_config_below_external_symlink_stays_private() {
776 let root = tempfile::tempdir().expect("temp root");
777 let external = tempfile::tempdir().expect("external root");
778 std::fs::write(external.path().join("config.json"), "{}").expect("write config");
779 std::os::unix::fs::symlink(external.path(), root.path().join("external"))
780 .expect("create external symlink");
781 let config_path = root.path().join("external/config.json");
782
783 let output = run_doctor(&DoctorOptions {
784 root: root.path(),
785 config_path: Some(&config_path),
786 });
787
788 assert_eq!(output.status, DoctorStatus::Pass);
789 assert_eq!(
790 output.checks[1].message,
791 "The explicitly selected configuration resolved successfully."
792 );
793 assert!(!output.checks[1].message.contains("external/config.json"));
794 }
795
796 #[test]
797 fn relative_explicit_config_is_preserved_in_remediation() {
798 let root = tempfile::tempdir().expect("temp root");
799 let config_path = root.path().join("custom.json");
800 std::fs::write(&config_path, "{").expect("write invalid config");
801
802 let output = run_doctor(&DoctorOptions {
803 root: root.path(),
804 config_path: Some(&config_path),
805 });
806
807 assert_eq!(output.status, DoctorStatus::Fail);
808 assert_eq!(
809 output.checks[1]
810 .remediation
811 .as_ref()
812 .map(|remediation| remediation.command.as_str()),
813 Some("fallow config --config=custom.json")
814 );
815 }
816
817 #[test]
818 fn nested_missing_project_relative_config_is_preserved_in_remediation() {
819 let root = tempfile::tempdir().expect("temp root");
820 let config_path = root.path().join("missing-dir/missing.json");
821
822 let output = run_doctor(&DoctorOptions {
823 root: root.path(),
824 config_path: Some(&config_path),
825 });
826
827 assert_eq!(output.status, DoctorStatus::Fail);
828 assert_eq!(
829 output.checks[1]
830 .remediation
831 .as_ref()
832 .map(|remediation| remediation.command.as_str()),
833 Some("fallow config --config=missing-dir/missing.json")
834 );
835 assert!(
836 !output.checks[1]
837 .message
838 .contains("same explicit --config value")
839 );
840 }
841
842 #[test]
843 fn leading_dash_config_name_is_bound_to_its_option() {
844 let root = tempfile::tempdir().expect("temp root");
845 let config_path = root.path().join("-missing.json");
846
847 let output = run_doctor(&DoctorOptions {
848 root: root.path(),
849 config_path: Some(&config_path),
850 });
851
852 assert_eq!(
853 output.checks[1]
854 .remediation
855 .as_ref()
856 .map(|remediation| remediation.command.as_str()),
857 Some("fallow config --config=-missing.json")
858 );
859 }
860
861 #[test]
862 fn config_path_equal_to_root_never_renders_an_empty_argument() {
863 let root = tempfile::tempdir().expect("temp root");
864
865 let output = run_doctor(&DoctorOptions {
866 root: root.path(),
867 config_path: Some(root.path()),
868 });
869
870 let command = output.checks[1]
871 .remediation
872 .as_ref()
873 .map(|remediation| remediation.command.as_str());
874 assert_eq!(command, Some("fallow config --config=."));
875 assert_ne!(command, Some("fallow config --config="));
876 }
877
878 #[test]
879 fn missing_config_traversal_outside_root_stays_private() {
880 let sandbox = tempfile::tempdir().expect("temp sandbox");
881 let root = sandbox.path().join("project");
882 std::fs::create_dir(&root).expect("create project root");
883 let config_path = root.join("../missing.json");
884
885 let output = run_doctor(&DoctorOptions {
886 root: &root,
887 config_path: Some(&config_path),
888 });
889
890 assert_eq!(output.status, DoctorStatus::Fail);
891 assert!(output.checks[1].remediation.is_none());
892 assert!(
893 output.checks[1]
894 .message
895 .contains("same explicit --config value")
896 );
897 assert!(!output.checks[1].message.contains("missing.json"));
898 }
899
900 #[cfg(unix)]
901 #[test]
902 fn missing_config_below_external_symlink_stays_private() {
903 let root = tempfile::tempdir().expect("temp root");
904 let external = tempfile::tempdir().expect("external root");
905 std::os::unix::fs::symlink(external.path(), root.path().join("external"))
906 .expect("create external symlink");
907 let config_path = root.path().join("external/missing-dir/missing.json");
908
909 let output = run_doctor(&DoctorOptions {
910 root: root.path(),
911 config_path: Some(&config_path),
912 });
913
914 assert_eq!(output.status, DoctorStatus::Fail);
915 assert!(output.checks[1].remediation.is_none());
916 assert!(
917 output.checks[1]
918 .message
919 .contains("same explicit --config value")
920 );
921 assert!(!output.checks[1].message.contains("missing-dir"));
922 }
923
924 #[test]
925 fn failed_external_config_requires_reusing_the_private_value() {
926 let root = tempfile::tempdir().expect("temp root");
927 let config_dir = tempfile::tempdir().expect("temp config dir");
928 let config_path = config_dir.path().join("external.json");
929 std::fs::write(&config_path, "{").expect("write invalid config");
930
931 let output = run_doctor(&DoctorOptions {
932 root: root.path(),
933 config_path: Some(&config_path),
934 });
935
936 assert_eq!(output.status, DoctorStatus::Fail);
937 assert!(output.checks[1].remediation.is_none());
938 assert!(
939 output.checks[1]
940 .message
941 .contains("same explicit --config value")
942 );
943 assert!(
944 !output.checks[1]
945 .message
946 .contains(&config_dir.path().display().to_string())
947 );
948 }
949
950 #[test]
951 fn missing_explicit_plugin_is_a_required_failure() {
952 let root = tempfile::tempdir().expect("temp root");
953 std::fs::write(
954 root.path().join(".fallowrc.json"),
955 r#"{"plugins":["missing-plugin.json"]}"#,
956 )
957 .expect("write config");
958
959 let output = run_doctor(&DoctorOptions {
960 root: root.path(),
961 config_path: None,
962 });
963
964 assert_eq!(output.status, DoctorStatus::Fail);
965 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
966 assert!(output.checks[3].required);
967 assert!(
968 output.checks[3]
969 .message
970 .contains("1 unresolved configured resource")
971 );
972 assert!(!output.checks[3].message.contains("missing-plugin.json"));
973 }
974
975 #[test]
976 fn malformed_explicit_plugin_is_a_required_failure() {
977 let root = tempfile::tempdir().expect("temp root");
978 std::fs::write(
979 root.path().join(".fallowrc.json"),
980 r#"{"plugins":["broken.json"]}"#,
981 )
982 .expect("write config");
983 std::fs::write(root.path().join("broken.json"), "{").expect("write plugin");
984
985 let output = run_doctor(&DoctorOptions {
986 root: root.path(),
987 config_path: None,
988 });
989
990 assert_eq!(output.status, DoctorStatus::Fail);
991 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
992 assert!(output.checks[3].required);
993 assert!(
994 output.checks[3]
995 .message
996 .contains("1 unresolved configured resource")
997 );
998 assert!(!output.checks[3].message.contains("broken.json"));
999 }
1000
1001 #[test]
1002 fn explicit_plugin_directory_without_definitions_is_a_required_failure() {
1003 let root = tempfile::tempdir().expect("temp root");
1004 std::fs::create_dir(root.path().join("plugins")).expect("create plugin directory");
1005 std::fs::write(
1006 root.path().join(".fallowrc.json"),
1007 r#"{"plugins":["plugins"]}"#,
1008 )
1009 .expect("write config");
1010
1011 let output = run_doctor(&DoctorOptions {
1012 root: root.path(),
1013 config_path: None,
1014 });
1015
1016 assert_eq!(output.status, DoctorStatus::Fail);
1017 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
1018 assert!(output.checks[3].required);
1019 assert!(
1020 output.checks[3]
1021 .message
1022 .contains("1 unresolved configured resource")
1023 );
1024 }
1025
1026 #[test]
1027 fn inactive_external_plugin_warns_with_project_root_remediation() {
1028 let root = tempfile::tempdir().expect("temp root");
1029 std::fs::write(
1030 root.path().join("package.json"),
1031 r#"{"name":"doctor-test"}"#,
1032 )
1033 .expect("write package manifest");
1034 std::fs::write(
1035 root.path().join("fallow-plugin-doctor.json"),
1036 r#"{"name":"doctor-plugin","enablers":["missing-framework"]}"#,
1037 )
1038 .expect("write plugin");
1039
1040 let output = run_doctor_with_discovery(
1041 &DoctorOptions {
1042 root: root.path(),
1043 config_path: None,
1044 },
1045 &|_| Err("missing companion".to_string()),
1046 );
1047
1048 assert_eq!(output.status, DoctorStatus::Warn);
1049 assert_eq!(output.checks[3].status, DoctorCheckStatus::Warn);
1050 assert!(
1051 output.checks[3]
1052 .message
1053 .contains("0 active of 1 configured")
1054 );
1055 assert_eq!(
1056 output.checks[3]
1057 .remediation
1058 .as_ref()
1059 .map(|remediation| (remediation.cwd.as_str(), remediation.mutating)),
1060 Some((".", false))
1061 );
1062 }
1063
1064 #[test]
1065 fn active_external_plugin_passes() {
1066 let root = tempfile::tempdir().expect("temp root");
1067 std::fs::write(
1068 root.path().join("package.json"),
1069 r#"{"name":"doctor-test","dependencies":{"doctor-framework":"1.0.0"}}"#,
1070 )
1071 .expect("write package manifest");
1072 std::fs::write(
1073 root.path().join("fallow-plugin-doctor.json"),
1074 r#"{"name":"doctor-plugin","enablers":["doctor-framework"]}"#,
1075 )
1076 .expect("write plugin");
1077
1078 let output = run_doctor_with_discovery(
1079 &DoctorOptions {
1080 root: root.path(),
1081 config_path: None,
1082 },
1083 &|_| Err("missing companion".to_string()),
1084 );
1085
1086 assert_eq!(output.status, DoctorStatus::Pass);
1087 assert_eq!(output.checks[3].status, DoctorCheckStatus::Pass);
1088 assert!(
1089 output.checks[3]
1090 .message
1091 .contains("1 active of 1 configured")
1092 );
1093 }
1094}