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