Skip to main content

fallow_config/config/
resolution.rs

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