Skip to main content

fallow_config/config/
resolution.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::IgnoreExportsUsedInFileConfig;
22use super::{FallowConfig, SecurityConfig};
23
24/// Process-local dedup state for inter-file rule warnings.
25static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
26
27/// Stable hash of `(rule_name, sorted glob list)`.
28fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
29    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
30    sorted.sort_unstable();
31    let mut hasher = DefaultHasher::new();
32    rule_name.hash(&mut hasher);
33    for s in &sorted {
34        s.hash(&mut hasher);
35    }
36    hasher.finish()
37}
38
39/// Returns `true` if this warning has not yet fired in the current process.
40fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
41    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
42    let key = inter_file_warn_key(rule_name, files);
43    seen.lock().map_or(true, |mut set| set.insert(key))
44}
45
46#[cfg(test)]
47fn reset_inter_file_warn_dedup_for_test() {
48    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
49        && let Ok(mut set) = seen.lock()
50    {
51        set.clear();
52    }
53}
54
55/// Rule for ignoring specific exports.
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
57pub struct IgnoreExportRule {
58    /// Glob pattern for files.
59    pub file: String,
60    /// Export names to ignore (`*` for all).
61    pub exports: Vec<String>,
62}
63
64/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
65#[derive(Debug)]
66pub struct CompiledIgnoreExportRule {
67    pub matcher: globset::GlobMatcher,
68    pub exports: Vec<String>,
69}
70
71/// Rule for suppressing an `unresolved-catalog-reference` finding.
72#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct IgnoreCatalogReferenceRule {
75    pub package: String,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub catalog: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub consumer: Option<String>,
80}
81
82/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
83#[derive(Debug)]
84pub struct CompiledIgnoreCatalogReferenceRule {
85    pub package: String,
86    pub catalog: Option<String>,
87    pub consumer_matcher: Option<globset::GlobMatcher>,
88}
89
90impl CompiledIgnoreCatalogReferenceRule {
91    /// Whether this rule suppresses an `unresolved-catalog-reference` finding.
92    #[must_use]
93    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
94        if self.package != package {
95            return false;
96        }
97        if let Some(catalog_filter) = &self.catalog
98            && catalog_filter != catalog
99        {
100            return false;
101        }
102        if let Some(matcher) = &self.consumer_matcher
103            && !matcher.is_match(consumer_path)
104        {
105            return false;
106        }
107        true
108    }
109}
110
111/// Rule for suppressing dependency-override findings.
112#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114pub struct IgnoreDependencyOverrideRule {
115    pub package: String,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub source: Option<String>,
118}
119
120/// `IgnoreDependencyOverrideRule` ready for matching.
121#[derive(Debug)]
122pub struct CompiledIgnoreDependencyOverrideRule {
123    pub package: String,
124    pub source: Option<String>,
125}
126
127impl CompiledIgnoreDependencyOverrideRule {
128    /// Whether this rule suppresses a dependency-override finding.
129    #[must_use]
130    pub fn matches(&self, package: &str, source_label: &str) -> bool {
131        if self.package != package {
132            return false;
133        }
134        if let Some(source_filter) = &self.source
135            && source_filter != source_label
136        {
137            return false;
138        }
139        true
140    }
141}
142
143/// Per-file override entry.
144#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
145#[serde(rename_all = "camelCase")]
146pub struct ConfigOverride {
147    pub files: Vec<String>,
148    #[serde(default)]
149    pub rules: PartialRulesConfig,
150}
151
152/// Resolved override with pre-compiled glob matchers.
153#[derive(Debug)]
154pub struct ResolvedOverride {
155    pub matchers: Vec<globset::GlobMatcher>,
156    pub rules: PartialRulesConfig,
157}
158
159/// Fully resolved configuration with all globs pre-compiled.
160#[derive(Debug)]
161pub struct ResolvedConfig {
162    pub root: PathBuf,
163    pub entry_patterns: Vec<String>,
164    pub ignore_patterns: GlobSet,
165    pub output: OutputFormat,
166    pub cache_dir: PathBuf,
167    pub threads: usize,
168    pub no_cache: bool,
169    pub cache_max_size_mb: Option<u32>,
170    pub cache_config_hash: u64,
171    pub ignore_dependencies: Vec<String>,
172    pub ignore_unresolved_imports: Vec<GlobMatcher>,
173    pub ignore_export_rules: Vec<IgnoreExportRule>,
174    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
175    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
176    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
177    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
178    pub used_class_members: Vec<UsedClassMemberRule>,
179    pub ignore_decorators: Vec<String>,
180    pub duplicates: DuplicatesConfig,
181    pub health: HealthConfig,
182    pub rules: RulesConfig,
183    pub boundaries: ResolvedBoundaryConfig,
184    pub production: bool,
185    pub quiet: bool,
186    pub external_plugins: Vec<ExternalPluginDef>,
187    pub dynamically_loaded: Vec<String>,
188    pub overrides: Vec<ResolvedOverride>,
189    pub regression: Option<super::RegressionConfig>,
190    pub audit: super::AuditConfig,
191    pub codeowners: Option<String>,
192    pub public_packages: Vec<String>,
193    pub flags: FlagsConfig,
194    pub security: SecurityConfig,
195    pub fix: super::FixConfig,
196    pub resolve: ResolveConfig,
197    pub include_entry_exports: bool,
198    pub auto_imports: bool,
199}
200
201/// Compute the cache-invalidation hash over extraction-affecting config fields.
202fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
203    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
204    names.sort_unstable();
205    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
206    for name in names {
207        hasher.update(&(name.len() as u32).to_le_bytes());
208        hasher.update(name.as_bytes());
209    }
210    hasher.digest()
211}
212
213fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
214    let Some(dir) = configured else {
215        return root.join(".fallow");
216    };
217    if dir.is_absolute() {
218        dir
219    } else {
220        root.join(dir)
221    }
222}
223
224impl FallowConfig {
225    /// Resolve into a fully resolved config with compiled globs.
226    #[expect(
227        clippy::expect_used,
228        reason = "user glob patterns are validated before config resolution"
229    )]
230    pub fn resolve(
231        self,
232        root: PathBuf,
233        output: OutputFormat,
234        threads: usize,
235        no_cache: bool,
236        quiet: bool,
237        cache_max_size_mb: Option<u32>,
238    ) -> ResolvedConfig {
239        let mut ignore_builder = GlobSetBuilder::new();
240        for pattern in &self.ignore_patterns {
241            ignore_builder.add(
242                Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
243            );
244        }
245
246        let default_ignores = [
247            "**/node_modules/**",
248            "**/dist/**",
249            "build/**",
250            "**/.git/**",
251            "**/coverage/**",
252            "**/*.min.js",
253            "**/*.min.mjs",
254        ];
255        for pattern in &default_ignores {
256            ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
257        }
258
259        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
260        let ignore_unresolved_imports: Vec<GlobMatcher> = self
261            .ignore_unresolved_imports
262            .iter()
263            .map(|pattern| {
264                Glob::new(pattern)
265                    .expect("ignoreUnresolvedImports entry was validated at config load time")
266                    .compile_matcher()
267            })
268            .collect();
269        let cache_dir = resolve_cache_dir(&root, self.cache.dir.clone());
270
271        let mut rules = self.rules;
272
273        let production = self.production.global();
274        if production {
275            rules.unused_dev_dependencies = Severity::Off;
276            rules.unused_optional_dependencies = Severity::Off;
277        }
278
279        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
280        external_plugins.extend(self.framework);
281
282        let mut boundaries = self.boundaries;
283        if boundaries.preset.is_some() {
284            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
285                .filter(|r| {
286                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
287                })
288                .unwrap_or_else(|| "src".to_owned());
289            if source_root != "src" {
290                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
291            }
292            boundaries.expand(&source_root);
293        }
294        let logical_groups = boundaries.expand_auto_discover(&root);
295
296        let mut boundaries = boundaries.resolve();
297        boundaries.logical_groups = logical_groups;
298
299        let overrides = self
300            .overrides
301            .into_iter()
302            .filter_map(|o| {
303                if o.rules.duplicate_exports.is_some()
304                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
305                {
306                    let files = o.files.join(", ");
307                    tracing::warn!(
308                        "overrides.rules.duplicate-exports has no effect for files matching [{files}]: duplicate-exports is an inter-file rule. Use top-level `ignoreExports` to exclude these files from duplicate-export grouping."
309                    );
310                }
311                if o.rules.circular_dependencies.is_some()
312                    && record_inter_file_warn_seen("circular-dependency", &o.files)
313                {
314                    let files = o.files.join(", ");
315                    tracing::warn!(
316                        "overrides.rules.circular-dependency has no effect for files matching [{files}]: circular-dependency is an inter-file rule. Use a file-level `// fallow-ignore-file circular-dependency` comment in one participating file instead."
317                    );
318                }
319                if o.rules.re_export_cycle.is_some()
320                    && record_inter_file_warn_seen("re-export-cycle", &o.files)
321                {
322                    let files = o.files.join(", ");
323                    tracing::warn!(
324                        "overrides.rules.re-export-cycle has no effect for files matching [{files}]: re-export-cycle is an inter-file rule (the cycle spans multiple barrels). Use a file-level `// fallow-ignore-file re-export-cycle` comment in one participating file instead, or set `rules.re-export-cycle: off` at the top level."
325                    );
326                }
327                let matchers: Vec<globset::GlobMatcher> = o
328                    .files
329                    .iter()
330                    .map(|pattern| {
331                        Glob::new(pattern)
332                            .expect("overrides[].files pattern was validated at config load time")
333                            .compile_matcher()
334                    })
335                    .collect();
336                if matchers.is_empty() {
337                    None
338                } else {
339                    Some(ResolvedOverride {
340                        matchers,
341                        rules: o.rules,
342                    })
343                }
344            })
345            .collect();
346
347        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
348            .ignore_exports
349            .iter()
350            .map(|rule| CompiledIgnoreExportRule {
351                matcher: Glob::new(&rule.file)
352                    .expect("ignoreExports[].file was validated at config load time")
353                    .compile_matcher(),
354                exports: rule.exports.clone(),
355            })
356            .collect();
357
358        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
359            .ignore_catalog_references
360            .iter()
361            .map(|rule| CompiledIgnoreCatalogReferenceRule {
362                package: rule.package.clone(),
363                catalog: rule.catalog.clone(),
364                consumer_matcher: rule.consumer.as_ref().map(|pattern| {
365                    Glob::new(pattern)
366                        .expect(
367                            "ignoreCatalogReferences[].consumer was validated at config load time",
368                        )
369                        .compile_matcher()
370                }),
371            })
372            .collect();
373
374        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
375            .ignore_dependency_overrides
376            .iter()
377            .map(|rule| CompiledIgnoreDependencyOverrideRule {
378                package: rule.package.clone(),
379                source: rule.source.clone(),
380            })
381            .collect();
382
383        let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
384
385        let cache_config_hash = if no_cache {
386            0
387        } else {
388            compute_cache_config_hash(&external_plugins)
389        };
390
391        ResolvedConfig {
392            root,
393            entry_patterns: self.entry,
394            ignore_patterns: compiled_ignore_patterns,
395            output,
396            cache_dir,
397            threads,
398            no_cache,
399            cache_max_size_mb,
400            cache_config_hash,
401            ignore_dependencies: self.ignore_dependencies,
402            ignore_unresolved_imports,
403            ignore_export_rules: self.ignore_exports,
404            compiled_ignore_exports,
405            compiled_ignore_catalog_references,
406            compiled_ignore_dependency_overrides,
407            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
408            used_class_members: self.used_class_members,
409            ignore_decorators: self.ignore_decorators,
410            duplicates: self.duplicates,
411            health: self.health,
412            rules,
413            boundaries,
414            production,
415            quiet,
416            external_plugins,
417            dynamically_loaded: self.dynamically_loaded,
418            overrides,
419            regression: self.regression,
420            audit: self.audit,
421            codeowners: self.codeowners,
422            public_packages: self.public_packages,
423            flags: self.flags,
424            security: self.security,
425            fix: self.fix,
426            resolve: self.resolve,
427            include_entry_exports: self.include_entry_exports,
428            auto_imports: self.auto_imports,
429        }
430    }
431}
432
433impl ResolvedConfig {
434    /// Resolve the effective rules for a given file path.
435    /// Starts with base rules and applies matching overrides in order.
436    #[must_use]
437    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
438        if self.overrides.is_empty() {
439            return self.rules.clone();
440        }
441
442        let relative = path.strip_prefix(&self.root).unwrap_or(path);
443        let relative_str = relative.to_string_lossy();
444
445        let mut rules = self.rules.clone();
446        for override_entry in &self.overrides {
447            let matches = override_entry
448                .matchers
449                .iter()
450                .any(|m| m.is_match(relative_str.as_ref()));
451            if matches {
452                rules.apply_partial(&override_entry.rules);
453            }
454        }
455        rules
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::CacheConfig;
463    use crate::config::boundaries::BoundaryConfig;
464    use crate::config::health::HealthConfig;
465
466    #[test]
467    fn overrides_deserialize() {
468        let json_str = r#"{
469            "overrides": [{
470                "files": ["*.test.ts"],
471                "rules": {
472                    "unused-exports": "off"
473                }
474            }]
475        }"#;
476        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
477        assert_eq!(config.overrides.len(), 1);
478        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
479        assert_eq!(
480            config.overrides[0].rules.unused_exports,
481            Some(Severity::Off)
482        );
483        assert_eq!(config.overrides[0].rules.unused_files, None);
484    }
485
486    #[test]
487    fn resolve_rules_for_path_no_overrides() {
488        let config = FallowConfig {
489            schema: None,
490            extends: vec![],
491            entry: vec![],
492            ignore_patterns: vec![],
493            framework: vec![],
494            workspaces: None,
495            ignore_dependencies: vec![],
496            ignore_unresolved_imports: vec![],
497            ignore_exports: vec![],
498            ignore_catalog_references: vec![],
499            ignore_dependency_overrides: vec![],
500            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
501            used_class_members: vec![],
502            ignore_decorators: vec![],
503            duplicates: DuplicatesConfig::default(),
504            health: HealthConfig::default(),
505            rules: RulesConfig::default(),
506            boundaries: BoundaryConfig::default(),
507            production: false.into(),
508            plugins: vec![],
509            dynamically_loaded: vec![],
510            overrides: vec![],
511            regression: None,
512            audit: crate::config::AuditConfig::default(),
513            codeowners: None,
514            public_packages: vec![],
515            flags: FlagsConfig::default(),
516            security: SecurityConfig::default(),
517            fix: crate::config::FixConfig::default(),
518            resolve: ResolveConfig::default(),
519            sealed: false,
520            include_entry_exports: false,
521            auto_imports: false,
522            cache: CacheConfig::default(),
523        };
524        let resolved = config.resolve(
525            PathBuf::from("/project"),
526            OutputFormat::Human,
527            1,
528            true,
529            true,
530            None,
531        );
532        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
533        assert_eq!(rules.unused_files, Severity::Error);
534    }
535
536    #[test]
537    fn resolve_rules_for_path_with_matching_override() {
538        let config = FallowConfig {
539            schema: None,
540            extends: vec![],
541            entry: vec![],
542            ignore_patterns: vec![],
543            framework: vec![],
544            workspaces: None,
545            ignore_dependencies: vec![],
546            ignore_unresolved_imports: vec![],
547            ignore_exports: vec![],
548            ignore_catalog_references: vec![],
549            ignore_dependency_overrides: vec![],
550            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
551            used_class_members: vec![],
552            ignore_decorators: vec![],
553            duplicates: DuplicatesConfig::default(),
554            health: HealthConfig::default(),
555            rules: RulesConfig::default(),
556            boundaries: BoundaryConfig::default(),
557            production: false.into(),
558            plugins: vec![],
559            dynamically_loaded: vec![],
560            overrides: vec![ConfigOverride {
561                files: vec!["*.test.ts".to_string()],
562                rules: PartialRulesConfig {
563                    unused_exports: Some(Severity::Off),
564                    ..Default::default()
565                },
566            }],
567            regression: None,
568            audit: crate::config::AuditConfig::default(),
569            codeowners: None,
570            public_packages: vec![],
571            flags: FlagsConfig::default(),
572            security: SecurityConfig::default(),
573            fix: crate::config::FixConfig::default(),
574            resolve: ResolveConfig::default(),
575            sealed: false,
576            include_entry_exports: false,
577            auto_imports: false,
578            cache: CacheConfig::default(),
579        };
580        let resolved = config.resolve(
581            PathBuf::from("/project"),
582            OutputFormat::Human,
583            1,
584            true,
585            true,
586            None,
587        );
588
589        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
590        assert_eq!(test_rules.unused_exports, Severity::Off);
591        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
592
593        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
594        assert_eq!(src_rules.unused_exports, Severity::Error);
595    }
596
597    #[test]
598    fn resolve_rules_for_path_later_override_wins() {
599        let config = FallowConfig {
600            schema: None,
601            extends: vec![],
602            entry: vec![],
603            ignore_patterns: vec![],
604            framework: vec![],
605            workspaces: None,
606            ignore_dependencies: vec![],
607            ignore_unresolved_imports: vec![],
608            ignore_exports: vec![],
609            ignore_catalog_references: vec![],
610            ignore_dependency_overrides: vec![],
611            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
612            used_class_members: vec![],
613            ignore_decorators: vec![],
614            duplicates: DuplicatesConfig::default(),
615            health: HealthConfig::default(),
616            rules: RulesConfig::default(),
617            boundaries: BoundaryConfig::default(),
618            production: false.into(),
619            plugins: vec![],
620            dynamically_loaded: vec![],
621            overrides: vec![
622                ConfigOverride {
623                    files: vec!["*.ts".to_string()],
624                    rules: PartialRulesConfig {
625                        unused_files: Some(Severity::Warn),
626                        ..Default::default()
627                    },
628                },
629                ConfigOverride {
630                    files: vec!["*.test.ts".to_string()],
631                    rules: PartialRulesConfig {
632                        unused_files: Some(Severity::Off),
633                        ..Default::default()
634                    },
635                },
636            ],
637            regression: None,
638            audit: crate::config::AuditConfig::default(),
639            codeowners: None,
640            public_packages: vec![],
641            flags: FlagsConfig::default(),
642            security: SecurityConfig::default(),
643            fix: crate::config::FixConfig::default(),
644            resolve: ResolveConfig::default(),
645            sealed: false,
646            include_entry_exports: false,
647            auto_imports: false,
648            cache: CacheConfig::default(),
649        };
650        let resolved = config.resolve(
651            PathBuf::from("/project"),
652            OutputFormat::Human,
653            1,
654            true,
655            true,
656            None,
657        );
658
659        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
660        assert_eq!(rules.unused_files, Severity::Off);
661
662        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
663        assert_eq!(rules2.unused_files, Severity::Warn);
664    }
665
666    #[test]
667    fn resolve_keeps_inter_file_rule_override_after_warning() {
668        let config = FallowConfig {
669            schema: None,
670            extends: vec![],
671            entry: vec![],
672            ignore_patterns: vec![],
673            framework: vec![],
674            workspaces: None,
675            ignore_dependencies: vec![],
676            ignore_unresolved_imports: vec![],
677            ignore_exports: vec![],
678            ignore_catalog_references: vec![],
679            ignore_dependency_overrides: vec![],
680            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
681            used_class_members: vec![],
682            ignore_decorators: vec![],
683            duplicates: DuplicatesConfig::default(),
684            health: HealthConfig::default(),
685            rules: RulesConfig::default(),
686            boundaries: BoundaryConfig::default(),
687            production: false.into(),
688            plugins: vec![],
689            dynamically_loaded: vec![],
690            overrides: vec![ConfigOverride {
691                files: vec!["**/ui/**".to_string()],
692                rules: PartialRulesConfig {
693                    duplicate_exports: Some(Severity::Off),
694                    unused_files: Some(Severity::Warn),
695                    ..Default::default()
696                },
697            }],
698            regression: None,
699            audit: crate::config::AuditConfig::default(),
700            codeowners: None,
701            public_packages: vec![],
702            flags: FlagsConfig::default(),
703            security: SecurityConfig::default(),
704            fix: crate::config::FixConfig::default(),
705            resolve: ResolveConfig::default(),
706            sealed: false,
707            include_entry_exports: false,
708            auto_imports: false,
709            cache: CacheConfig::default(),
710        };
711        let resolved = config.resolve(
712            PathBuf::from("/project"),
713            OutputFormat::Human,
714            1,
715            true,
716            true,
717            None,
718        );
719        assert_eq!(
720            resolved.overrides.len(),
721            1,
722            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
723        );
724        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
725        assert_eq!(rules.unused_files, Severity::Warn);
726    }
727
728    #[test]
729    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
730        reset_inter_file_warn_dedup_for_test();
731        let files_a = vec!["__test_dedup_a/*".to_string()];
732        let files_b = vec!["__test_dedup_b/*".to_string()];
733
734        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
735        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
736        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
737
738        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
739        assert!(!record_inter_file_warn_seen(
740            "circular-dependency",
741            &files_a
742        ));
743
744        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
745
746        let files_reordered = vec![
747            "__test_dedup_b/*".to_string(),
748            "__test_dedup_a/*".to_string(),
749        ];
750        let files_natural = vec![
751            "__test_dedup_a/*".to_string(),
752            "__test_dedup_b/*".to_string(),
753        ];
754        reset_inter_file_warn_dedup_for_test();
755        assert!(record_inter_file_warn_seen(
756            "duplicate-exports",
757            &files_natural
758        ));
759        assert!(!record_inter_file_warn_seen(
760            "duplicate-exports",
761            &files_reordered
762        ));
763    }
764
765    #[test]
766    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
767        reset_inter_file_warn_dedup_for_test();
768        let files = vec!["__test_resolve_dedup/**".to_string()];
769        let build_config = || FallowConfig {
770            schema: None,
771            extends: vec![],
772            entry: vec![],
773            ignore_patterns: vec![],
774            framework: vec![],
775            workspaces: None,
776            ignore_dependencies: vec![],
777            ignore_unresolved_imports: vec![],
778            ignore_exports: vec![],
779            ignore_catalog_references: vec![],
780            ignore_dependency_overrides: vec![],
781            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
782            used_class_members: vec![],
783            ignore_decorators: vec![],
784            duplicates: DuplicatesConfig::default(),
785            health: HealthConfig::default(),
786            rules: RulesConfig::default(),
787            boundaries: BoundaryConfig::default(),
788            production: false.into(),
789            plugins: vec![],
790            dynamically_loaded: vec![],
791            overrides: vec![ConfigOverride {
792                files: files.clone(),
793                rules: PartialRulesConfig {
794                    duplicate_exports: Some(Severity::Off),
795                    ..Default::default()
796                },
797            }],
798            regression: None,
799            audit: crate::config::AuditConfig::default(),
800            codeowners: None,
801            public_packages: vec![],
802            flags: FlagsConfig::default(),
803            security: SecurityConfig::default(),
804            fix: crate::config::FixConfig::default(),
805            resolve: ResolveConfig::default(),
806            sealed: false,
807            include_entry_exports: false,
808            auto_imports: false,
809            cache: CacheConfig::default(),
810        };
811        for _ in 0..10 {
812            let _ = build_config().resolve(
813                PathBuf::from("/project"),
814                OutputFormat::Human,
815                1,
816                true,
817                true,
818                None,
819            );
820        }
821        assert!(
822            !record_inter_file_warn_seen("duplicate-exports", &files),
823            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
824        );
825    }
826
827    /// Helper to build a FallowConfig with minimal boilerplate.
828    fn make_config(production: bool) -> FallowConfig {
829        FallowConfig {
830            schema: None,
831            extends: vec![],
832            entry: vec![],
833            ignore_patterns: vec![],
834            framework: vec![],
835            workspaces: None,
836            ignore_dependencies: vec![],
837            ignore_unresolved_imports: vec![],
838            ignore_exports: vec![],
839            ignore_catalog_references: vec![],
840            ignore_dependency_overrides: vec![],
841            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
842            used_class_members: vec![],
843            ignore_decorators: vec![],
844            duplicates: DuplicatesConfig::default(),
845            health: HealthConfig::default(),
846            rules: RulesConfig::default(),
847            boundaries: BoundaryConfig::default(),
848            production: production.into(),
849            plugins: vec![],
850            dynamically_loaded: vec![],
851            overrides: vec![],
852            regression: None,
853            audit: crate::config::AuditConfig::default(),
854            codeowners: None,
855            public_packages: vec![],
856            flags: FlagsConfig::default(),
857            security: SecurityConfig::default(),
858            fix: crate::config::FixConfig::default(),
859            resolve: ResolveConfig::default(),
860            sealed: false,
861            include_entry_exports: false,
862            auto_imports: false,
863            cache: CacheConfig::default(),
864        }
865    }
866
867    #[test]
868    fn resolve_production_forces_dev_deps_off() {
869        let resolved = make_config(true).resolve(
870            PathBuf::from("/project"),
871            OutputFormat::Human,
872            1,
873            true,
874            true,
875            None,
876        );
877        assert_eq!(
878            resolved.rules.unused_dev_dependencies,
879            Severity::Off,
880            "production mode should force unused_dev_dependencies to off"
881        );
882    }
883
884    #[test]
885    fn resolve_production_forces_optional_deps_off() {
886        let resolved = make_config(true).resolve(
887            PathBuf::from("/project"),
888            OutputFormat::Human,
889            1,
890            true,
891            true,
892            None,
893        );
894        assert_eq!(
895            resolved.rules.unused_optional_dependencies,
896            Severity::Off,
897            "production mode should force unused_optional_dependencies to off"
898        );
899    }
900
901    #[test]
902    fn resolve_production_preserves_other_rules() {
903        let resolved = make_config(true).resolve(
904            PathBuf::from("/project"),
905            OutputFormat::Human,
906            1,
907            true,
908            true,
909            None,
910        );
911        assert_eq!(resolved.rules.unused_files, Severity::Error);
912        assert_eq!(resolved.rules.unused_exports, Severity::Error);
913        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
914    }
915
916    #[test]
917    fn resolve_non_production_keeps_dev_deps_default() {
918        let resolved = make_config(false).resolve(
919            PathBuf::from("/project"),
920            OutputFormat::Human,
921            1,
922            true,
923            true,
924            None,
925        );
926        assert_eq!(
927            resolved.rules.unused_dev_dependencies,
928            Severity::Warn,
929            "non-production should keep default severity"
930        );
931        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
932    }
933
934    #[test]
935    fn resolve_production_flag_stored() {
936        let resolved = make_config(true).resolve(
937            PathBuf::from("/project"),
938            OutputFormat::Human,
939            1,
940            true,
941            true,
942            None,
943        );
944        assert!(resolved.production);
945
946        let resolved2 = make_config(false).resolve(
947            PathBuf::from("/project"),
948            OutputFormat::Human,
949            1,
950            true,
951            true,
952            None,
953        );
954        assert!(!resolved2.production);
955    }
956
957    #[test]
958    fn resolve_default_ignores_node_modules() {
959        let resolved = make_config(false).resolve(
960            PathBuf::from("/project"),
961            OutputFormat::Human,
962            1,
963            true,
964            true,
965            None,
966        );
967        assert!(
968            resolved
969                .ignore_patterns
970                .is_match("node_modules/lodash/index.js")
971        );
972        assert!(
973            resolved
974                .ignore_patterns
975                .is_match("packages/a/node_modules/react/index.js")
976        );
977    }
978
979    #[test]
980    fn resolve_default_ignores_dist() {
981        let resolved = make_config(false).resolve(
982            PathBuf::from("/project"),
983            OutputFormat::Human,
984            1,
985            true,
986            true,
987            None,
988        );
989        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
990        assert!(
991            resolved
992                .ignore_patterns
993                .is_match("packages/ui/dist/index.js")
994        );
995    }
996
997    #[test]
998    fn resolve_default_ignores_root_build_only() {
999        let resolved = make_config(false).resolve(
1000            PathBuf::from("/project"),
1001            OutputFormat::Human,
1002            1,
1003            true,
1004            true,
1005            None,
1006        );
1007        assert!(
1008            resolved.ignore_patterns.is_match("build/output.js"),
1009            "root build/ should be ignored"
1010        );
1011        assert!(
1012            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1013            "nested build/ should NOT be ignored by default"
1014        );
1015    }
1016
1017    #[test]
1018    fn resolve_default_ignores_minified_files() {
1019        let resolved = make_config(false).resolve(
1020            PathBuf::from("/project"),
1021            OutputFormat::Human,
1022            1,
1023            true,
1024            true,
1025            None,
1026        );
1027        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1028        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1029    }
1030
1031    #[test]
1032    fn resolve_default_ignores_git() {
1033        let resolved = make_config(false).resolve(
1034            PathBuf::from("/project"),
1035            OutputFormat::Human,
1036            1,
1037            true,
1038            true,
1039            None,
1040        );
1041        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1042    }
1043
1044    #[test]
1045    fn resolve_default_ignores_coverage() {
1046        let resolved = make_config(false).resolve(
1047            PathBuf::from("/project"),
1048            OutputFormat::Human,
1049            1,
1050            true,
1051            true,
1052            None,
1053        );
1054        assert!(
1055            resolved
1056                .ignore_patterns
1057                .is_match("coverage/lcov-report/index.js")
1058        );
1059    }
1060
1061    #[test]
1062    fn resolve_source_files_not_ignored_by_default() {
1063        let resolved = make_config(false).resolve(
1064            PathBuf::from("/project"),
1065            OutputFormat::Human,
1066            1,
1067            true,
1068            true,
1069            None,
1070        );
1071        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1072        assert!(
1073            !resolved
1074                .ignore_patterns
1075                .is_match("src/components/Button.tsx")
1076        );
1077        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1078    }
1079
1080    #[test]
1081    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1082        let mut config = make_config(false);
1083        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1084        let resolved = config.resolve(
1085            PathBuf::from("/project"),
1086            OutputFormat::Human,
1087            1,
1088            true,
1089            true,
1090            None,
1091        );
1092        assert!(
1093            resolved
1094                .ignore_patterns
1095                .is_match("src/__generated__/types.ts")
1096        );
1097        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1098    }
1099
1100    #[test]
1101    fn resolve_passes_through_entry_patterns() {
1102        let mut config = make_config(false);
1103        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1104        let resolved = config.resolve(
1105            PathBuf::from("/project"),
1106            OutputFormat::Human,
1107            1,
1108            true,
1109            true,
1110            None,
1111        );
1112        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1113    }
1114
1115    #[test]
1116    fn resolve_passes_through_ignore_dependencies() {
1117        let mut config = make_config(false);
1118        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1119        let resolved = config.resolve(
1120            PathBuf::from("/project"),
1121            OutputFormat::Human,
1122            1,
1123            true,
1124            true,
1125            None,
1126        );
1127        assert_eq!(
1128            resolved.ignore_dependencies,
1129            vec!["postcss", "autoprefixer"]
1130        );
1131    }
1132
1133    #[test]
1134    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1135        let mut config = make_config(false);
1136        config.ignore_unresolved_imports = vec![
1137            "@example/icons".to_string(),
1138            "@example/icons/**".to_string(),
1139            "../generated/**".to_string(),
1140        ];
1141        let resolved = config.resolve(
1142            PathBuf::from("/project"),
1143            OutputFormat::Human,
1144            1,
1145            true,
1146            true,
1147            None,
1148        );
1149
1150        assert!(
1151            resolved
1152                .ignore_unresolved_imports
1153                .iter()
1154                .any(|matcher| matcher.is_match("@example/icons"))
1155        );
1156        assert!(
1157            resolved
1158                .ignore_unresolved_imports
1159                .iter()
1160                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1161        );
1162        assert!(
1163            resolved
1164                .ignore_unresolved_imports
1165                .iter()
1166                .any(|matcher| matcher.is_match("../generated/client"))
1167        );
1168    }
1169
1170    #[test]
1171    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1172        let mut config = make_config(false);
1173        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1174        let resolved = config.resolve(
1175            PathBuf::from("/project"),
1176            OutputFormat::Human,
1177            1,
1178            true,
1179            true,
1180            None,
1181        );
1182
1183        assert!(
1184            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1185            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1186        );
1187        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1188    }
1189
1190    #[test]
1191    fn resolve_sets_cache_dir() {
1192        let resolved = make_config(false).resolve(
1193            PathBuf::from("/my/project"),
1194            OutputFormat::Human,
1195            1,
1196            true,
1197            true,
1198            None,
1199        );
1200        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1201    }
1202
1203    #[test]
1204    fn resolve_uses_relative_configured_cache_dir_from_root() {
1205        let config = FallowConfig {
1206            cache: crate::CacheConfig {
1207                dir: Some(PathBuf::from(".cache/fallow")),
1208                ..Default::default()
1209            },
1210            ..make_config(false)
1211        };
1212        let resolved = config.resolve(
1213            PathBuf::from("/my/project"),
1214            OutputFormat::Human,
1215            1,
1216            false,
1217            true,
1218            None,
1219        );
1220        assert_eq!(
1221            resolved.cache_dir,
1222            PathBuf::from("/my/project/.cache/fallow")
1223        );
1224    }
1225
1226    #[test]
1227    fn resolve_keeps_absolute_configured_cache_dir() {
1228        let config = FallowConfig {
1229            cache: crate::CacheConfig {
1230                dir: Some(PathBuf::from("/tmp/fallow-cache")),
1231                ..Default::default()
1232            },
1233            ..make_config(false)
1234        };
1235        let resolved = config.resolve(
1236            PathBuf::from("/my/project"),
1237            OutputFormat::Human,
1238            1,
1239            false,
1240            true,
1241            None,
1242        );
1243        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1244    }
1245
1246    #[test]
1247    fn resolve_passes_through_thread_count() {
1248        let resolved = make_config(false).resolve(
1249            PathBuf::from("/project"),
1250            OutputFormat::Human,
1251            8,
1252            true,
1253            true,
1254            None,
1255        );
1256        assert_eq!(resolved.threads, 8);
1257    }
1258
1259    #[test]
1260    fn resolve_passes_through_quiet_flag() {
1261        let resolved = make_config(false).resolve(
1262            PathBuf::from("/project"),
1263            OutputFormat::Human,
1264            1,
1265            true,
1266            false,
1267            None,
1268        );
1269        assert!(!resolved.quiet);
1270
1271        let resolved2 = make_config(false).resolve(
1272            PathBuf::from("/project"),
1273            OutputFormat::Human,
1274            1,
1275            true,
1276            true,
1277            None,
1278        );
1279        assert!(resolved2.quiet);
1280    }
1281
1282    #[test]
1283    fn resolve_passes_through_no_cache_flag() {
1284        let resolved_no_cache = make_config(false).resolve(
1285            PathBuf::from("/project"),
1286            OutputFormat::Human,
1287            1,
1288            true,
1289            true,
1290            None,
1291        );
1292        assert!(resolved_no_cache.no_cache);
1293
1294        let resolved_with_cache = make_config(false).resolve(
1295            PathBuf::from("/project"),
1296            OutputFormat::Human,
1297            1,
1298            false,
1299            true,
1300            None,
1301        );
1302        assert!(!resolved_with_cache.no_cache);
1303    }
1304
1305    #[test]
1306    #[should_panic(expected = "validated at config load time")]
1307    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1308        let mut config = make_config(false);
1309        config.overrides = vec![ConfigOverride {
1310            files: vec!["[invalid".to_string()],
1311            rules: PartialRulesConfig {
1312                unused_files: Some(Severity::Off),
1313                ..Default::default()
1314            },
1315        }];
1316        let _ = config.resolve(
1317            PathBuf::from("/project"),
1318            OutputFormat::Human,
1319            1,
1320            true,
1321            true,
1322            None,
1323        );
1324    }
1325
1326    #[test]
1327    fn resolve_override_with_empty_files_skipped() {
1328        let mut config = make_config(false);
1329        config.overrides = vec![ConfigOverride {
1330            files: vec![],
1331            rules: PartialRulesConfig {
1332                unused_files: Some(Severity::Off),
1333                ..Default::default()
1334            },
1335        }];
1336        let resolved = config.resolve(
1337            PathBuf::from("/project"),
1338            OutputFormat::Human,
1339            1,
1340            true,
1341            true,
1342            None,
1343        );
1344        assert!(
1345            resolved.overrides.is_empty(),
1346            "override with no file patterns should be skipped"
1347        );
1348    }
1349
1350    #[test]
1351    fn resolve_multiple_valid_overrides() {
1352        let mut config = make_config(false);
1353        config.overrides = vec![
1354            ConfigOverride {
1355                files: vec!["*.test.ts".to_string()],
1356                rules: PartialRulesConfig {
1357                    unused_exports: Some(Severity::Off),
1358                    ..Default::default()
1359                },
1360            },
1361            ConfigOverride {
1362                files: vec!["*.stories.tsx".to_string()],
1363                rules: PartialRulesConfig {
1364                    unused_files: Some(Severity::Off),
1365                    ..Default::default()
1366                },
1367            },
1368        ];
1369        let resolved = config.resolve(
1370            PathBuf::from("/project"),
1371            OutputFormat::Human,
1372            1,
1373            true,
1374            true,
1375            None,
1376        );
1377        assert_eq!(resolved.overrides.len(), 2);
1378    }
1379
1380    #[test]
1381    fn ignore_export_rule_deserialize() {
1382        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1383        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1384        assert_eq!(rule.file, "src/types/*.ts");
1385        assert_eq!(rule.exports, vec!["*"]);
1386    }
1387
1388    #[test]
1389    fn ignore_export_rule_specific_exports() {
1390        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1391        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1392        assert_eq!(rule.exports.len(), 3);
1393        assert!(rule.exports.contains(&"FOO".to_string()));
1394    }
1395
1396    mod proptests {
1397        use super::*;
1398        use proptest::prelude::*;
1399
1400        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1401            make_config(production).resolve(
1402                PathBuf::from("/project"),
1403                OutputFormat::Human,
1404                1,
1405                true,
1406                true,
1407                None,
1408            )
1409        }
1410
1411        proptest! {
1412            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1413            #[test]
1414            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1415                let resolved = arb_resolved_config(production);
1416                prop_assert!(
1417                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1418                    "Default ignore should match node_modules"
1419                );
1420                prop_assert!(
1421                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1422                    "Default ignore should match dist"
1423                );
1424            }
1425
1426            /// Production mode always forces dev and optional deps to Off.
1427            #[test]
1428            fn production_forces_dev_deps_off(_unused in Just(())) {
1429                let resolved = arb_resolved_config(true);
1430                prop_assert_eq!(
1431                    resolved.rules.unused_dev_dependencies,
1432                    Severity::Off,
1433                    "Production should force unused_dev_dependencies off"
1434                );
1435                prop_assert_eq!(
1436                    resolved.rules.unused_optional_dependencies,
1437                    Severity::Off,
1438                    "Production should force unused_optional_dependencies off"
1439                );
1440            }
1441
1442            /// Non-production mode preserves default severity for dev deps.
1443            #[test]
1444            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1445                let resolved = arb_resolved_config(false);
1446                prop_assert_eq!(
1447                    resolved.rules.unused_dev_dependencies,
1448                    Severity::Warn,
1449                    "Non-production should keep default dev dep severity"
1450                );
1451            }
1452
1453            /// Default cache dir is root/.fallow.
1454            #[test]
1455            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1456                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1457                let expected_cache = root.join(".fallow");
1458                let resolved = make_config(false).resolve(
1459                    root,
1460                    OutputFormat::Human,
1461                    1,
1462                    true,
1463                    true,
1464                    None,
1465                );
1466                prop_assert_eq!(
1467                    resolved.cache_dir, expected_cache,
1468                    "Default cache dir should be root/.fallow"
1469                );
1470            }
1471
1472            /// Thread count is always passed through exactly.
1473            #[test]
1474            fn threads_passed_through(threads in 1..64usize) {
1475                let resolved = make_config(false).resolve(
1476                    PathBuf::from("/project"),
1477                    OutputFormat::Human,
1478                    threads,
1479                    true,
1480                    true, None,
1481                );
1482                prop_assert_eq!(
1483                    resolved.threads, threads,
1484                    "Thread count should be passed through"
1485                );
1486            }
1487
1488            /// Custom ignore patterns are merged with defaults, not replacing them.
1489            /// Uses a pattern regex that cannot match node_modules paths, so the
1490            /// assertion proves the default pattern is what provides the match.
1491            #[test]
1492            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1493                let mut config = make_config(false);
1494                config.ignore_patterns = vec![pattern];
1495                let resolved = config.resolve(
1496                    PathBuf::from("/project"),
1497                    OutputFormat::Human,
1498                    1,
1499                    true,
1500                    true, None,
1501                );
1502                prop_assert!(
1503                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1504                    "Default node_modules ignore should still be active"
1505                );
1506            }
1507        }
1508    }
1509
1510    #[test]
1511    fn resolve_expands_boundary_preset() {
1512        use crate::config::boundaries::BoundaryPreset;
1513
1514        let mut config = make_config(false);
1515        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1516        let resolved = config.resolve(
1517            PathBuf::from("/project"),
1518            OutputFormat::Human,
1519            1,
1520            true,
1521            true,
1522            None,
1523        );
1524        assert_eq!(resolved.boundaries.zones.len(), 3);
1525        assert_eq!(resolved.boundaries.rules.len(), 3);
1526        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1527        assert_eq!(
1528            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1529            Some("adapters")
1530        );
1531    }
1532
1533    #[test]
1534    fn resolve_boundary_preset_with_user_override() {
1535        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1536
1537        let mut config = make_config(false);
1538        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1539        config.boundaries.zones = vec![BoundaryZone {
1540            name: "domain".to_string(),
1541            patterns: vec!["src/core/**".to_string()],
1542            auto_discover: vec![],
1543            root: None,
1544        }];
1545        let resolved = config.resolve(
1546            PathBuf::from("/project"),
1547            OutputFormat::Human,
1548            1,
1549            true,
1550            true,
1551            None,
1552        );
1553        assert_eq!(resolved.boundaries.zones.len(), 3);
1554        assert_eq!(
1555            resolved.boundaries.classify_zone("src/core/user.ts"),
1556            Some("domain")
1557        );
1558        assert_eq!(
1559            resolved.boundaries.classify_zone("src/domain/user.ts"),
1560            None
1561        );
1562    }
1563
1564    #[test]
1565    fn resolve_no_preset_unchanged() {
1566        let config = make_config(false);
1567        let resolved = config.resolve(
1568            PathBuf::from("/project"),
1569            OutputFormat::Human,
1570            1,
1571            true,
1572            true,
1573            None,
1574        );
1575        assert!(resolved.boundaries.is_empty());
1576    }
1577}