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