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