Skip to main content

fallow_api/
doctor.rs

1//! Read-only project readiness inspection.
2
3use 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
14/// Inputs for a deterministic doctor run.
15pub struct DoctorOptions<'a> {
16    /// Project root, before canonical validation.
17    pub root: &'a Path,
18    /// Optional explicit fallow config path.
19    pub config_path: Option<&'a Path>,
20}
21
22/// Inspect project-local readiness without analysis, cache writes, telemetry,
23/// network access, or third-party execution.
24#[must_use]
25pub fn run_doctor(options: &DoctorOptions<'_>) -> DoctorOutput {
26    run_doctor_with_discovery(options, &crate::type_aware::discover_companion)
27}
28
29/// Inspect readiness using an explicit cache-directory override.
30///
31/// The override takes precedence over `cache.dir`; relative paths resolve from
32/// the validated project root. Empty paths are ignored. Hosts can forward their
33/// environment settings here without making the API read ambient cache settings
34/// or changing existing [`DoctorOptions`] callers. Inspection never writes caches.
35#[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
210/// Report whether the project has an installed dependency tree.
211///
212/// Advisory, never required: analysis runs without `node_modules`, it just
213/// runs blind to package `exports`, to plugins that activate on an installed
214/// package, and to a dependency's installed shape. Doctor used to report
215/// `pass` on a tree that had never been installed, which is the one state this
216/// command exists to catch.
217fn 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
239/// Report whether a persisted extraction cache would be reused.
240///
241/// Advisory, never required, and never a `fail`: a refused cache costs time,
242/// not correctness. A missing cache passes, because a first run legitimately
243/// has none; a cache that exists and would be discarded warns, because the
244/// project is paying for a blob it never gets back.
245fn 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
281/// Report whether the persisted module graph would load.
282///
283/// Advisory for the same reason as the extraction-cache check, and reported
284/// separately because the two blobs are reused independently: a project whose
285/// extraction cache is perfectly healthy can still rebuild the whole graph on
286/// every run, and the graph is the larger file of the two.
287///
288/// This answers whether the blob LOADS, not whether a run would reuse it. The
289/// reuse decision also compares resolver options, entry points, and per-file
290/// content hashes, and computing those means running discovery and extraction,
291/// which doctor deliberately does not do.
292fn 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
332/// Render a byte count at a unit that shows it.
333///
334/// A fixed megabyte figure reported every small blob as `0.0 MB`, which reads
335/// as "empty" next to a message about a cache that exists and was refused: a
336/// corrupt 4 KB file and a truncated 40-byte one printed the same size.
337fn 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    /// Issue: doctor reported `pass` on a tree that had never been installed,
832    /// while the command exists to diagnose exactly that.
833    #[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    /// A project with no cache yet is not a problem; only a cache that exists
885    /// and would be thrown away is worth a warning.
886    #[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    /// Framing this build never wrote is corruption, and must not be reported
908    /// as a format bump.
909    ///
910    /// "cache format version changed" sends the reader to look for an upgrade;
911    /// the fix for a blob with no fallow framing is to delete it. The upgrade
912    /// message has its own test below, on a blob that keeps the framing and
913    /// moves only the declared version.
914    #[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    /// The message a user reads after upgrading. A blob that keeps fallow's
963    /// framing and declares a version this build does not write is stale, and
964    /// costs exactly one rebuild.
965    #[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        // `FLWX` plus a little-endian version, the framing `fallow-extract`
971        // writes. Version 1 is far below anything this build produces, so the
972        // blob is stale rather than foreign.
973        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    /// The graph blob is the larger of the two persisted caches and is reused
1005    /// independently of the extraction blob, so a doctor that only looked at
1006    /// the extraction cache called a project healthy while the expensive half
1007    /// was discarded on every run.
1008    #[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        // Look checks up by id, not by position: the report's completeness is
1086        // the contract, the order in which the rows happen to be pushed is not.
1087        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}