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