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