Skip to main content

fallow_config/config/
resolution.rs

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