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