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