Skip to main content

fallow_config/config/
resolution.rs

1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::boundaries::ResolvedBoundaryConfig;
8use super::duplicates_config::DuplicatesConfig;
9use super::format::OutputFormat;
10use super::health::HealthConfig;
11use super::rules::{PartialRulesConfig, RulesConfig, Severity};
12use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
13
14use super::FallowConfig;
15
16/// Rule for ignoring specific exports.
17#[derive(Debug, Deserialize, Serialize, JsonSchema)]
18pub struct IgnoreExportRule {
19    /// Glob pattern for files.
20    pub file: String,
21    /// Export names to ignore (`*` for all).
22    pub exports: Vec<String>,
23}
24
25/// Per-file override entry.
26#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
27#[serde(rename_all = "camelCase")]
28pub struct ConfigOverride {
29    /// Glob patterns to match files against (relative to config file location).
30    pub files: Vec<String>,
31    /// Partial rules — only specified fields override the base rules.
32    #[serde(default)]
33    pub rules: PartialRulesConfig,
34}
35
36/// Resolved override with pre-compiled glob matchers.
37#[derive(Debug)]
38pub struct ResolvedOverride {
39    pub matchers: Vec<globset::GlobMatcher>,
40    pub rules: PartialRulesConfig,
41}
42
43/// Fully resolved configuration with all globs pre-compiled.
44#[derive(Debug)]
45pub struct ResolvedConfig {
46    pub root: PathBuf,
47    pub entry_patterns: Vec<String>,
48    pub ignore_patterns: GlobSet,
49    pub output: OutputFormat,
50    pub cache_dir: PathBuf,
51    pub threads: usize,
52    pub no_cache: bool,
53    pub ignore_dependencies: Vec<String>,
54    pub ignore_export_rules: Vec<IgnoreExportRule>,
55    pub duplicates: DuplicatesConfig,
56    pub health: HealthConfig,
57    pub rules: RulesConfig,
58    /// Resolved architecture boundary configuration with pre-compiled glob matchers.
59    pub boundaries: ResolvedBoundaryConfig,
60    /// Whether production mode is active.
61    pub production: bool,
62    /// Suppress progress output and non-essential stderr messages.
63    pub quiet: bool,
64    /// External plugin definitions (from plugin files + inline framework definitions).
65    pub external_plugins: Vec<ExternalPluginDef>,
66    /// Per-file rule overrides with pre-compiled glob matchers.
67    pub overrides: Vec<ResolvedOverride>,
68    /// Regression config (passed through from user config, not resolved).
69    pub regression: Option<super::RegressionConfig>,
70}
71
72impl FallowConfig {
73    /// Resolve into a fully resolved config with compiled globs.
74    pub fn resolve(
75        self,
76        root: PathBuf,
77        output: OutputFormat,
78        threads: usize,
79        no_cache: bool,
80        quiet: bool,
81    ) -> ResolvedConfig {
82        let mut ignore_builder = GlobSetBuilder::new();
83        for pattern in &self.ignore_patterns {
84            match Glob::new(pattern) {
85                Ok(glob) => {
86                    ignore_builder.add(glob);
87                }
88                Err(e) => {
89                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
90                }
91            }
92        }
93
94        // Default ignores
95        // Note: `build/` is only ignored at the project root (not `**/build/**`)
96        // because nested `build/` directories like `test/build/` may contain source files.
97        let default_ignores = [
98            "**/node_modules/**",
99            "**/dist/**",
100            "build/**",
101            "**/.git/**",
102            "**/coverage/**",
103            "**/*.min.js",
104            "**/*.min.mjs",
105        ];
106        for pattern in &default_ignores {
107            if let Ok(glob) = Glob::new(pattern) {
108                ignore_builder.add(glob);
109            }
110        }
111
112        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
113        let cache_dir = root.join(".fallow");
114
115        let mut rules = self.rules;
116
117        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
118        let production = self.production;
119        if production {
120            rules.unused_dev_dependencies = Severity::Off;
121            rules.unused_optional_dependencies = Severity::Off;
122        }
123
124        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
125        // Merge inline framework definitions into external plugins
126        external_plugins.extend(self.framework);
127
128        // Expand boundary preset (if configured) before validation.
129        // Detect source root from tsconfig.json, falling back to "src".
130        let mut boundaries = self.boundaries;
131        if boundaries.preset.is_some() {
132            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
133                .filter(|r| {
134                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
135                })
136                .unwrap_or_else(|| "src".to_owned());
137            if source_root != "src" {
138                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
139            }
140            boundaries.expand(&source_root);
141        }
142
143        // Validate and compile architecture boundary config
144        let validation_errors = boundaries.validate_zone_references();
145        for (rule_idx, zone_name) in &validation_errors {
146            tracing::error!(
147                "boundary rule {} references undefined zone '{zone_name}'",
148                rule_idx
149            );
150        }
151        let boundaries = boundaries.resolve();
152
153        // Pre-compile override glob matchers
154        let overrides = self
155            .overrides
156            .into_iter()
157            .filter_map(|o| {
158                let matchers: Vec<globset::GlobMatcher> = o
159                    .files
160                    .iter()
161                    .filter_map(|pattern| match Glob::new(pattern) {
162                        Ok(glob) => Some(glob.compile_matcher()),
163                        Err(e) => {
164                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
165                            None
166                        }
167                    })
168                    .collect();
169                if matchers.is_empty() {
170                    None
171                } else {
172                    Some(ResolvedOverride {
173                        matchers,
174                        rules: o.rules,
175                    })
176                }
177            })
178            .collect();
179
180        ResolvedConfig {
181            root,
182            entry_patterns: self.entry,
183            ignore_patterns: compiled_ignore_patterns,
184            output,
185            cache_dir,
186            threads,
187            no_cache,
188            ignore_dependencies: self.ignore_dependencies,
189            ignore_export_rules: self.ignore_exports,
190            duplicates: self.duplicates,
191            health: self.health,
192            rules,
193            boundaries,
194            production,
195            quiet,
196            external_plugins,
197            overrides,
198            regression: self.regression,
199        }
200    }
201}
202
203impl ResolvedConfig {
204    /// Resolve the effective rules for a given file path.
205    /// Starts with base rules and applies matching overrides in order.
206    #[must_use]
207    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
208        if self.overrides.is_empty() {
209            return self.rules.clone();
210        }
211
212        let relative = path.strip_prefix(&self.root).unwrap_or(path);
213        let relative_str = relative.to_string_lossy();
214
215        let mut rules = self.rules.clone();
216        for override_entry in &self.overrides {
217            let matches = override_entry
218                .matchers
219                .iter()
220                .any(|m| m.is_match(relative_str.as_ref()));
221            if matches {
222                rules.apply_partial(&override_entry.rules);
223            }
224        }
225        rules
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::config::boundaries::BoundaryConfig;
233    use crate::config::health::HealthConfig;
234
235    #[test]
236    fn overrides_deserialize() {
237        let json_str = r#"{
238            "overrides": [{
239                "files": ["*.test.ts"],
240                "rules": {
241                    "unused-exports": "off"
242                }
243            }]
244        }"#;
245        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
246        assert_eq!(config.overrides.len(), 1);
247        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
248        assert_eq!(
249            config.overrides[0].rules.unused_exports,
250            Some(Severity::Off)
251        );
252        assert_eq!(config.overrides[0].rules.unused_files, None);
253    }
254
255    #[test]
256    fn resolve_rules_for_path_no_overrides() {
257        let config = FallowConfig {
258            schema: None,
259            extends: vec![],
260            entry: vec![],
261            ignore_patterns: vec![],
262            framework: vec![],
263            workspaces: None,
264            ignore_dependencies: vec![],
265            ignore_exports: vec![],
266            duplicates: DuplicatesConfig::default(),
267            health: HealthConfig::default(),
268            rules: RulesConfig::default(),
269            boundaries: BoundaryConfig::default(),
270            production: false,
271            plugins: vec![],
272            overrides: vec![],
273            regression: None,
274        };
275        let resolved = config.resolve(
276            PathBuf::from("/project"),
277            OutputFormat::Human,
278            1,
279            true,
280            true,
281        );
282        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
283        assert_eq!(rules.unused_files, Severity::Error);
284    }
285
286    #[test]
287    fn resolve_rules_for_path_with_matching_override() {
288        let config = FallowConfig {
289            schema: None,
290            extends: vec![],
291            entry: vec![],
292            ignore_patterns: vec![],
293            framework: vec![],
294            workspaces: None,
295            ignore_dependencies: vec![],
296            ignore_exports: vec![],
297            duplicates: DuplicatesConfig::default(),
298            health: HealthConfig::default(),
299            rules: RulesConfig::default(),
300            boundaries: BoundaryConfig::default(),
301            production: false,
302            plugins: vec![],
303            overrides: vec![ConfigOverride {
304                files: vec!["*.test.ts".to_string()],
305                rules: PartialRulesConfig {
306                    unused_exports: Some(Severity::Off),
307                    ..Default::default()
308                },
309            }],
310            regression: None,
311        };
312        let resolved = config.resolve(
313            PathBuf::from("/project"),
314            OutputFormat::Human,
315            1,
316            true,
317            true,
318        );
319
320        // Test file matches override
321        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
322        assert_eq!(test_rules.unused_exports, Severity::Off);
323        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
324
325        // Non-test file does not match
326        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
327        assert_eq!(src_rules.unused_exports, Severity::Error);
328    }
329
330    #[test]
331    fn resolve_rules_for_path_later_override_wins() {
332        let config = FallowConfig {
333            schema: None,
334            extends: vec![],
335            entry: vec![],
336            ignore_patterns: vec![],
337            framework: vec![],
338            workspaces: None,
339            ignore_dependencies: vec![],
340            ignore_exports: vec![],
341            duplicates: DuplicatesConfig::default(),
342            health: HealthConfig::default(),
343            rules: RulesConfig::default(),
344            boundaries: BoundaryConfig::default(),
345            production: false,
346            plugins: vec![],
347            overrides: vec![
348                ConfigOverride {
349                    files: vec!["*.ts".to_string()],
350                    rules: PartialRulesConfig {
351                        unused_files: Some(Severity::Warn),
352                        ..Default::default()
353                    },
354                },
355                ConfigOverride {
356                    files: vec!["*.test.ts".to_string()],
357                    rules: PartialRulesConfig {
358                        unused_files: Some(Severity::Off),
359                        ..Default::default()
360                    },
361                },
362            ],
363            regression: None,
364        };
365        let resolved = config.resolve(
366            PathBuf::from("/project"),
367            OutputFormat::Human,
368            1,
369            true,
370            true,
371        );
372
373        // First override matches *.ts, second matches *.test.ts; second wins
374        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
375        assert_eq!(rules.unused_files, Severity::Off);
376
377        // Non-test .ts file only matches first override
378        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
379        assert_eq!(rules2.unused_files, Severity::Warn);
380    }
381
382    /// Helper to build a FallowConfig with minimal boilerplate.
383    fn make_config(production: bool) -> FallowConfig {
384        FallowConfig {
385            schema: None,
386            extends: vec![],
387            entry: vec![],
388            ignore_patterns: vec![],
389            framework: vec![],
390            workspaces: None,
391            ignore_dependencies: vec![],
392            ignore_exports: vec![],
393            duplicates: DuplicatesConfig::default(),
394            health: HealthConfig::default(),
395            rules: RulesConfig::default(),
396            boundaries: BoundaryConfig::default(),
397            production,
398            plugins: vec![],
399            overrides: vec![],
400            regression: None,
401        }
402    }
403
404    // ── Production mode ─────────────────────────────────────────────
405
406    #[test]
407    fn resolve_production_forces_dev_deps_off() {
408        let resolved = make_config(true).resolve(
409            PathBuf::from("/project"),
410            OutputFormat::Human,
411            1,
412            true,
413            true,
414        );
415        assert_eq!(
416            resolved.rules.unused_dev_dependencies,
417            Severity::Off,
418            "production mode should force unused_dev_dependencies to off"
419        );
420    }
421
422    #[test]
423    fn resolve_production_forces_optional_deps_off() {
424        let resolved = make_config(true).resolve(
425            PathBuf::from("/project"),
426            OutputFormat::Human,
427            1,
428            true,
429            true,
430        );
431        assert_eq!(
432            resolved.rules.unused_optional_dependencies,
433            Severity::Off,
434            "production mode should force unused_optional_dependencies to off"
435        );
436    }
437
438    #[test]
439    fn resolve_production_preserves_other_rules() {
440        let resolved = make_config(true).resolve(
441            PathBuf::from("/project"),
442            OutputFormat::Human,
443            1,
444            true,
445            true,
446        );
447        // Other rules should remain at their defaults
448        assert_eq!(resolved.rules.unused_files, Severity::Error);
449        assert_eq!(resolved.rules.unused_exports, Severity::Error);
450        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
451    }
452
453    #[test]
454    fn resolve_non_production_keeps_dev_deps_default() {
455        let resolved = make_config(false).resolve(
456            PathBuf::from("/project"),
457            OutputFormat::Human,
458            1,
459            true,
460            true,
461        );
462        assert_eq!(
463            resolved.rules.unused_dev_dependencies,
464            Severity::Error,
465            "non-production should keep default severity"
466        );
467        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Error);
468    }
469
470    #[test]
471    fn resolve_production_flag_stored() {
472        let resolved = make_config(true).resolve(
473            PathBuf::from("/project"),
474            OutputFormat::Human,
475            1,
476            true,
477            true,
478        );
479        assert!(resolved.production);
480
481        let resolved2 = make_config(false).resolve(
482            PathBuf::from("/project"),
483            OutputFormat::Human,
484            1,
485            true,
486            true,
487        );
488        assert!(!resolved2.production);
489    }
490
491    // ── Default ignore patterns ─────────────────────────────────────
492
493    #[test]
494    fn resolve_default_ignores_node_modules() {
495        let resolved = make_config(false).resolve(
496            PathBuf::from("/project"),
497            OutputFormat::Human,
498            1,
499            true,
500            true,
501        );
502        assert!(
503            resolved
504                .ignore_patterns
505                .is_match("node_modules/lodash/index.js")
506        );
507        assert!(
508            resolved
509                .ignore_patterns
510                .is_match("packages/a/node_modules/react/index.js")
511        );
512    }
513
514    #[test]
515    fn resolve_default_ignores_dist() {
516        let resolved = make_config(false).resolve(
517            PathBuf::from("/project"),
518            OutputFormat::Human,
519            1,
520            true,
521            true,
522        );
523        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
524        assert!(
525            resolved
526                .ignore_patterns
527                .is_match("packages/ui/dist/index.js")
528        );
529    }
530
531    #[test]
532    fn resolve_default_ignores_root_build_only() {
533        let resolved = make_config(false).resolve(
534            PathBuf::from("/project"),
535            OutputFormat::Human,
536            1,
537            true,
538            true,
539        );
540        assert!(
541            resolved.ignore_patterns.is_match("build/output.js"),
542            "root build/ should be ignored"
543        );
544        // The pattern is `build/**` (root-only), not `**/build/**`
545        assert!(
546            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
547            "nested build/ should NOT be ignored by default"
548        );
549    }
550
551    #[test]
552    fn resolve_default_ignores_minified_files() {
553        let resolved = make_config(false).resolve(
554            PathBuf::from("/project"),
555            OutputFormat::Human,
556            1,
557            true,
558            true,
559        );
560        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
561        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
562    }
563
564    #[test]
565    fn resolve_default_ignores_git() {
566        let resolved = make_config(false).resolve(
567            PathBuf::from("/project"),
568            OutputFormat::Human,
569            1,
570            true,
571            true,
572        );
573        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
574    }
575
576    #[test]
577    fn resolve_default_ignores_coverage() {
578        let resolved = make_config(false).resolve(
579            PathBuf::from("/project"),
580            OutputFormat::Human,
581            1,
582            true,
583            true,
584        );
585        assert!(
586            resolved
587                .ignore_patterns
588                .is_match("coverage/lcov-report/index.js")
589        );
590    }
591
592    #[test]
593    fn resolve_source_files_not_ignored_by_default() {
594        let resolved = make_config(false).resolve(
595            PathBuf::from("/project"),
596            OutputFormat::Human,
597            1,
598            true,
599            true,
600        );
601        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
602        assert!(
603            !resolved
604                .ignore_patterns
605                .is_match("src/components/Button.tsx")
606        );
607        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
608    }
609
610    // ── Custom ignore patterns ──────────────────────────────────────
611
612    #[test]
613    fn resolve_custom_ignore_patterns_merged_with_defaults() {
614        let mut config = make_config(false);
615        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
616        let resolved = config.resolve(
617            PathBuf::from("/project"),
618            OutputFormat::Human,
619            1,
620            true,
621            true,
622        );
623        // Custom pattern works
624        assert!(
625            resolved
626                .ignore_patterns
627                .is_match("src/__generated__/types.ts")
628        );
629        // Default patterns still work
630        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
631    }
632
633    // ── Config fields passthrough ───────────────────────────────────
634
635    #[test]
636    fn resolve_passes_through_entry_patterns() {
637        let mut config = make_config(false);
638        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
639        let resolved = config.resolve(
640            PathBuf::from("/project"),
641            OutputFormat::Human,
642            1,
643            true,
644            true,
645        );
646        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
647    }
648
649    #[test]
650    fn resolve_passes_through_ignore_dependencies() {
651        let mut config = make_config(false);
652        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
653        let resolved = config.resolve(
654            PathBuf::from("/project"),
655            OutputFormat::Human,
656            1,
657            true,
658            true,
659        );
660        assert_eq!(
661            resolved.ignore_dependencies,
662            vec!["postcss", "autoprefixer"]
663        );
664    }
665
666    #[test]
667    fn resolve_sets_cache_dir() {
668        let resolved = make_config(false).resolve(
669            PathBuf::from("/my/project"),
670            OutputFormat::Human,
671            1,
672            true,
673            true,
674        );
675        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
676    }
677
678    #[test]
679    fn resolve_passes_through_thread_count() {
680        let resolved = make_config(false).resolve(
681            PathBuf::from("/project"),
682            OutputFormat::Human,
683            8,
684            true,
685            true,
686        );
687        assert_eq!(resolved.threads, 8);
688    }
689
690    #[test]
691    fn resolve_passes_through_quiet_flag() {
692        let resolved = make_config(false).resolve(
693            PathBuf::from("/project"),
694            OutputFormat::Human,
695            1,
696            true,
697            false,
698        );
699        assert!(!resolved.quiet);
700
701        let resolved2 = make_config(false).resolve(
702            PathBuf::from("/project"),
703            OutputFormat::Human,
704            1,
705            true,
706            true,
707        );
708        assert!(resolved2.quiet);
709    }
710
711    #[test]
712    fn resolve_passes_through_no_cache_flag() {
713        let resolved_no_cache = make_config(false).resolve(
714            PathBuf::from("/project"),
715            OutputFormat::Human,
716            1,
717            true,
718            true,
719        );
720        assert!(resolved_no_cache.no_cache);
721
722        let resolved_with_cache = make_config(false).resolve(
723            PathBuf::from("/project"),
724            OutputFormat::Human,
725            1,
726            false,
727            true,
728        );
729        assert!(!resolved_with_cache.no_cache);
730    }
731
732    // ── Override resolution edge cases ───────────────────────────────
733
734    #[test]
735    fn resolve_override_with_invalid_glob_skipped() {
736        let mut config = make_config(false);
737        config.overrides = vec![ConfigOverride {
738            files: vec!["[invalid".to_string()],
739            rules: PartialRulesConfig {
740                unused_files: Some(Severity::Off),
741                ..Default::default()
742            },
743        }];
744        let resolved = config.resolve(
745            PathBuf::from("/project"),
746            OutputFormat::Human,
747            1,
748            true,
749            true,
750        );
751        // Invalid glob should be skipped, so no overrides should be compiled
752        assert!(
753            resolved.overrides.is_empty(),
754            "override with invalid glob should be skipped"
755        );
756    }
757
758    #[test]
759    fn resolve_override_with_empty_files_skipped() {
760        let mut config = make_config(false);
761        config.overrides = vec![ConfigOverride {
762            files: vec![],
763            rules: PartialRulesConfig {
764                unused_files: Some(Severity::Off),
765                ..Default::default()
766            },
767        }];
768        let resolved = config.resolve(
769            PathBuf::from("/project"),
770            OutputFormat::Human,
771            1,
772            true,
773            true,
774        );
775        assert!(
776            resolved.overrides.is_empty(),
777            "override with no file patterns should be skipped"
778        );
779    }
780
781    #[test]
782    fn resolve_multiple_valid_overrides() {
783        let mut config = make_config(false);
784        config.overrides = vec![
785            ConfigOverride {
786                files: vec!["*.test.ts".to_string()],
787                rules: PartialRulesConfig {
788                    unused_exports: Some(Severity::Off),
789                    ..Default::default()
790                },
791            },
792            ConfigOverride {
793                files: vec!["*.stories.tsx".to_string()],
794                rules: PartialRulesConfig {
795                    unused_files: Some(Severity::Off),
796                    ..Default::default()
797                },
798            },
799        ];
800        let resolved = config.resolve(
801            PathBuf::from("/project"),
802            OutputFormat::Human,
803            1,
804            true,
805            true,
806        );
807        assert_eq!(resolved.overrides.len(), 2);
808    }
809
810    // ── IgnoreExportRule ────────────────────────────────────────────
811
812    #[test]
813    fn ignore_export_rule_deserialize() {
814        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
815        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
816        assert_eq!(rule.file, "src/types/*.ts");
817        assert_eq!(rule.exports, vec!["*"]);
818    }
819
820    #[test]
821    fn ignore_export_rule_specific_exports() {
822        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
823        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
824        assert_eq!(rule.exports.len(), 3);
825        assert!(rule.exports.contains(&"FOO".to_string()));
826    }
827
828    mod proptests {
829        use super::*;
830        use proptest::prelude::*;
831
832        fn arb_resolved_config(production: bool) -> ResolvedConfig {
833            make_config(production).resolve(
834                PathBuf::from("/project"),
835                OutputFormat::Human,
836                1,
837                true,
838                true,
839            )
840        }
841
842        proptest! {
843            /// Resolved config always has non-empty ignore patterns (defaults are always added).
844            #[test]
845            fn resolved_config_has_default_ignores(production in any::<bool>()) {
846                let resolved = arb_resolved_config(production);
847                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
848                prop_assert!(
849                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
850                    "Default ignore should match node_modules"
851                );
852                prop_assert!(
853                    resolved.ignore_patterns.is_match("dist/bundle.js"),
854                    "Default ignore should match dist"
855                );
856            }
857
858            /// Production mode always forces dev and optional deps to Off.
859            #[test]
860            fn production_forces_dev_deps_off(_unused in Just(())) {
861                let resolved = arb_resolved_config(true);
862                prop_assert_eq!(
863                    resolved.rules.unused_dev_dependencies,
864                    Severity::Off,
865                    "Production should force unused_dev_dependencies off"
866                );
867                prop_assert_eq!(
868                    resolved.rules.unused_optional_dependencies,
869                    Severity::Off,
870                    "Production should force unused_optional_dependencies off"
871                );
872            }
873
874            /// Non-production mode preserves default severity for dev deps.
875            #[test]
876            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
877                let resolved = arb_resolved_config(false);
878                prop_assert_eq!(
879                    resolved.rules.unused_dev_dependencies,
880                    Severity::Error,
881                    "Non-production should keep default dev dep severity"
882                );
883            }
884
885            /// Cache dir is always root/.fallow.
886            #[test]
887            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
888                let root = PathBuf::from(format!("/project/{dir_suffix}"));
889                let expected_cache = root.join(".fallow");
890                let resolved = make_config(false).resolve(
891                    root,
892                    OutputFormat::Human,
893                    1,
894                    true,
895                    true,
896                );
897                prop_assert_eq!(
898                    resolved.cache_dir, expected_cache,
899                    "Cache dir should be root/.fallow"
900                );
901            }
902
903            /// Thread count is always passed through exactly.
904            #[test]
905            fn threads_passed_through(threads in 1..64usize) {
906                let resolved = make_config(false).resolve(
907                    PathBuf::from("/project"),
908                    OutputFormat::Human,
909                    threads,
910                    true,
911                    true,
912                );
913                prop_assert_eq!(
914                    resolved.threads, threads,
915                    "Thread count should be passed through"
916                );
917            }
918
919            /// Custom ignore patterns are merged with defaults, not replacing them.
920            #[test]
921            fn custom_ignores_dont_replace_defaults(pattern in "[a-zA-Z0-9_*/.]{1,30}") {
922                let mut config = make_config(false);
923                config.ignore_patterns = vec![pattern];
924                let resolved = config.resolve(
925                    PathBuf::from("/project"),
926                    OutputFormat::Human,
927                    1,
928                    true,
929                    true,
930                );
931                // Defaults should still be present
932                prop_assert!(
933                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
934                    "Default node_modules ignore should still be active"
935                );
936            }
937        }
938    }
939
940    // ── Boundary preset expansion ──────────────────────────────────
941
942    #[test]
943    fn resolve_expands_boundary_preset() {
944        use crate::config::boundaries::BoundaryPreset;
945
946        let mut config = make_config(false);
947        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
948        let resolved = config.resolve(
949            PathBuf::from("/project"),
950            OutputFormat::Human,
951            1,
952            true,
953            true,
954        );
955        // Preset should have been expanded into zones (no tsconfig → fallback to "src")
956        assert_eq!(resolved.boundaries.zones.len(), 3);
957        assert_eq!(resolved.boundaries.rules.len(), 3);
958        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
959        assert_eq!(
960            resolved.boundaries.classify_zone("src/adapters/http.ts"),
961            Some("adapters")
962        );
963    }
964
965    #[test]
966    fn resolve_boundary_preset_with_user_override() {
967        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
968
969        let mut config = make_config(false);
970        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
971        config.boundaries.zones = vec![BoundaryZone {
972            name: "domain".to_string(),
973            patterns: vec!["src/core/**".to_string()],
974            root: None,
975        }];
976        let resolved = config.resolve(
977            PathBuf::from("/project"),
978            OutputFormat::Human,
979            1,
980            true,
981            true,
982        );
983        // User zone "domain" replaced preset zone "domain"
984        assert_eq!(resolved.boundaries.zones.len(), 3);
985        // The user's pattern should be used for domain zone
986        assert_eq!(
987            resolved.boundaries.classify_zone("src/core/user.ts"),
988            Some("domain")
989        );
990        // Original preset pattern should NOT match
991        assert_eq!(
992            resolved.boundaries.classify_zone("src/domain/user.ts"),
993            None
994        );
995    }
996
997    #[test]
998    fn resolve_no_preset_unchanged() {
999        let config = make_config(false);
1000        let resolved = config.resolve(
1001            PathBuf::from("/project"),
1002            OutputFormat::Human,
1003            1,
1004            true,
1005            true,
1006        );
1007        assert!(resolved.boundaries.is_empty());
1008    }
1009}