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