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
29#[must_use]
36pub fn run_doctor_with_cache_dir(
37 options: &DoctorOptions<'_>,
38 cache_dir: Option<&Path>,
39) -> DoctorOutput {
40 run_doctor_with_cache_dir_and_discovery(
41 options,
42 cache_dir,
43 &crate::type_aware::discover_companion,
44 )
45}
46
47fn run_doctor_with_discovery<F>(options: &DoctorOptions<'_>, discover_companion: &F) -> DoctorOutput
48where
49 F: Fn(&Path) -> Result<(), String>,
50{
51 run_doctor_with_cache_dir_and_discovery(options, None, discover_companion)
52}
53
54fn run_doctor_with_cache_dir_and_discovery<F>(
55 options: &DoctorOptions<'_>,
56 cache_dir: Option<&Path>,
57 discover_companion: &F,
58) -> DoctorOutput
59where
60 F: Fn(&Path) -> Result<(), String>,
61{
62 let mut checks = Vec::with_capacity(7);
63 let root = match fallow_engine::validate::validate_root(options.root) {
64 Ok(root) => {
65 checks.push(check(
66 DoctorCheckId::Root,
67 DoctorCheckCategory::Project,
68 DoctorCheckStatus::Pass,
69 true,
70 "Project root is an accessible directory.",
71 None,
72 ));
73 root
74 }
75 Err(_) => {
76 checks.push(check(
77 DoctorCheckId::Root,
78 DoctorCheckCategory::Project,
79 DoctorCheckStatus::Fail,
80 true,
81 "Project root is not accessible. Set --root to an existing, readable directory.",
82 None,
83 ));
84 push_prerequisite_skips(&mut checks, "Project root readiness failed.");
85 return build_output(checks);
86 }
87 };
88
89 let project = config_for_project_readiness(
90 &root,
91 options.config_path,
92 ProjectConfigOptions {
93 output: fallow_config::OutputFormat::Json,
94 no_cache: true,
95 threads: 1,
96 production_override: None,
97 quiet: true,
98 analysis: fallow_config::ProductionAnalysis::DeadCode,
99 allow_remote_extends: false,
100 },
101 );
102
103 match project {
104 Ok(mut readiness) => {
105 if let Some(path) = cache_dir.filter(|path| !path.as_os_str().is_empty()) {
106 readiness.project.config.cache_dir = if path.is_absolute() {
107 path.to_path_buf()
108 } else {
109 root.join(path)
110 };
111 }
112 push_ready_project_checks(
113 &mut checks,
114 &root,
115 &readiness.project,
116 &readiness.configured_plugin_diagnostics,
117 discover_companion,
118 );
119 }
120 Err(error) => {
121 push_project_failure_checks(&mut checks, error.message(), &root, options.config_path);
122 }
123 }
124
125 build_output(checks)
126}
127
128fn push_ready_project_checks<F>(
129 checks: &mut Vec<DoctorCheck>,
130 root: &Path,
131 project: &ProjectConfig,
132 configured_plugin_diagnostics: &[fallow_config::ConfiguredPluginDiagnostic],
133 discover_companion: &F,
134) where
135 F: Fn(&Path) -> Result<(), String>,
136{
137 let config_message = project.path.as_ref().map_or_else(
138 || "Zero-config defaults resolved successfully.".to_string(),
139 |path| match safe_config_argument(root, path) {
140 Some(path) => format!("Configuration resolved from {path}."),
141 None => "The explicitly selected configuration resolved successfully.".to_string(),
142 },
143 );
144 checks.push(check(
145 DoctorCheckId::Config,
146 DoctorCheckCategory::Configuration,
147 DoctorCheckStatus::Pass,
148 true,
149 config_message,
150 None,
151 ));
152
153 let workspace_status = if project.workspace_diagnostics.is_empty() {
154 DoctorCheckStatus::Pass
155 } else {
156 DoctorCheckStatus::Warn
157 };
158 let workspace_count = project.workspaces.len();
159 let workspace_noun = if workspace_count == 1 {
160 "workspace package"
161 } else {
162 "workspace packages"
163 };
164 let mut workspace_message = if project.workspace_diagnostics.is_empty() {
165 format!("Workspace discovery completed ({workspace_count} {workspace_noun}).")
166 } else {
167 let diagnostic_count = project.workspace_diagnostics.len();
168 let diagnostic_noun = if diagnostic_count == 1 {
169 "diagnostic"
170 } else {
171 "diagnostics"
172 };
173 format!(
174 "Workspace discovery completed with {diagnostic_count} {diagnostic_noun}; {workspace_count} {workspace_noun} retained."
175 )
176 };
177 if workspace_status == DoctorCheckStatus::Warn {
178 append_external_config_note(&mut workspace_message, root, project.path.as_deref());
179 }
180 checks.push(check(
181 DoctorCheckId::Workspaces,
182 DoctorCheckCategory::Workspace,
183 workspace_status,
184 false,
185 workspace_message,
186 (workspace_status == DoctorCheckStatus::Warn)
187 .then(|| {
188 remediation_with_config(
189 "fallow workspaces --format json --quiet",
190 root,
191 project.path.as_deref(),
192 )
193 })
194 .flatten(),
195 ));
196
197 checks.push(plugin_check(root, project, configured_plugin_diagnostics));
198
199 checks.push(type_aware_check(
200 root,
201 &project.config.type_aware,
202 discover_companion,
203 ));
204
205 checks.push(dependencies_check(root));
206 checks.push(cache_check(&project.config));
207 checks.push(graph_cache_check(&project.config));
208}
209
210fn dependencies_check(root: &Path) -> DoctorCheck {
218 if !fallow_config::node_modules_missing(root) {
219 return check(
220 DoctorCheckId::Dependencies,
221 DoctorCheckCategory::Project,
222 DoctorCheckStatus::Pass,
223 false,
224 "Dependencies are installed, or the project runs without a node_modules directory.",
225 None,
226 );
227 }
228 check(
229 DoctorCheckId::Dependencies,
230 DoctorCheckCategory::Project,
231 DoctorCheckStatus::Warn,
232 false,
233 "No node_modules directory. Package exports, plugin activation, and dependency \
234 classification degrade until dependencies are installed.",
235 Some(remediation("npm install", true)),
236 )
237}
238
239fn cache_check(config: &fallow_config::ResolvedConfig) -> DoctorCheck {
246 let status = fallow_engine::cache_status::inspect_parse_cache(config);
247 let size = status
248 .size_bytes
249 .map_or_else(String::new, |bytes| format!(" ({})", format_size_mb(bytes)));
250 match status.rejection {
251 None => check(
252 DoctorCheckId::Cache,
253 DoctorCheckCategory::Cache,
254 DoctorCheckStatus::Pass,
255 false,
256 format!("Extraction cache is reusable{size}."),
257 None,
258 ),
259 Some(fallow_types::cache_rejection::CacheRejection::Absent) => check(
260 DoctorCheckId::Cache,
261 DoctorCheckCategory::Cache,
262 DoctorCheckStatus::Pass,
263 false,
264 "No extraction cache yet; the next run writes one.",
265 None,
266 ),
267 Some(rejection) => check(
268 DoctorCheckId::Cache,
269 DoctorCheckCategory::Cache,
270 DoctorCheckStatus::Warn,
271 false,
272 format!(
273 "Extraction cache{size} would not be reused: {}. The next run parses every file.",
274 rejection.describe()
275 ),
276 Some(remediation("fallow dead-code --quiet", false)),
277 ),
278 }
279}
280
281fn graph_cache_check(config: &fallow_config::ResolvedConfig) -> DoctorCheck {
293 let status = fallow_engine::cache_status::inspect_graph_cache(config);
294 let size = status
295 .size_bytes
296 .map_or_else(String::new, |bytes| format!(" ({})", format_size_mb(bytes)));
297 match status.rejection {
298 None => check(
299 DoctorCheckId::GraphCache,
300 DoctorCheckCategory::Cache,
301 DoctorCheckStatus::Pass,
302 false,
303 format!(
304 "Module-graph cache{size} loads; a run reuses it when the analysed files and \
305 options are unchanged."
306 ),
307 None,
308 ),
309 Some(fallow_types::cache_rejection::CacheRejection::Absent) => check(
310 DoctorCheckId::GraphCache,
311 DoctorCheckCategory::Cache,
312 DoctorCheckStatus::Pass,
313 false,
314 "No module-graph cache yet; the next run writes one.",
315 None,
316 ),
317 Some(rejection) => check(
318 DoctorCheckId::GraphCache,
319 DoctorCheckCategory::Cache,
320 DoctorCheckStatus::Warn,
321 false,
322 format!(
323 "Module-graph cache{size} would not be reused: {}. The next run resolves imports \
324 and rebuilds the graph.",
325 rejection.describe()
326 ),
327 Some(remediation("fallow dead-code --quiet", false)),
328 ),
329 }
330}
331
332fn format_size_mb(bytes: u64) -> String {
338 #[expect(
339 clippy::cast_precision_loss,
340 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
341 )]
342 let scaled = bytes as f64;
343 if bytes >= 1024 * 1024 {
344 format!("{:.1} MB", scaled / (1024.0 * 1024.0))
345 } else if bytes >= 1024 {
346 format!("{:.1} KB", scaled / 1024.0)
347 } else {
348 format!("{bytes} bytes")
349 }
350}
351
352fn plugin_check(
353 root: &Path,
354 project: &ProjectConfig,
355 configured_plugin_diagnostics: &[fallow_config::ConfiguredPluginDiagnostic],
356) -> DoctorCheck {
357 if !configured_plugin_diagnostics.is_empty() {
358 let diagnostic_count = configured_plugin_diagnostics.len();
359 let resource_noun = if diagnostic_count == 1 {
360 "resource"
361 } else {
362 "resources"
363 };
364 let mut message = format!(
365 "External plugin configuration contains {diagnostic_count} unresolved configured {resource_noun}."
366 );
367 append_external_config_note(&mut message, root, project.path.as_deref());
368 return check(
369 DoctorCheckId::Plugins,
370 DoctorCheckCategory::Plugin,
371 DoctorCheckStatus::Fail,
372 true,
373 message,
374 remediation_with_config(
375 "fallow plugin-check --format json --quiet",
376 root,
377 project.path.as_deref(),
378 ),
379 );
380 }
381
382 let configured = &project.config.external_plugins;
383 if configured.is_empty() {
384 return check(
385 DoctorCheckId::Plugins,
386 DoctorCheckCategory::Plugin,
387 DoctorCheckStatus::Pass,
388 true,
389 "No external plugins are configured; built-in detection remains available.",
390 None,
391 );
392 }
393
394 let active = configured
395 .iter()
396 .filter(|plugin| external_plugin_is_active(plugin, root, &project.workspaces))
397 .count();
398 let configured_count = configured.len();
399 let status = if active == configured_count {
400 DoctorCheckStatus::Pass
401 } else {
402 DoctorCheckStatus::Warn
403 };
404 let mut message = format!(
405 "External plugin activation evaluated ({active} active of {configured_count} configured)."
406 );
407 if status == DoctorCheckStatus::Warn {
408 append_external_config_note(&mut message, root, project.path.as_deref());
409 }
410 check(
411 DoctorCheckId::Plugins,
412 DoctorCheckCategory::Plugin,
413 status,
414 false,
415 message,
416 (status == DoctorCheckStatus::Warn)
417 .then(|| {
418 remediation_with_config(
419 "fallow plugin-check --format json --quiet",
420 root,
421 project.path.as_deref(),
422 )
423 })
424 .flatten(),
425 )
426}
427
428fn external_plugin_is_active(
429 plugin: &fallow_config::ExternalPluginDef,
430 root: &Path,
431 workspaces: &[fallow_config::WorkspaceInfo],
432) -> bool {
433 std::iter::once(root)
434 .chain(workspaces.iter().map(|workspace| workspace.root.as_path()))
435 .any(|package_root| {
436 let Some(package) = fallow_config::load_dir_package_json(package_root) else {
437 return false;
438 };
439 fallow_engine::plugins::is_external_plugin_active(
440 plugin,
441 &package.all_dependency_names(),
442 package_root,
443 &[],
444 )
445 })
446}
447
448fn push_project_failure_checks(
449 checks: &mut Vec<DoctorCheck>,
450 error: &str,
451 root: &Path,
452 config_path: Option<&Path>,
453) {
454 if error.starts_with("invalid external plugin definition") {
455 checks.push(check(
456 DoctorCheckId::Config,
457 DoctorCheckCategory::Configuration,
458 DoctorCheckStatus::Pass,
459 true,
460 "Configuration parsed, but external plugin validation failed.",
461 None,
462 ));
463 checks.push(skipped(
464 DoctorCheckId::Workspaces,
465 DoctorCheckCategory::Workspace,
466 "Plugin validation failed before workspace discovery.",
467 ));
468 let mut message = "External plugin configuration is invalid.".to_string();
469 append_external_config_note(&mut message, root, config_path);
470 checks.push(check(
471 DoctorCheckId::Plugins,
472 DoctorCheckCategory::Plugin,
473 DoctorCheckStatus::Fail,
474 true,
475 message,
476 remediation_with_config(
477 "fallow plugin-check --format json --quiet",
478 root,
479 config_path,
480 ),
481 ));
482 } else if error.starts_with("root package.json") || error.starts_with("root Deno config") {
483 checks.push(check(
484 DoctorCheckId::Config,
485 DoctorCheckCategory::Configuration,
486 DoctorCheckStatus::Pass,
487 true,
488 "Fallow configuration resolved successfully.",
489 None,
490 ));
491 let mut message = "Root workspace manifest discovery failed.".to_string();
492 append_external_config_note(&mut message, root, config_path);
493 checks.push(check(
494 DoctorCheckId::Workspaces,
495 DoctorCheckCategory::Workspace,
496 DoctorCheckStatus::Fail,
497 true,
498 message,
499 remediation_with_config("fallow workspaces --format json --quiet", root, config_path),
500 ));
501 checks.push(skipped(
502 DoctorCheckId::Plugins,
503 DoctorCheckCategory::Plugin,
504 "Workspace discovery failed before readiness collection completed.",
505 ));
506 } else {
507 let mut message = "Fallow configuration could not be resolved.".to_string();
508 append_external_config_note(&mut message, root, config_path);
509 checks.push(check(
510 DoctorCheckId::Config,
511 DoctorCheckCategory::Configuration,
512 DoctorCheckStatus::Fail,
513 true,
514 message,
515 remediation_with_config("fallow config", root, config_path),
516 ));
517 checks.push(skipped(
518 DoctorCheckId::Workspaces,
519 DoctorCheckCategory::Workspace,
520 "Configuration readiness failed.",
521 ));
522 checks.push(skipped(
523 DoctorCheckId::Plugins,
524 DoctorCheckCategory::Plugin,
525 "Configuration readiness failed.",
526 ));
527 }
528 checks.push(skipped(
529 DoctorCheckId::TypeAware,
530 DoctorCheckCategory::Companion,
531 "Configuration readiness did not establish whether type-aware analysis is enabled.",
532 ));
533 checks.push(dependencies_check(root));
534 checks.push(skipped(
535 DoctorCheckId::Cache,
536 DoctorCheckCategory::Cache,
537 "Configuration readiness did not establish which cache this project uses.",
538 ));
539 checks.push(skipped(
540 DoctorCheckId::GraphCache,
541 DoctorCheckCategory::Cache,
542 "Configuration readiness did not establish which cache this project uses.",
543 ));
544}
545
546fn type_aware_check<F>(
547 root: &Path,
548 config: &fallow_config::TypeAwareConfig,
549 discover_companion: &F,
550) -> DoctorCheck
551where
552 F: Fn(&Path) -> Result<(), String>,
553{
554 let (enabled, require) = match effective_type_aware_config(config) {
555 Ok(effective) => effective,
556 Err(message) => {
557 return check(
558 DoctorCheckId::TypeAware,
559 DoctorCheckCategory::Companion,
560 DoctorCheckStatus::Fail,
561 true,
562 message,
563 None,
564 );
565 }
566 };
567 if !enabled {
568 return skipped(
569 DoctorCheckId::TypeAware,
570 DoctorCheckCategory::Companion,
571 "Type-aware analysis is not enabled.",
572 );
573 }
574
575 match discover_companion(root) {
576 Ok(()) => check(
577 DoctorCheckId::TypeAware,
578 DoctorCheckCategory::Companion,
579 DoctorCheckStatus::Pass,
580 require == fallow_config::TypeAwareRequire::Complete,
581 "A trusted type-aware companion is discoverable without starting it.",
582 None,
583 ),
584 Err(_) => {
585 let required = require == fallow_config::TypeAwareRequire::Complete;
586 check(
587 DoctorCheckId::TypeAware,
588 DoctorCheckCategory::Companion,
589 if required {
590 DoctorCheckStatus::Fail
591 } else {
592 DoctorCheckStatus::Warn
593 },
594 required,
595 "Type-aware analysis is enabled, but no trusted companion is discoverable.",
596 Some(remediation(
597 &format!(
598 "npm install --save-dev fallow-type-aware@{}",
599 env!("CARGO_PKG_VERSION")
600 ),
601 true,
602 )),
603 )
604 }
605 }
606}
607
608fn effective_type_aware_config(
609 config: &fallow_config::TypeAwareConfig,
610) -> Result<(bool, fallow_config::TypeAwareRequire), &'static str> {
611 let enabled = match std::env::var("FALLOW_TYPE_AWARE") {
612 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
613 "1" | "true" | "yes" | "on" => true,
614 "0" | "false" | "no" | "off" => false,
615 _ => return Err("FALLOW_TYPE_AWARE must contain a supported boolean value."),
616 },
617 Err(std::env::VarError::NotPresent) => config.enabled,
618 Err(std::env::VarError::NotUnicode(_)) => {
619 return Err("FALLOW_TYPE_AWARE must contain valid UTF-8.");
620 }
621 };
622 let require = match std::env::var("FALLOW_TYPE_AWARE_REQUIRE") {
623 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
624 "best-effort" => fallow_config::TypeAwareRequire::BestEffort,
625 "complete" => fallow_config::TypeAwareRequire::Complete,
626 _ => return Err("FALLOW_TYPE_AWARE_REQUIRE must be best-effort or complete."),
627 },
628 Err(std::env::VarError::NotPresent) => config.require,
629 Err(std::env::VarError::NotUnicode(_)) => {
630 return Err("FALLOW_TYPE_AWARE_REQUIRE must contain valid UTF-8.");
631 }
632 };
633 Ok((enabled, require))
634}
635
636fn remediation(command: &str, mutating: bool) -> DoctorRemediation {
637 DoctorRemediation {
638 command: command.to_string(),
639 cwd: ".".to_string(),
640 mutating,
641 }
642}
643
644fn remediation_with_config(
645 command: &str,
646 root: &Path,
647 config_path: Option<&Path>,
648) -> Option<DoctorRemediation> {
649 let command = match config_path {
650 None => command.to_string(),
651 Some(path) => format!("{command} --config={}", safe_config_argument(root, path)?),
652 };
653 Some(remediation(&command, false))
654}
655
656fn safe_config_argument(root: &Path, config_path: &Path) -> Option<String> {
657 let canonical_root = dunce::canonicalize(root).ok()?;
658 let canonical = canonicalize_with_missing_suffix(config_path)?;
659 let relative = relative_path(&canonical_root, &canonical)?;
660 relative
661 .bytes()
662 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
663 .then_some(relative)
664}
665
666fn canonicalize_with_missing_suffix(path: &Path) -> Option<PathBuf> {
667 let mut ancestor = path;
668 let mut suffix = Vec::new();
669
670 loop {
671 match dunce::canonicalize(ancestor) {
672 Ok(mut canonical) => {
673 if !suffix.is_empty() && !canonical.is_dir() {
674 return None;
675 }
676 for component in suffix.into_iter().rev() {
677 canonical.push(component);
678 }
679 return Some(canonical);
680 }
681 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
682 match std::fs::symlink_metadata(ancestor) {
683 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
684 _ => return None,
685 }
686 let std::path::Component::Normal(component) = ancestor.components().next_back()?
687 else {
688 return None;
689 };
690 suffix.push(component.to_os_string());
691 ancestor = ancestor.parent()?;
692 }
693 Err(_) => return None,
694 }
695 }
696}
697
698fn append_external_config_note(message: &mut String, root: &Path, config_path: Option<&Path>) {
699 if config_path.is_some_and(|path| safe_config_argument(root, path).is_none()) {
700 message.push_str(" Repeat this diagnostic with the same explicit --config value.");
701 }
702}
703
704fn push_prerequisite_skips(checks: &mut Vec<DoctorCheck>, message: &str) {
705 for (id, category) in [
706 (DoctorCheckId::Config, DoctorCheckCategory::Configuration),
707 (DoctorCheckId::Workspaces, DoctorCheckCategory::Workspace),
708 (DoctorCheckId::Plugins, DoctorCheckCategory::Plugin),
709 (DoctorCheckId::TypeAware, DoctorCheckCategory::Companion),
710 (DoctorCheckId::Dependencies, DoctorCheckCategory::Project),
711 (DoctorCheckId::Cache, DoctorCheckCategory::Cache),
712 (DoctorCheckId::GraphCache, DoctorCheckCategory::Cache),
713 ] {
714 checks.push(skipped(id, category, message));
715 }
716}
717
718fn skipped(id: DoctorCheckId, category: DoctorCheckCategory, message: &str) -> DoctorCheck {
719 check(
720 id,
721 category,
722 DoctorCheckStatus::Skipped,
723 false,
724 message,
725 None,
726 )
727}
728
729fn check(
730 id: DoctorCheckId,
731 category: DoctorCheckCategory,
732 status: DoctorCheckStatus,
733 required: bool,
734 message: impl Into<String>,
735 remediation: Option<DoctorRemediation>,
736) -> DoctorCheck {
737 DoctorCheck {
738 id,
739 category,
740 status,
741 required,
742 message: message.into(),
743 remediation,
744 }
745}
746
747fn build_output(checks: Vec<DoctorCheck>) -> DoctorOutput {
748 let summary = checks
749 .iter()
750 .fold(DoctorSummary::default(), |mut summary, check| {
751 match check.status {
752 DoctorCheckStatus::Pass => summary.pass += 1,
753 DoctorCheckStatus::Warn => summary.warn += 1,
754 DoctorCheckStatus::Fail => summary.fail += 1,
755 DoctorCheckStatus::Skipped => summary.skipped += 1,
756 }
757 summary
758 });
759 let status = if checks
760 .iter()
761 .any(|check| check.required && check.status == DoctorCheckStatus::Fail)
762 {
763 DoctorStatus::Fail
764 } else if summary.warn > 0 {
765 DoctorStatus::Warn
766 } else {
767 DoctorStatus::Pass
768 };
769 DoctorOutput {
770 schema_version: SchemaVersion(DOCTOR_SCHEMA_VERSION),
771 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
772 root: ".".to_string(),
773 status,
774 summary,
775 checks,
776 }
777}
778
779fn relative_path(root: &Path, path: &Path) -> Option<String> {
780 path.strip_prefix(root).ok().map(|path| {
781 let relative = path.to_string_lossy().replace('\\', "/");
782 if relative.is_empty() {
783 ".".to_string()
784 } else {
785 relative
786 }
787 })
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn zero_config_is_ready_with_stable_order() {
796 let root = tempfile::tempdir().expect("temp root");
797 let output = run_doctor_with_discovery(
798 &DoctorOptions {
799 root: root.path(),
800 config_path: None,
801 },
802 &|_| Err("missing companion".to_string()),
803 );
804
805 assert_eq!(output.status, DoctorStatus::Warn);
806 assert_eq!(output.root, ".");
807 assert_eq!(
808 output
809 .checks
810 .iter()
811 .map(|check| check.id)
812 .collect::<Vec<_>>(),
813 vec![
814 DoctorCheckId::Root,
815 DoctorCheckId::Config,
816 DoctorCheckId::Workspaces,
817 DoctorCheckId::Plugins,
818 DoctorCheckId::TypeAware,
819 DoctorCheckId::Dependencies,
820 DoctorCheckId::Cache,
821 DoctorCheckId::GraphCache,
822 ]
823 );
824 assert_eq!(
825 output.checks[1].message,
826 "Zero-config defaults resolved successfully."
827 );
828 assert_eq!(output.checks[4].status, DoctorCheckStatus::Skipped);
829 }
830
831 #[test]
834 fn missing_node_modules_warns_without_failing() {
835 let root = tempfile::tempdir().expect("temp root");
836
837 let output = run_doctor_with_discovery(
838 &DoctorOptions {
839 root: root.path(),
840 config_path: None,
841 },
842 &|_| Err("missing companion".to_string()),
843 );
844
845 let dependencies = output
846 .checks
847 .iter()
848 .find(|check| check.id == DoctorCheckId::Dependencies)
849 .expect("dependencies check is reported");
850 assert_eq!(dependencies.status, DoctorCheckStatus::Warn);
851 assert!(!dependencies.required);
852 assert_eq!(output.status, DoctorStatus::Warn);
853 assert!(
854 dependencies
855 .remediation
856 .as_ref()
857 .is_some_and(|remediation| remediation.mutating),
858 "installing dependencies mutates the project"
859 );
860 }
861
862 #[test]
863 fn installed_dependencies_pass_the_dependency_check() {
864 let root = tempfile::tempdir().expect("temp root");
865 std::fs::create_dir(root.path().join("node_modules")).expect("create node_modules");
866
867 let output = run_doctor_with_discovery(
868 &DoctorOptions {
869 root: root.path(),
870 config_path: None,
871 },
872 &|_| Err("missing companion".to_string()),
873 );
874
875 let dependencies = output
876 .checks
877 .iter()
878 .find(|check| check.id == DoctorCheckId::Dependencies)
879 .expect("dependencies check is reported");
880 assert_eq!(dependencies.status, DoctorCheckStatus::Pass);
881 assert_eq!(output.status, DoctorStatus::Pass);
882 }
883
884 #[test]
887 fn absent_cache_passes_the_cache_check() {
888 let root = tempfile::tempdir().expect("temp root");
889
890 let output = run_doctor_with_discovery(
891 &DoctorOptions {
892 root: root.path(),
893 config_path: None,
894 },
895 &|_| Err("missing companion".to_string()),
896 );
897
898 let cache = output
899 .checks
900 .iter()
901 .find(|check| check.id == DoctorCheckId::Cache)
902 .expect("cache check is reported");
903 assert_eq!(cache.status, DoctorCheckStatus::Pass);
904 assert!(!cache.required);
905 }
906
907 #[test]
915 fn a_corrupt_cache_warns_as_undecodable_with_a_size_that_shows() {
916 let root = tempfile::tempdir().expect("temp root");
917 let cache_dir = root.path().join(".fallow");
918 std::fs::create_dir_all(&cache_dir).expect("create cache dir");
919 std::fs::write(
920 cache_dir.join("cache.bin"),
921 b"not-a-payload-this-build-wrote",
922 )
923 .expect("write foreign cache");
924
925 let output = run_doctor_with_discovery(
926 &DoctorOptions {
927 root: root.path(),
928 config_path: None,
929 },
930 &|_| Err("missing companion".to_string()),
931 );
932
933 let cache = output
934 .checks
935 .iter()
936 .find(|check| check.id == DoctorCheckId::Cache)
937 .expect("cache check is reported");
938 assert_eq!(cache.status, DoctorCheckStatus::Warn);
939 assert!(!cache.required);
940 assert!(
941 cache.message.contains("could not be decoded"),
942 "a blob without fallow's framing is corrupt, not stale: {}",
943 cache.message
944 );
945 assert!(
946 !cache.message.contains("cache format version changed"),
947 "corruption must not send the reader hunting for an upgrade: {}",
948 cache.message
949 );
950 assert!(
951 cache.message.contains("30 bytes"),
952 "a small blob must report a size that shows it exists, not 0.0 MB: {}",
953 cache.message
954 );
955 assert_ne!(
956 output.status,
957 DoctorStatus::Fail,
958 "a refused cache costs time, not correctness"
959 );
960 }
961
962 #[test]
966 fn a_cache_from_an_older_format_version_warns_as_a_format_change() {
967 let root = tempfile::tempdir().expect("temp root");
968 let cache_dir = root.path().join(".fallow");
969 std::fs::create_dir_all(&cache_dir).expect("create cache dir");
970 let mut framed = b"FLWX".to_vec();
974 framed.extend_from_slice(&1_u32.to_le_bytes());
975 framed.extend_from_slice(b"payload-from-an-older-release");
976 std::fs::write(cache_dir.join("cache.bin"), framed).expect("write stale cache");
977
978 let output = run_doctor_with_discovery(
979 &DoctorOptions {
980 root: root.path(),
981 config_path: None,
982 },
983 &|_| Err("missing companion".to_string()),
984 );
985
986 let cache = output
987 .checks
988 .iter()
989 .find(|check| check.id == DoctorCheckId::Cache)
990 .expect("cache check is reported");
991 assert_eq!(cache.status, DoctorCheckStatus::Warn);
992 assert!(
993 cache.message.contains("cache format version changed"),
994 "{}",
995 cache.message
996 );
997 assert!(
998 !cache.message.contains("could not be decoded"),
999 "an upgrade must not be reported as corruption: {}",
1000 cache.message
1001 );
1002 }
1003
1004 #[test]
1009 fn absent_graph_cache_passes_its_own_check() {
1010 let root = tempfile::tempdir().expect("temp root");
1011
1012 let output = run_doctor_with_discovery(
1013 &DoctorOptions {
1014 root: root.path(),
1015 config_path: None,
1016 },
1017 &|_| Err("missing companion".to_string()),
1018 );
1019
1020 let graph_cache = output
1021 .checks
1022 .iter()
1023 .find(|check| check.id == DoctorCheckId::GraphCache)
1024 .expect("graph cache check is reported");
1025 assert_eq!(graph_cache.status, DoctorCheckStatus::Pass);
1026 assert!(!graph_cache.required);
1027 }
1028
1029 #[test]
1030 fn a_corrupt_graph_cache_warns_as_undecodable_with_a_size_that_shows() {
1031 let root = tempfile::tempdir().expect("temp root");
1032 let cache_dir = root.path().join(".fallow");
1033 std::fs::create_dir_all(&cache_dir).expect("create cache dir");
1034 std::fs::write(
1035 cache_dir.join("graph-cache.bin"),
1036 b"not-a-payload-this-build-wrote",
1037 )
1038 .expect("write foreign graph cache");
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 let graph_cache = output
1049 .checks
1050 .iter()
1051 .find(|check| check.id == DoctorCheckId::GraphCache)
1052 .expect("graph cache check is reported");
1053 assert_eq!(graph_cache.status, DoctorCheckStatus::Warn);
1054 assert!(!graph_cache.required);
1055 assert!(
1056 graph_cache.message.contains("could not be decoded"),
1057 "a blob without fallow's framing is corrupt, not stale: {}",
1058 graph_cache.message
1059 );
1060 assert!(
1061 graph_cache.message.contains("30 bytes"),
1062 "a small blob must report a size that shows it exists, not 0.0 MB: {}",
1063 graph_cache.message
1064 );
1065 assert_ne!(
1066 output.status,
1067 DoctorStatus::Fail,
1068 "a refused cache costs time, not correctness"
1069 );
1070 }
1071
1072 #[test]
1073 fn invalid_config_returns_complete_failed_report() {
1074 let root = tempfile::tempdir().expect("temp root");
1075 std::fs::write(root.path().join(".fallowrc.json"), "{").expect("write invalid config");
1076
1077 let output = run_doctor_with_discovery(
1078 &DoctorOptions {
1079 root: root.path(),
1080 config_path: None,
1081 },
1082 &|_| Err("missing companion".to_string()),
1083 );
1084
1085 let check = |id: DoctorCheckId| {
1088 output
1089 .checks
1090 .iter()
1091 .find(|check| check.id == id)
1092 .unwrap_or_else(|| panic!("{id:?} is reported even when config fails"))
1093 };
1094
1095 assert_eq!(output.status, DoctorStatus::Fail);
1096 assert_eq!(
1097 output.checks.len(),
1098 8,
1099 "a failing config must not truncate the report"
1100 );
1101 assert_eq!(check(DoctorCheckId::Config).status, DoctorCheckStatus::Fail);
1102 assert_eq!(
1103 check(DoctorCheckId::Workspaces).status,
1104 DoctorCheckStatus::Skipped
1105 );
1106 assert!(
1107 !check(DoctorCheckId::Config)
1108 .message
1109 .contains(&root.path().display().to_string()),
1110 "the failure must not echo the host path"
1111 );
1112 }
1113
1114 #[test]
1115 fn optional_missing_type_aware_companion_warns() {
1116 let root = tempfile::tempdir().expect("temp root");
1117 std::fs::write(
1118 root.path().join(".fallowrc.json"),
1119 r#"{"typeAware":{"enabled":true}}"#,
1120 )
1121 .expect("write config");
1122
1123 let output = run_doctor_with_discovery(
1124 &DoctorOptions {
1125 root: root.path(),
1126 config_path: None,
1127 },
1128 &|_| Err("missing companion".to_string()),
1129 );
1130
1131 assert_eq!(output.status, DoctorStatus::Warn);
1132 assert_eq!(output.checks[4].status, DoctorCheckStatus::Warn);
1133 assert!(!output.checks[4].required);
1134 }
1135
1136 #[test]
1137 fn required_missing_type_aware_companion_fails() {
1138 let root = tempfile::tempdir().expect("temp root");
1139 std::fs::write(
1140 root.path().join(".fallowrc.json"),
1141 r#"{"typeAware":{"enabled":true,"require":"complete"}}"#,
1142 )
1143 .expect("write config");
1144
1145 let output = run_doctor_with_discovery(
1146 &DoctorOptions {
1147 root: root.path(),
1148 config_path: None,
1149 },
1150 &|_| Err("missing companion".to_string()),
1151 );
1152
1153 assert_eq!(output.status, DoctorStatus::Fail);
1154 assert_eq!(output.checks[4].status, DoctorCheckStatus::Fail);
1155 assert!(output.checks[4].required);
1156 assert_eq!(
1157 output.checks[4]
1158 .remediation
1159 .as_ref()
1160 .map(|remediation| remediation.mutating),
1161 Some(true)
1162 );
1163 }
1164
1165 #[test]
1166 fn invalid_root_does_not_echo_the_host_path() {
1167 let missing = Path::new("/definitely/missing/fallow-doctor-private-root");
1168 let output = run_doctor(&DoctorOptions {
1169 root: missing,
1170 config_path: None,
1171 });
1172
1173 assert_eq!(output.status, DoctorStatus::Fail);
1174 assert_eq!(output.root, ".");
1175 assert!(output.checks[0].message.contains("--root"));
1176 assert_eq!(
1177 output.checks[0].message,
1178 "Project root is not accessible. Set --root to an existing, readable directory."
1179 );
1180 assert!(
1181 output
1182 .checks
1183 .iter()
1184 .all(|check| !check.message.contains("fallow-doctor-private-root"))
1185 );
1186 }
1187
1188 #[test]
1189 fn external_config_does_not_echo_its_host_path() {
1190 let root = tempfile::tempdir().expect("temp root");
1191 std::fs::create_dir(root.path().join("node_modules")).expect("create node_modules");
1192 let config_dir = tempfile::tempdir().expect("temp config dir");
1193 let config_path = config_dir.path().join("external.fallowrc.json");
1194 std::fs::write(&config_path, "{}").expect("write config");
1195
1196 let output = run_doctor(&DoctorOptions {
1197 root: root.path(),
1198 config_path: Some(&config_path),
1199 });
1200
1201 assert_eq!(output.status, DoctorStatus::Pass);
1202 assert_eq!(
1203 output.checks[1].message,
1204 "The explicitly selected configuration resolved successfully."
1205 );
1206 assert!(
1207 !output.checks[1]
1208 .message
1209 .contains(&config_dir.path().display().to_string())
1210 );
1211 }
1212
1213 #[test]
1214 fn parent_relative_external_config_stays_private() {
1215 let sandbox = tempfile::tempdir().expect("temp sandbox");
1216 let root = sandbox.path().join("project");
1217 std::fs::create_dir(&root).expect("create project root");
1218 std::fs::create_dir(root.join("node_modules")).expect("create node_modules");
1219 let config_path = root.join("../customer-secret.json");
1220 std::fs::write(&config_path, "{}").expect("write config");
1221
1222 let output = run_doctor(&DoctorOptions {
1223 root: &root,
1224 config_path: Some(&config_path),
1225 });
1226
1227 assert_eq!(output.status, DoctorStatus::Pass);
1228 assert_eq!(
1229 output.checks[1].message,
1230 "The explicitly selected configuration resolved successfully."
1231 );
1232 assert!(!output.checks[1].message.contains("customer-secret"));
1233 }
1234
1235 #[cfg(unix)]
1236 #[test]
1237 fn successful_config_below_external_symlink_stays_private() {
1238 let root = tempfile::tempdir().expect("temp root");
1239 std::fs::create_dir(root.path().join("node_modules")).expect("create node_modules");
1240 let external = tempfile::tempdir().expect("external root");
1241 std::fs::write(external.path().join("config.json"), "{}").expect("write config");
1242 std::os::unix::fs::symlink(external.path(), root.path().join("external"))
1243 .expect("create external symlink");
1244 let config_path = root.path().join("external/config.json");
1245
1246 let output = run_doctor(&DoctorOptions {
1247 root: root.path(),
1248 config_path: Some(&config_path),
1249 });
1250
1251 assert_eq!(output.status, DoctorStatus::Pass);
1252 assert_eq!(
1253 output.checks[1].message,
1254 "The explicitly selected configuration resolved successfully."
1255 );
1256 assert!(!output.checks[1].message.contains("external/config.json"));
1257 }
1258
1259 #[test]
1260 fn relative_explicit_config_is_preserved_in_remediation() {
1261 let root = tempfile::tempdir().expect("temp root");
1262 let config_path = root.path().join("custom.json");
1263 std::fs::write(&config_path, "{").expect("write invalid config");
1264
1265 let output = run_doctor(&DoctorOptions {
1266 root: root.path(),
1267 config_path: Some(&config_path),
1268 });
1269
1270 assert_eq!(output.status, DoctorStatus::Fail);
1271 assert_eq!(
1272 output.checks[1]
1273 .remediation
1274 .as_ref()
1275 .map(|remediation| remediation.command.as_str()),
1276 Some("fallow config --config=custom.json")
1277 );
1278 }
1279
1280 #[test]
1281 fn nested_missing_project_relative_config_is_preserved_in_remediation() {
1282 let root = tempfile::tempdir().expect("temp root");
1283 let config_path = root.path().join("missing-dir/missing.json");
1284
1285 let output = run_doctor(&DoctorOptions {
1286 root: root.path(),
1287 config_path: Some(&config_path),
1288 });
1289
1290 assert_eq!(output.status, DoctorStatus::Fail);
1291 assert_eq!(
1292 output.checks[1]
1293 .remediation
1294 .as_ref()
1295 .map(|remediation| remediation.command.as_str()),
1296 Some("fallow config --config=missing-dir/missing.json")
1297 );
1298 assert!(
1299 !output.checks[1]
1300 .message
1301 .contains("same explicit --config value")
1302 );
1303 }
1304
1305 #[test]
1306 fn leading_dash_config_name_is_bound_to_its_option() {
1307 let root = tempfile::tempdir().expect("temp root");
1308 let config_path = root.path().join("-missing.json");
1309
1310 let output = run_doctor(&DoctorOptions {
1311 root: root.path(),
1312 config_path: Some(&config_path),
1313 });
1314
1315 assert_eq!(
1316 output.checks[1]
1317 .remediation
1318 .as_ref()
1319 .map(|remediation| remediation.command.as_str()),
1320 Some("fallow config --config=-missing.json")
1321 );
1322 }
1323
1324 #[test]
1325 fn config_path_equal_to_root_never_renders_an_empty_argument() {
1326 let root = tempfile::tempdir().expect("temp root");
1327
1328 let output = run_doctor(&DoctorOptions {
1329 root: root.path(),
1330 config_path: Some(root.path()),
1331 });
1332
1333 let command = output.checks[1]
1334 .remediation
1335 .as_ref()
1336 .map(|remediation| remediation.command.as_str());
1337 assert_eq!(command, Some("fallow config --config=."));
1338 assert_ne!(command, Some("fallow config --config="));
1339 }
1340
1341 #[test]
1342 fn missing_config_traversal_outside_root_stays_private() {
1343 let sandbox = tempfile::tempdir().expect("temp sandbox");
1344 let root = sandbox.path().join("project");
1345 std::fs::create_dir(&root).expect("create project root");
1346 let config_path = root.join("../missing.json");
1347
1348 let output = run_doctor(&DoctorOptions {
1349 root: &root,
1350 config_path: Some(&config_path),
1351 });
1352
1353 assert_eq!(output.status, DoctorStatus::Fail);
1354 assert!(output.checks[1].remediation.is_none());
1355 assert!(
1356 output.checks[1]
1357 .message
1358 .contains("same explicit --config value")
1359 );
1360 assert!(!output.checks[1].message.contains("missing.json"));
1361 }
1362
1363 #[cfg(unix)]
1364 #[test]
1365 fn missing_config_below_external_symlink_stays_private() {
1366 let root = tempfile::tempdir().expect("temp root");
1367 let external = tempfile::tempdir().expect("external root");
1368 std::os::unix::fs::symlink(external.path(), root.path().join("external"))
1369 .expect("create external symlink");
1370 let config_path = root.path().join("external/missing-dir/missing.json");
1371
1372 let output = run_doctor(&DoctorOptions {
1373 root: root.path(),
1374 config_path: Some(&config_path),
1375 });
1376
1377 assert_eq!(output.status, DoctorStatus::Fail);
1378 assert!(output.checks[1].remediation.is_none());
1379 assert!(
1380 output.checks[1]
1381 .message
1382 .contains("same explicit --config value")
1383 );
1384 assert!(!output.checks[1].message.contains("missing-dir"));
1385 }
1386
1387 #[test]
1388 fn failed_external_config_requires_reusing_the_private_value() {
1389 let root = tempfile::tempdir().expect("temp root");
1390 let config_dir = tempfile::tempdir().expect("temp config dir");
1391 let config_path = config_dir.path().join("external.json");
1392 std::fs::write(&config_path, "{").expect("write invalid config");
1393
1394 let output = run_doctor(&DoctorOptions {
1395 root: root.path(),
1396 config_path: Some(&config_path),
1397 });
1398
1399 assert_eq!(output.status, DoctorStatus::Fail);
1400 assert!(output.checks[1].remediation.is_none());
1401 assert!(
1402 output.checks[1]
1403 .message
1404 .contains("same explicit --config value")
1405 );
1406 assert!(
1407 !output.checks[1]
1408 .message
1409 .contains(&config_dir.path().display().to_string())
1410 );
1411 }
1412
1413 #[test]
1414 fn missing_explicit_plugin_is_a_required_failure() {
1415 let root = tempfile::tempdir().expect("temp root");
1416 std::fs::write(
1417 root.path().join(".fallowrc.json"),
1418 r#"{"plugins":["missing-plugin.json"]}"#,
1419 )
1420 .expect("write config");
1421
1422 let output = run_doctor(&DoctorOptions {
1423 root: root.path(),
1424 config_path: None,
1425 });
1426
1427 assert_eq!(output.status, DoctorStatus::Fail);
1428 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
1429 assert!(output.checks[3].required);
1430 assert!(
1431 output.checks[3]
1432 .message
1433 .contains("1 unresolved configured resource")
1434 );
1435 assert!(!output.checks[3].message.contains("missing-plugin.json"));
1436 }
1437
1438 #[test]
1439 fn malformed_explicit_plugin_is_a_required_failure() {
1440 let root = tempfile::tempdir().expect("temp root");
1441 std::fs::write(
1442 root.path().join(".fallowrc.json"),
1443 r#"{"plugins":["broken.json"]}"#,
1444 )
1445 .expect("write config");
1446 std::fs::write(root.path().join("broken.json"), "{").expect("write plugin");
1447
1448 let output = run_doctor(&DoctorOptions {
1449 root: root.path(),
1450 config_path: None,
1451 });
1452
1453 assert_eq!(output.status, DoctorStatus::Fail);
1454 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
1455 assert!(output.checks[3].required);
1456 assert!(
1457 output.checks[3]
1458 .message
1459 .contains("1 unresolved configured resource")
1460 );
1461 assert!(!output.checks[3].message.contains("broken.json"));
1462 }
1463
1464 #[test]
1465 fn explicit_plugin_directory_without_definitions_is_a_required_failure() {
1466 let root = tempfile::tempdir().expect("temp root");
1467 std::fs::create_dir(root.path().join("plugins")).expect("create plugin directory");
1468 std::fs::write(
1469 root.path().join(".fallowrc.json"),
1470 r#"{"plugins":["plugins"]}"#,
1471 )
1472 .expect("write config");
1473
1474 let output = run_doctor(&DoctorOptions {
1475 root: root.path(),
1476 config_path: None,
1477 });
1478
1479 assert_eq!(output.status, DoctorStatus::Fail);
1480 assert_eq!(output.checks[3].status, DoctorCheckStatus::Fail);
1481 assert!(output.checks[3].required);
1482 assert!(
1483 output.checks[3]
1484 .message
1485 .contains("1 unresolved configured resource")
1486 );
1487 }
1488
1489 #[test]
1490 fn inactive_external_plugin_warns_with_project_root_remediation() {
1491 let root = tempfile::tempdir().expect("temp root");
1492 std::fs::write(
1493 root.path().join("package.json"),
1494 r#"{"name":"doctor-test"}"#,
1495 )
1496 .expect("write package manifest");
1497 std::fs::write(
1498 root.path().join("fallow-plugin-doctor.json"),
1499 r#"{"name":"doctor-plugin","enablers":["missing-framework"]}"#,
1500 )
1501 .expect("write plugin");
1502
1503 let output = run_doctor_with_discovery(
1504 &DoctorOptions {
1505 root: root.path(),
1506 config_path: None,
1507 },
1508 &|_| Err("missing companion".to_string()),
1509 );
1510
1511 assert_eq!(output.status, DoctorStatus::Warn);
1512 assert_eq!(output.checks[3].status, DoctorCheckStatus::Warn);
1513 assert!(
1514 output.checks[3]
1515 .message
1516 .contains("0 active of 1 configured")
1517 );
1518 assert_eq!(
1519 output.checks[3]
1520 .remediation
1521 .as_ref()
1522 .map(|remediation| (remediation.cwd.as_str(), remediation.mutating)),
1523 Some((".", false))
1524 );
1525 }
1526
1527 #[test]
1528 fn active_external_plugin_passes() {
1529 let root = tempfile::tempdir().expect("temp root");
1530 std::fs::create_dir(root.path().join("node_modules")).expect("create node_modules");
1531 std::fs::write(
1532 root.path().join("package.json"),
1533 r#"{"name":"doctor-test","dependencies":{"doctor-framework":"1.0.0"}}"#,
1534 )
1535 .expect("write package manifest");
1536 std::fs::write(
1537 root.path().join("fallow-plugin-doctor.json"),
1538 r#"{"name":"doctor-plugin","enablers":["doctor-framework"]}"#,
1539 )
1540 .expect("write plugin");
1541
1542 let output = run_doctor_with_discovery(
1543 &DoctorOptions {
1544 root: root.path(),
1545 config_path: None,
1546 },
1547 &|_| Err("missing companion".to_string()),
1548 );
1549
1550 assert_eq!(output.status, DoctorStatus::Pass);
1551 assert_eq!(output.checks[3].status, DoctorCheckStatus::Pass);
1552 assert!(
1553 output.checks[3]
1554 .message
1555 .contains("1 active of 1 configured")
1556 );
1557 }
1558}