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