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