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