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::{BoundaryConfig, FallowConfig, ProductionConfig, 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    /// Rule packs loaded from the `rulePacks` config key, in config order.
185    /// Validated at config load (`load_rule_packs` is also the validation
186    /// gate in the CLI and programmatic entry points); a pack that fails to
187    /// load here is skipped with a `tracing::error!` as defense in depth.
188    pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
189    pub production: bool,
190    pub quiet: bool,
191    pub external_plugins: Vec<ExternalPluginDef>,
192    pub dynamically_loaded: Vec<String>,
193    pub overrides: Vec<ResolvedOverride>,
194    pub regression: Option<super::RegressionConfig>,
195    pub audit: super::AuditConfig,
196    pub codeowners: Option<String>,
197    pub public_packages: Vec<String>,
198    pub flags: FlagsConfig,
199    pub security: SecurityConfig,
200    pub fix: super::FixConfig,
201    pub resolve: ResolveConfig,
202    pub include_entry_exports: bool,
203    pub auto_imports: bool,
204    /// Source files strictly larger than this many bytes are skipped at
205    /// discovery (never read, parsed, or analyzed), guarding against the
206    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
207    /// causes (issue #1086). `None` means no limit. Declaration files
208    /// (`.d.ts`/`.d.mts`/`.d.cts`) are exempt regardless of size because they
209    /// are reachability roots for global types. Defaults to
210    /// [`DEFAULT_MAX_FILE_SIZE_MB`] MB; the CLI overrides it post-resolve from
211    /// `--max-file-size` / `FALLOW_MAX_FILE_SIZE` (`0` = unlimited).
212    pub max_file_size_bytes: Option<u64>,
213}
214
215/// Default per-file size ceiling (in megabytes) for source discovery. A value
216/// chosen so hand-written source effectively never reaches it while generated
217/// API clients, vendored bundles, and minified blobs do. See issue #1086.
218pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
219
220/// [`DEFAULT_MAX_FILE_SIZE_MB`] expressed in bytes.
221pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
222
223/// Convert a user-supplied megabyte ceiling into the byte limit stored on
224/// [`ResolvedConfig::max_file_size_bytes`]. `Some(0)` means "no limit"
225/// (`None`); any other `Some(n)` is `n` MB in bytes; `None` (unset) keeps the
226/// built-in [`DEFAULT_MAX_FILE_SIZE_BYTES`].
227#[must_use]
228pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
229    match max_file_size_mb {
230        None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
231        Some(0) => None,
232        Some(mb) => Some(u64::from(mb) * 1024 * 1024),
233    }
234}
235
236/// Compute the cache-invalidation hash over extraction-affecting config fields.
237fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
238    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
239    names.sort_unstable();
240    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
241    for name in names {
242        hasher.update(&(name.len() as u32).to_le_bytes());
243        hasher.update(name.as_bytes());
244    }
245    hasher.digest()
246}
247
248fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
249    let Some(dir) = configured else {
250        return root.join(".fallow");
251    };
252    if dir.is_absolute() {
253        dir
254    } else {
255        root.join(dir)
256    }
257}
258
259fn normalize_user_glob_pattern(pattern: &str) -> &str {
260    pattern.strip_prefix("./").unwrap_or(pattern)
261}
262
263#[expect(
264    clippy::expect_used,
265    reason = "user glob patterns are validated before config resolution"
266)]
267fn compile_ignore_patterns(ignore_patterns: &[String]) -> GlobSet {
268    let mut ignore_builder = GlobSetBuilder::new();
269    for pattern in ignore_patterns {
270        let normalized = normalize_user_glob_pattern(pattern);
271        ignore_builder.add(
272            Glob::new(normalized).expect("ignorePatterns entry was validated at config load time"),
273        );
274    }
275
276    let default_ignores = [
277        "**/node_modules/**",
278        "**/dist/**",
279        "build/**",
280        "**/.git/**",
281        "**/coverage/**",
282        "**/*.min.js",
283        "**/*.min.mjs",
284        "**/*.min.cjs",
285        "**/*.bundle.js",
286    ];
287    for pattern in &default_ignores {
288        ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
289    }
290
291    ignore_builder.build().unwrap_or_default()
292}
293
294#[expect(
295    clippy::expect_used,
296    reason = "user glob patterns are validated before config resolution"
297)]
298fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
299    patterns
300        .iter()
301        .map(|pattern| {
302            let normalized = normalize_user_glob_pattern(pattern);
303            Glob::new(normalized)
304                .expect("ignoreUnresolvedImports entry was validated at config load time")
305                .compile_matcher()
306        })
307        .collect()
308}
309
310fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
311    if production {
312        rules.unused_dev_dependencies = Severity::Off;
313        rules.unused_optional_dependencies = Severity::Off;
314    }
315    rules
316}
317
318fn resolve_boundaries(
319    mut boundaries: super::boundaries::BoundaryConfig,
320    root: &Path,
321) -> ResolvedBoundaryConfig {
322    if boundaries.preset.is_some() {
323        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
324            .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
325            .unwrap_or_else(|| "src".to_owned());
326        if source_root != "src" {
327            tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
328        }
329        boundaries.expand(&source_root);
330    }
331    let logical_groups = boundaries.expand_auto_discover(root);
332    let mut resolved = boundaries.resolve();
333    resolved.logical_groups = logical_groups;
334    resolved
335}
336
337fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
338    if rules.duplicate_exports.is_some() && record_inter_file_warn_seen("duplicate-exports", files)
339    {
340        let files = files.join(", ");
341        tracing::warn!(
342            "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."
343        );
344    }
345    if rules.circular_dependencies.is_some()
346        && record_inter_file_warn_seen("circular-dependency", files)
347    {
348        let files = files.join(", ");
349        tracing::warn!(
350            "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."
351        );
352    }
353    if rules.re_export_cycle.is_some() && record_inter_file_warn_seen("re-export-cycle", files) {
354        let files = files.join(", ");
355        tracing::warn!(
356            "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."
357        );
358    }
359}
360
361#[expect(
362    clippy::expect_used,
363    reason = "override glob patterns are validated before config resolution"
364)]
365fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
366    overrides
367        .into_iter()
368        .filter_map(|override_entry| {
369            warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
370            let matchers: Vec<globset::GlobMatcher> = override_entry
371                .files
372                .iter()
373                .map(|pattern| {
374                    Glob::new(pattern)
375                        .expect("overrides[].files pattern was validated at config load time")
376                        .compile_matcher()
377                })
378                .collect();
379            if matchers.is_empty() {
380                None
381            } else {
382                Some(ResolvedOverride {
383                    matchers,
384                    rules: override_entry.rules,
385                })
386            }
387        })
388        .collect()
389}
390
391/// Compile `ignoreExports` file globs into matchers paired with export names.
392#[expect(
393    clippy::expect_used,
394    reason = "user glob patterns are validated before config resolution"
395)]
396fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
397    rules
398        .iter()
399        .map(|rule| CompiledIgnoreExportRule {
400            matcher: Glob::new(&rule.file)
401                .expect("ignoreExports[].file was validated at config load time")
402                .compile_matcher(),
403            exports: rule.exports.clone(),
404        })
405        .collect()
406}
407
408/// Compile `ignoreCatalogReferences` rules, pre-compiling the consumer glob.
409#[expect(
410    clippy::expect_used,
411    reason = "user glob patterns are validated before config resolution"
412)]
413fn compile_ignore_catalog_reference_rules(
414    rules: &[IgnoreCatalogReferenceRule],
415) -> Vec<CompiledIgnoreCatalogReferenceRule> {
416    rules
417        .iter()
418        .map(|rule| CompiledIgnoreCatalogReferenceRule {
419            package: rule.package.clone(),
420            catalog: rule.catalog.clone(),
421            consumer_matcher: rule.consumer.as_ref().map(|pattern| {
422                Glob::new(pattern)
423                    .expect("ignoreCatalogReferences[].consumer was validated at config load time")
424                    .compile_matcher()
425            }),
426        })
427        .collect()
428}
429
430/// Convert `ignoreDependencyOverrides` rules into their match-ready form.
431fn compile_ignore_dependency_override_rules(
432    rules: &[IgnoreDependencyOverrideRule],
433) -> Vec<CompiledIgnoreDependencyOverrideRule> {
434    rules
435        .iter()
436        .map(|rule| CompiledIgnoreDependencyOverrideRule {
437            package: rule.package.clone(),
438            source: rule.source.clone(),
439        })
440        .collect()
441}
442
443struct CompiledIgnoreSettings {
444    patterns: GlobSet,
445    unresolved_imports: Vec<GlobMatcher>,
446    exports: Vec<CompiledIgnoreExportRule>,
447    catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
448    dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
449}
450
451fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
452    CompiledIgnoreSettings {
453        patterns: compile_ignore_patterns(&config.ignore_patterns),
454        unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
455        exports: compile_ignore_export_rules(&config.ignore_exports),
456        catalog_references: compile_ignore_catalog_reference_rules(
457            &config.ignore_catalog_references,
458        ),
459        dependency_overrides: compile_ignore_dependency_override_rules(
460            &config.ignore_dependency_overrides,
461        ),
462    }
463}
464
465struct ResolvedPluginSettings {
466    external_plugins: Vec<ExternalPluginDef>,
467    rule_packs: Vec<crate::rule_pack::RulePackDef>,
468}
469
470fn resolve_plugin_settings(
471    root: &Path,
472    configured_plugins: &[String],
473    framework: Vec<ExternalPluginDef>,
474    rule_packs: &[String],
475) -> ResolvedPluginSettings {
476    let mut external_plugins = discover_external_plugins(root, configured_plugins);
477    external_plugins.extend(framework);
478
479    let rule_packs = crate::rule_pack::load_rule_packs(root, rule_packs).unwrap_or_else(|errors| {
480        for error in &errors {
481            tracing::error!("invalid rule pack: {error}");
482        }
483        Vec::new()
484    });
485
486    ResolvedPluginSettings {
487        external_plugins,
488        rule_packs,
489    }
490}
491
492struct ResolvedCacheSettings {
493    dir: PathBuf,
494    max_size_mb: Option<u32>,
495    config_hash: u64,
496}
497
498struct ResolvedProductionRules {
499    production: bool,
500    rules: RulesConfig,
501}
502
503fn resolve_production_rules(
504    production_config: ProductionConfig,
505    rules: RulesConfig,
506) -> ResolvedProductionRules {
507    let production = production_config.global();
508    ResolvedProductionRules {
509        production,
510        rules: resolve_rules_for_production(rules, production),
511    }
512}
513
514fn resolve_cache_settings(
515    root: &Path,
516    configured_dir: Option<PathBuf>,
517    configured_max_size_mb: Option<u32>,
518    override_max_size_mb: Option<u32>,
519    no_cache: bool,
520    external_plugins: &[ExternalPluginDef],
521) -> ResolvedCacheSettings {
522    ResolvedCacheSettings {
523        dir: resolve_cache_dir(root, configured_dir),
524        max_size_mb: override_max_size_mb.or(configured_max_size_mb),
525        config_hash: if no_cache {
526            0
527        } else {
528            compute_cache_config_hash(external_plugins)
529        },
530    }
531}
532
533fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
534    SecurityConfig {
535        request_receivers: security.normalized_request_receivers(),
536        ..security
537    }
538}
539
540struct ResolvedPathPolicySettings {
541    boundaries: ResolvedBoundaryConfig,
542    overrides: Vec<ResolvedOverride>,
543}
544
545fn resolve_path_policy_settings(
546    boundaries: BoundaryConfig,
547    overrides: Vec<ConfigOverride>,
548    root: &Path,
549) -> ResolvedPathPolicySettings {
550    ResolvedPathPolicySettings {
551        boundaries: resolve_boundaries(boundaries, root),
552        overrides: compile_overrides(overrides),
553    }
554}
555
556impl FallowConfig {
557    /// Resolve into a fully resolved config with compiled globs.
558    #[expect(
559        clippy::too_many_arguments,
560        reason = "public cross-crate API: ResolvedConfig builder whose runtime-override parameters (root, output, threads, no_cache, quiet, cache_max_size_mb) are an established stable signature; bundling them would break callers"
561    )]
562    pub fn resolve(
563        self,
564        root: PathBuf,
565        output: OutputFormat,
566        threads: usize,
567        no_cache: bool,
568        quiet: bool,
569        cache_max_size_mb: Option<u32>,
570    ) -> ResolvedConfig {
571        let compiled_ignores = compile_ignore_settings(&self);
572
573        let production_rules = resolve_production_rules(self.production, self.rules);
574
575        let plugins =
576            resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
577
578        let cache = resolve_cache_settings(
579            &root,
580            self.cache.dir,
581            self.cache.max_size_mb,
582            cache_max_size_mb,
583            no_cache,
584            &plugins.external_plugins,
585        );
586
587        let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
588
589        ResolvedConfig {
590            root,
591            entry_patterns: self.entry,
592            ignore_patterns: compiled_ignores.patterns,
593            output,
594            cache_dir: cache.dir,
595            threads,
596            no_cache,
597            cache_max_size_mb: cache.max_size_mb,
598            cache_config_hash: cache.config_hash,
599            ignore_dependencies: self.ignore_dependencies,
600            ignore_unresolved_imports: compiled_ignores.unresolved_imports,
601            ignore_export_rules: self.ignore_exports,
602            compiled_ignore_exports: compiled_ignores.exports,
603            compiled_ignore_catalog_references: compiled_ignores.catalog_references,
604            compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
605            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
606            used_class_members: self.used_class_members,
607            ignore_decorators: self.ignore_decorators,
608            duplicates: self.duplicates,
609            health: self.health,
610            rules: production_rules.rules,
611            boundaries: path_policy.boundaries,
612            rule_packs: plugins.rule_packs,
613            production: production_rules.production,
614            quiet,
615            external_plugins: plugins.external_plugins,
616            dynamically_loaded: self.dynamically_loaded,
617            overrides: path_policy.overrides,
618            regression: self.regression,
619            audit: self.audit,
620            codeowners: self.codeowners,
621            public_packages: self.public_packages,
622            flags: self.flags,
623            security: normalize_security_config(self.security),
624            fix: self.fix,
625            resolve: self.resolve,
626            include_entry_exports: self.include_entry_exports,
627            auto_imports: self.auto_imports,
628            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
629        }
630    }
631}
632
633impl ResolvedConfig {
634    /// Resolve the effective rules for a given file path.
635    /// Starts with base rules and applies matching overrides in order.
636    #[must_use]
637    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
638        if self.overrides.is_empty() {
639            return self.rules.clone();
640        }
641
642        let relative = path.strip_prefix(&self.root).unwrap_or(path);
643        let relative_str = relative.to_string_lossy();
644
645        let mut rules = self.rules.clone();
646        for override_entry in &self.overrides {
647            let matches = override_entry
648                .matchers
649                .iter()
650                .any(|m| m.is_match(relative_str.as_ref()));
651            if matches {
652                rules.apply_partial(&override_entry.rules);
653            }
654        }
655        rules
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::CacheConfig;
663    use crate::config::boundaries::BoundaryConfig;
664    use crate::config::health::HealthConfig;
665
666    #[test]
667    fn overrides_deserialize() {
668        let json_str = r#"{
669            "overrides": [{
670                "files": ["*.test.ts"],
671                "rules": {
672                    "unused-exports": "off"
673                }
674            }]
675        }"#;
676        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
677        assert_eq!(config.overrides.len(), 1);
678        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
679        assert_eq!(
680            config.overrides[0].rules.unused_exports,
681            Some(Severity::Off)
682        );
683        assert_eq!(config.overrides[0].rules.unused_files, None);
684    }
685
686    #[test]
687    fn resolve_rules_for_path_no_overrides() {
688        let config = FallowConfig {
689            schema: None,
690            extends: vec![],
691            entry: vec![],
692            ignore_patterns: vec![],
693            framework: vec![],
694            workspaces: None,
695            ignore_dependencies: vec![],
696            ignore_unresolved_imports: vec![],
697            ignore_exports: vec![],
698            ignore_catalog_references: vec![],
699            ignore_dependency_overrides: vec![],
700            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
701            used_class_members: vec![],
702            ignore_decorators: vec![],
703            duplicates: DuplicatesConfig::default(),
704            health: HealthConfig::default(),
705            rules: RulesConfig::default(),
706            boundaries: BoundaryConfig::default(),
707            production: false.into(),
708            plugins: vec![],
709            rule_packs: vec![],
710            dynamically_loaded: vec![],
711            overrides: vec![],
712            regression: None,
713            audit: crate::config::AuditConfig::default(),
714            codeowners: None,
715            public_packages: vec![],
716            flags: FlagsConfig::default(),
717            security: SecurityConfig::default(),
718            fix: crate::config::FixConfig::default(),
719            resolve: ResolveConfig::default(),
720            sealed: false,
721            include_entry_exports: false,
722            auto_imports: false,
723            cache: CacheConfig::default(),
724        };
725        let resolved = config.resolve(
726            PathBuf::from("/project"),
727            OutputFormat::Human,
728            1,
729            true,
730            true,
731            None,
732        );
733        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
734        assert_eq!(rules.unused_files, Severity::Error);
735    }
736
737    #[test]
738    fn resolve_rules_for_path_with_matching_override() {
739        let config = FallowConfig {
740            schema: None,
741            extends: vec![],
742            entry: vec![],
743            ignore_patterns: vec![],
744            framework: vec![],
745            workspaces: None,
746            ignore_dependencies: vec![],
747            ignore_unresolved_imports: vec![],
748            ignore_exports: vec![],
749            ignore_catalog_references: vec![],
750            ignore_dependency_overrides: vec![],
751            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
752            used_class_members: vec![],
753            ignore_decorators: vec![],
754            duplicates: DuplicatesConfig::default(),
755            health: HealthConfig::default(),
756            rules: RulesConfig::default(),
757            boundaries: BoundaryConfig::default(),
758            production: false.into(),
759            plugins: vec![],
760            rule_packs: vec![],
761            dynamically_loaded: vec![],
762            overrides: vec![ConfigOverride {
763                files: vec!["*.test.ts".to_string()],
764                rules: PartialRulesConfig {
765                    unused_exports: Some(Severity::Off),
766                    ..Default::default()
767                },
768            }],
769            regression: None,
770            audit: crate::config::AuditConfig::default(),
771            codeowners: None,
772            public_packages: vec![],
773            flags: FlagsConfig::default(),
774            security: SecurityConfig::default(),
775            fix: crate::config::FixConfig::default(),
776            resolve: ResolveConfig::default(),
777            sealed: false,
778            include_entry_exports: false,
779            auto_imports: false,
780            cache: CacheConfig::default(),
781        };
782        let resolved = config.resolve(
783            PathBuf::from("/project"),
784            OutputFormat::Human,
785            1,
786            true,
787            true,
788            None,
789        );
790
791        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
792        assert_eq!(test_rules.unused_exports, Severity::Off);
793        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
794
795        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
796        assert_eq!(src_rules.unused_exports, Severity::Error);
797    }
798
799    #[test]
800    fn resolve_rules_for_path_later_override_wins() {
801        let config = FallowConfig {
802            schema: None,
803            extends: vec![],
804            entry: vec![],
805            ignore_patterns: vec![],
806            framework: vec![],
807            workspaces: None,
808            ignore_dependencies: vec![],
809            ignore_unresolved_imports: vec![],
810            ignore_exports: vec![],
811            ignore_catalog_references: vec![],
812            ignore_dependency_overrides: vec![],
813            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
814            used_class_members: vec![],
815            ignore_decorators: vec![],
816            duplicates: DuplicatesConfig::default(),
817            health: HealthConfig::default(),
818            rules: RulesConfig::default(),
819            boundaries: BoundaryConfig::default(),
820            production: false.into(),
821            plugins: vec![],
822            rule_packs: vec![],
823            dynamically_loaded: vec![],
824            overrides: vec![
825                ConfigOverride {
826                    files: vec!["*.ts".to_string()],
827                    rules: PartialRulesConfig {
828                        unused_files: Some(Severity::Warn),
829                        ..Default::default()
830                    },
831                },
832                ConfigOverride {
833                    files: vec!["*.test.ts".to_string()],
834                    rules: PartialRulesConfig {
835                        unused_files: Some(Severity::Off),
836                        ..Default::default()
837                    },
838                },
839            ],
840            regression: None,
841            audit: crate::config::AuditConfig::default(),
842            codeowners: None,
843            public_packages: vec![],
844            flags: FlagsConfig::default(),
845            security: SecurityConfig::default(),
846            fix: crate::config::FixConfig::default(),
847            resolve: ResolveConfig::default(),
848            sealed: false,
849            include_entry_exports: false,
850            auto_imports: false,
851            cache: CacheConfig::default(),
852        };
853        let resolved = config.resolve(
854            PathBuf::from("/project"),
855            OutputFormat::Human,
856            1,
857            true,
858            true,
859            None,
860        );
861
862        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
863        assert_eq!(rules.unused_files, Severity::Off);
864
865        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
866        assert_eq!(rules2.unused_files, Severity::Warn);
867    }
868
869    #[test]
870    fn resolve_keeps_inter_file_rule_override_after_warning() {
871        let config = FallowConfig {
872            schema: None,
873            extends: vec![],
874            entry: vec![],
875            ignore_patterns: vec![],
876            framework: vec![],
877            workspaces: None,
878            ignore_dependencies: vec![],
879            ignore_unresolved_imports: vec![],
880            ignore_exports: vec![],
881            ignore_catalog_references: vec![],
882            ignore_dependency_overrides: vec![],
883            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
884            used_class_members: vec![],
885            ignore_decorators: vec![],
886            duplicates: DuplicatesConfig::default(),
887            health: HealthConfig::default(),
888            rules: RulesConfig::default(),
889            boundaries: BoundaryConfig::default(),
890            production: false.into(),
891            plugins: vec![],
892            rule_packs: vec![],
893            dynamically_loaded: vec![],
894            overrides: vec![ConfigOverride {
895                files: vec!["**/ui/**".to_string()],
896                rules: PartialRulesConfig {
897                    duplicate_exports: Some(Severity::Off),
898                    unused_files: Some(Severity::Warn),
899                    ..Default::default()
900                },
901            }],
902            regression: None,
903            audit: crate::config::AuditConfig::default(),
904            codeowners: None,
905            public_packages: vec![],
906            flags: FlagsConfig::default(),
907            security: SecurityConfig::default(),
908            fix: crate::config::FixConfig::default(),
909            resolve: ResolveConfig::default(),
910            sealed: false,
911            include_entry_exports: false,
912            auto_imports: false,
913            cache: CacheConfig::default(),
914        };
915        let resolved = config.resolve(
916            PathBuf::from("/project"),
917            OutputFormat::Human,
918            1,
919            true,
920            true,
921            None,
922        );
923        assert_eq!(
924            resolved.overrides.len(),
925            1,
926            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
927        );
928        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
929        assert_eq!(rules.unused_files, Severity::Warn);
930    }
931
932    #[test]
933    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
934        reset_inter_file_warn_dedup_for_test();
935        let files_a = vec!["__test_dedup_a/*".to_string()];
936        let files_b = vec!["__test_dedup_b/*".to_string()];
937
938        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
939        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
940        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
941
942        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
943        assert!(!record_inter_file_warn_seen(
944            "circular-dependency",
945            &files_a
946        ));
947
948        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
949
950        let files_reordered = vec![
951            "__test_dedup_b/*".to_string(),
952            "__test_dedup_a/*".to_string(),
953        ];
954        let files_natural = vec![
955            "__test_dedup_a/*".to_string(),
956            "__test_dedup_b/*".to_string(),
957        ];
958        reset_inter_file_warn_dedup_for_test();
959        assert!(record_inter_file_warn_seen(
960            "duplicate-exports",
961            &files_natural
962        ));
963        assert!(!record_inter_file_warn_seen(
964            "duplicate-exports",
965            &files_reordered
966        ));
967    }
968
969    #[test]
970    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
971        reset_inter_file_warn_dedup_for_test();
972        let files = vec!["__test_resolve_dedup/**".to_string()];
973        let build_config = || FallowConfig {
974            schema: None,
975            extends: vec![],
976            entry: vec![],
977            ignore_patterns: vec![],
978            framework: vec![],
979            workspaces: None,
980            ignore_dependencies: vec![],
981            ignore_unresolved_imports: vec![],
982            ignore_exports: vec![],
983            ignore_catalog_references: vec![],
984            ignore_dependency_overrides: vec![],
985            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
986            used_class_members: vec![],
987            ignore_decorators: vec![],
988            duplicates: DuplicatesConfig::default(),
989            health: HealthConfig::default(),
990            rules: RulesConfig::default(),
991            boundaries: BoundaryConfig::default(),
992            production: false.into(),
993            plugins: vec![],
994            rule_packs: vec![],
995            dynamically_loaded: vec![],
996            overrides: vec![ConfigOverride {
997                files: files.clone(),
998                rules: PartialRulesConfig {
999                    duplicate_exports: Some(Severity::Off),
1000                    ..Default::default()
1001                },
1002            }],
1003            regression: None,
1004            audit: crate::config::AuditConfig::default(),
1005            codeowners: None,
1006            public_packages: vec![],
1007            flags: FlagsConfig::default(),
1008            security: SecurityConfig::default(),
1009            fix: crate::config::FixConfig::default(),
1010            resolve: ResolveConfig::default(),
1011            sealed: false,
1012            include_entry_exports: false,
1013            auto_imports: false,
1014            cache: CacheConfig::default(),
1015        };
1016        for _ in 0..10 {
1017            let _ = build_config().resolve(
1018                PathBuf::from("/project"),
1019                OutputFormat::Human,
1020                1,
1021                true,
1022                true,
1023                None,
1024            );
1025        }
1026        assert!(
1027            !record_inter_file_warn_seen("duplicate-exports", &files),
1028            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1029        );
1030    }
1031
1032    /// Helper to build a FallowConfig with minimal boilerplate.
1033    fn make_config(production: bool) -> FallowConfig {
1034        FallowConfig {
1035            schema: None,
1036            extends: vec![],
1037            entry: vec![],
1038            ignore_patterns: vec![],
1039            framework: vec![],
1040            workspaces: None,
1041            ignore_dependencies: vec![],
1042            ignore_unresolved_imports: vec![],
1043            ignore_exports: vec![],
1044            ignore_catalog_references: vec![],
1045            ignore_dependency_overrides: vec![],
1046            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1047            used_class_members: vec![],
1048            ignore_decorators: vec![],
1049            duplicates: DuplicatesConfig::default(),
1050            health: HealthConfig::default(),
1051            rules: RulesConfig::default(),
1052            boundaries: BoundaryConfig::default(),
1053            production: production.into(),
1054            plugins: vec![],
1055            rule_packs: vec![],
1056            dynamically_loaded: vec![],
1057            overrides: vec![],
1058            regression: None,
1059            audit: crate::config::AuditConfig::default(),
1060            codeowners: None,
1061            public_packages: vec![],
1062            flags: FlagsConfig::default(),
1063            security: SecurityConfig::default(),
1064            fix: crate::config::FixConfig::default(),
1065            resolve: ResolveConfig::default(),
1066            sealed: false,
1067            include_entry_exports: false,
1068            auto_imports: false,
1069            cache: CacheConfig::default(),
1070        }
1071    }
1072
1073    #[test]
1074    fn resolve_production_forces_dev_deps_off() {
1075        let resolved = make_config(true).resolve(
1076            PathBuf::from("/project"),
1077            OutputFormat::Human,
1078            1,
1079            true,
1080            true,
1081            None,
1082        );
1083        assert_eq!(
1084            resolved.rules.unused_dev_dependencies,
1085            Severity::Off,
1086            "production mode should force unused_dev_dependencies to off"
1087        );
1088    }
1089
1090    #[test]
1091    fn resolve_production_forces_optional_deps_off() {
1092        let resolved = make_config(true).resolve(
1093            PathBuf::from("/project"),
1094            OutputFormat::Human,
1095            1,
1096            true,
1097            true,
1098            None,
1099        );
1100        assert_eq!(
1101            resolved.rules.unused_optional_dependencies,
1102            Severity::Off,
1103            "production mode should force unused_optional_dependencies to off"
1104        );
1105    }
1106
1107    #[test]
1108    fn resolve_production_preserves_other_rules() {
1109        let resolved = make_config(true).resolve(
1110            PathBuf::from("/project"),
1111            OutputFormat::Human,
1112            1,
1113            true,
1114            true,
1115            None,
1116        );
1117        assert_eq!(resolved.rules.unused_files, Severity::Error);
1118        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1119        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1120    }
1121
1122    #[test]
1123    fn resolve_non_production_keeps_dev_deps_default() {
1124        let resolved = make_config(false).resolve(
1125            PathBuf::from("/project"),
1126            OutputFormat::Human,
1127            1,
1128            true,
1129            true,
1130            None,
1131        );
1132        assert_eq!(
1133            resolved.rules.unused_dev_dependencies,
1134            Severity::Warn,
1135            "non-production should keep default severity"
1136        );
1137        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1138    }
1139
1140    #[test]
1141    fn resolve_production_flag_stored() {
1142        let resolved = make_config(true).resolve(
1143            PathBuf::from("/project"),
1144            OutputFormat::Human,
1145            1,
1146            true,
1147            true,
1148            None,
1149        );
1150        assert!(resolved.production);
1151
1152        let resolved2 = make_config(false).resolve(
1153            PathBuf::from("/project"),
1154            OutputFormat::Human,
1155            1,
1156            true,
1157            true,
1158            None,
1159        );
1160        assert!(!resolved2.production);
1161    }
1162
1163    #[test]
1164    fn resolve_default_ignores_node_modules() {
1165        let resolved = make_config(false).resolve(
1166            PathBuf::from("/project"),
1167            OutputFormat::Human,
1168            1,
1169            true,
1170            true,
1171            None,
1172        );
1173        assert!(
1174            resolved
1175                .ignore_patterns
1176                .is_match("node_modules/lodash/index.js")
1177        );
1178        assert!(
1179            resolved
1180                .ignore_patterns
1181                .is_match("packages/a/node_modules/react/index.js")
1182        );
1183    }
1184
1185    #[test]
1186    fn resolve_default_ignores_dist() {
1187        let resolved = make_config(false).resolve(
1188            PathBuf::from("/project"),
1189            OutputFormat::Human,
1190            1,
1191            true,
1192            true,
1193            None,
1194        );
1195        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1196        assert!(
1197            resolved
1198                .ignore_patterns
1199                .is_match("packages/ui/dist/index.js")
1200        );
1201    }
1202
1203    #[test]
1204    fn resolve_default_ignores_root_build_only() {
1205        let resolved = make_config(false).resolve(
1206            PathBuf::from("/project"),
1207            OutputFormat::Human,
1208            1,
1209            true,
1210            true,
1211            None,
1212        );
1213        assert!(
1214            resolved.ignore_patterns.is_match("build/output.js"),
1215            "root build/ should be ignored"
1216        );
1217        assert!(
1218            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1219            "nested build/ should NOT be ignored by default"
1220        );
1221    }
1222
1223    #[test]
1224    fn resolve_default_ignores_minified_files() {
1225        let resolved = make_config(false).resolve(
1226            PathBuf::from("/project"),
1227            OutputFormat::Human,
1228            1,
1229            true,
1230            true,
1231            None,
1232        );
1233        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1234        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1235        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1236        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1237        assert!(
1238            resolved
1239                .ignore_patterns
1240                .is_match("src/vendor/app.bundle.js")
1241        );
1242        // Hand-written source with a similar name stays analyzed.
1243        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1244        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1245    }
1246
1247    #[test]
1248    fn resolve_max_file_size_bytes_default_and_unlimited() {
1249        // Unset keeps the built-in default.
1250        assert_eq!(
1251            resolve_max_file_size_bytes(None),
1252            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1253        );
1254        // `0` means no limit.
1255        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1256        // Any other value is that many megabytes in bytes.
1257        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1258        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1259    }
1260
1261    #[test]
1262    fn resolve_sets_default_max_file_size() {
1263        let resolved = make_config(false).resolve(
1264            PathBuf::from("/project"),
1265            OutputFormat::Human,
1266            1,
1267            true,
1268            true,
1269            None,
1270        );
1271        assert_eq!(
1272            resolved.max_file_size_bytes,
1273            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1274        );
1275    }
1276
1277    #[test]
1278    fn resolve_default_ignores_git() {
1279        let resolved = make_config(false).resolve(
1280            PathBuf::from("/project"),
1281            OutputFormat::Human,
1282            1,
1283            true,
1284            true,
1285            None,
1286        );
1287        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1288    }
1289
1290    #[test]
1291    fn resolve_default_ignores_coverage() {
1292        let resolved = make_config(false).resolve(
1293            PathBuf::from("/project"),
1294            OutputFormat::Human,
1295            1,
1296            true,
1297            true,
1298            None,
1299        );
1300        assert!(
1301            resolved
1302                .ignore_patterns
1303                .is_match("coverage/lcov-report/index.js")
1304        );
1305    }
1306
1307    #[test]
1308    fn resolve_source_files_not_ignored_by_default() {
1309        let resolved = make_config(false).resolve(
1310            PathBuf::from("/project"),
1311            OutputFormat::Human,
1312            1,
1313            true,
1314            true,
1315            None,
1316        );
1317        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1318        assert!(
1319            !resolved
1320                .ignore_patterns
1321                .is_match("src/components/Button.tsx")
1322        );
1323        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1324    }
1325
1326    #[test]
1327    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1328        let mut config = make_config(false);
1329        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1330        let resolved = config.resolve(
1331            PathBuf::from("/project"),
1332            OutputFormat::Human,
1333            1,
1334            true,
1335            true,
1336            None,
1337        );
1338        assert!(
1339            resolved
1340                .ignore_patterns
1341                .is_match("src/__generated__/types.ts")
1342        );
1343        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1344    }
1345
1346    #[test]
1347    fn resolve_normalizes_leading_dot_ignore_patterns() {
1348        let mut config = make_config(false);
1349        config.ignore_patterns = vec!["./src/generated/**".to_string()];
1350        let resolved = config.resolve(
1351            PathBuf::from("/project"),
1352            OutputFormat::Human,
1353            1,
1354            true,
1355            true,
1356            None,
1357        );
1358
1359        assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1360        assert!(
1361            !resolved
1362                .ignore_patterns
1363                .is_match("./src/generated/client.ts")
1364        );
1365    }
1366
1367    #[test]
1368    fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1369        let mut config = make_config(false);
1370        config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1371        let resolved = config.resolve(
1372            PathBuf::from("/project"),
1373            OutputFormat::Human,
1374            1,
1375            true,
1376            true,
1377            None,
1378        );
1379
1380        assert!(
1381            resolved
1382                .ignore_unresolved_imports
1383                .iter()
1384                .any(|matcher| matcher.is_match("src/generated/client"))
1385        );
1386        assert!(
1387            !resolved
1388                .ignore_unresolved_imports
1389                .iter()
1390                .any(|matcher| matcher.is_match("./src/generated/client"))
1391        );
1392    }
1393
1394    #[test]
1395    fn resolve_passes_through_entry_patterns() {
1396        let mut config = make_config(false);
1397        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1398        let resolved = config.resolve(
1399            PathBuf::from("/project"),
1400            OutputFormat::Human,
1401            1,
1402            true,
1403            true,
1404            None,
1405        );
1406        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1407    }
1408
1409    #[test]
1410    fn resolve_passes_through_ignore_dependencies() {
1411        let mut config = make_config(false);
1412        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1413        let resolved = config.resolve(
1414            PathBuf::from("/project"),
1415            OutputFormat::Human,
1416            1,
1417            true,
1418            true,
1419            None,
1420        );
1421        assert_eq!(
1422            resolved.ignore_dependencies,
1423            vec!["postcss", "autoprefixer"]
1424        );
1425    }
1426
1427    #[test]
1428    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1429        let mut config = make_config(false);
1430        config.ignore_unresolved_imports = vec![
1431            "@example/icons".to_string(),
1432            "@example/icons/**".to_string(),
1433            "../generated/**".to_string(),
1434        ];
1435        let resolved = config.resolve(
1436            PathBuf::from("/project"),
1437            OutputFormat::Human,
1438            1,
1439            true,
1440            true,
1441            None,
1442        );
1443
1444        assert!(
1445            resolved
1446                .ignore_unresolved_imports
1447                .iter()
1448                .any(|matcher| matcher.is_match("@example/icons"))
1449        );
1450        assert!(
1451            resolved
1452                .ignore_unresolved_imports
1453                .iter()
1454                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1455        );
1456        assert!(
1457            resolved
1458                .ignore_unresolved_imports
1459                .iter()
1460                .any(|matcher| matcher.is_match("../generated/client"))
1461        );
1462    }
1463
1464    #[test]
1465    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1466        let mut config = make_config(false);
1467        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1468        let resolved = config.resolve(
1469            PathBuf::from("/project"),
1470            OutputFormat::Human,
1471            1,
1472            true,
1473            true,
1474            None,
1475        );
1476
1477        assert!(
1478            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1479            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1480        );
1481        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1482    }
1483
1484    #[test]
1485    fn resolve_sets_cache_dir() {
1486        let resolved = make_config(false).resolve(
1487            PathBuf::from("/my/project"),
1488            OutputFormat::Human,
1489            1,
1490            true,
1491            true,
1492            None,
1493        );
1494        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1495    }
1496
1497    #[test]
1498    fn resolve_uses_relative_configured_cache_dir_from_root() {
1499        let config = FallowConfig {
1500            cache: crate::CacheConfig {
1501                dir: Some(PathBuf::from(".cache/fallow")),
1502                ..Default::default()
1503            },
1504            ..make_config(false)
1505        };
1506        let resolved = config.resolve(
1507            PathBuf::from("/my/project"),
1508            OutputFormat::Human,
1509            1,
1510            false,
1511            true,
1512            None,
1513        );
1514        assert_eq!(
1515            resolved.cache_dir,
1516            PathBuf::from("/my/project/.cache/fallow")
1517        );
1518    }
1519
1520    #[test]
1521    fn resolve_keeps_absolute_configured_cache_dir() {
1522        let config = FallowConfig {
1523            cache: crate::CacheConfig {
1524                dir: Some(PathBuf::from("/tmp/fallow-cache")),
1525                ..Default::default()
1526            },
1527            ..make_config(false)
1528        };
1529        let resolved = config.resolve(
1530            PathBuf::from("/my/project"),
1531            OutputFormat::Human,
1532            1,
1533            false,
1534            true,
1535            None,
1536        );
1537        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1538    }
1539
1540    #[test]
1541    fn resolve_passes_through_thread_count() {
1542        let resolved = make_config(false).resolve(
1543            PathBuf::from("/project"),
1544            OutputFormat::Human,
1545            8,
1546            true,
1547            true,
1548            None,
1549        );
1550        assert_eq!(resolved.threads, 8);
1551    }
1552
1553    #[test]
1554    fn resolve_passes_through_quiet_flag() {
1555        let resolved = make_config(false).resolve(
1556            PathBuf::from("/project"),
1557            OutputFormat::Human,
1558            1,
1559            true,
1560            false,
1561            None,
1562        );
1563        assert!(!resolved.quiet);
1564
1565        let resolved2 = make_config(false).resolve(
1566            PathBuf::from("/project"),
1567            OutputFormat::Human,
1568            1,
1569            true,
1570            true,
1571            None,
1572        );
1573        assert!(resolved2.quiet);
1574    }
1575
1576    #[test]
1577    fn resolve_passes_through_no_cache_flag() {
1578        let resolved_no_cache = make_config(false).resolve(
1579            PathBuf::from("/project"),
1580            OutputFormat::Human,
1581            1,
1582            true,
1583            true,
1584            None,
1585        );
1586        assert!(resolved_no_cache.no_cache);
1587
1588        let resolved_with_cache = make_config(false).resolve(
1589            PathBuf::from("/project"),
1590            OutputFormat::Human,
1591            1,
1592            false,
1593            true,
1594            None,
1595        );
1596        assert!(!resolved_with_cache.no_cache);
1597    }
1598
1599    #[test]
1600    #[should_panic(expected = "validated at config load time")]
1601    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1602        let mut config = make_config(false);
1603        config.overrides = vec![ConfigOverride {
1604            files: vec!["[invalid".to_string()],
1605            rules: PartialRulesConfig {
1606                unused_files: Some(Severity::Off),
1607                ..Default::default()
1608            },
1609        }];
1610        let _ = config.resolve(
1611            PathBuf::from("/project"),
1612            OutputFormat::Human,
1613            1,
1614            true,
1615            true,
1616            None,
1617        );
1618    }
1619
1620    #[test]
1621    fn resolve_override_with_empty_files_skipped() {
1622        let mut config = make_config(false);
1623        config.overrides = vec![ConfigOverride {
1624            files: vec![],
1625            rules: PartialRulesConfig {
1626                unused_files: Some(Severity::Off),
1627                ..Default::default()
1628            },
1629        }];
1630        let resolved = config.resolve(
1631            PathBuf::from("/project"),
1632            OutputFormat::Human,
1633            1,
1634            true,
1635            true,
1636            None,
1637        );
1638        assert!(
1639            resolved.overrides.is_empty(),
1640            "override with no file patterns should be skipped"
1641        );
1642    }
1643
1644    #[test]
1645    fn resolve_multiple_valid_overrides() {
1646        let mut config = make_config(false);
1647        config.overrides = vec![
1648            ConfigOverride {
1649                files: vec!["*.test.ts".to_string()],
1650                rules: PartialRulesConfig {
1651                    unused_exports: Some(Severity::Off),
1652                    ..Default::default()
1653                },
1654            },
1655            ConfigOverride {
1656                files: vec!["*.stories.tsx".to_string()],
1657                rules: PartialRulesConfig {
1658                    unused_files: Some(Severity::Off),
1659                    ..Default::default()
1660                },
1661            },
1662        ];
1663        let resolved = config.resolve(
1664            PathBuf::from("/project"),
1665            OutputFormat::Human,
1666            1,
1667            true,
1668            true,
1669            None,
1670        );
1671        assert_eq!(resolved.overrides.len(), 2);
1672    }
1673
1674    #[test]
1675    fn ignore_export_rule_deserialize() {
1676        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1677        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1678        assert_eq!(rule.file, "src/types/*.ts");
1679        assert_eq!(rule.exports, vec!["*"]);
1680    }
1681
1682    #[test]
1683    fn ignore_export_rule_specific_exports() {
1684        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1685        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1686        assert_eq!(rule.exports.len(), 3);
1687        assert!(rule.exports.contains(&"FOO".to_string()));
1688    }
1689
1690    mod proptests {
1691        use super::*;
1692        use proptest::prelude::*;
1693
1694        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1695            make_config(production).resolve(
1696                PathBuf::from("/project"),
1697                OutputFormat::Human,
1698                1,
1699                true,
1700                true,
1701                None,
1702            )
1703        }
1704
1705        proptest! {
1706            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1707            #[test]
1708            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1709                let resolved = arb_resolved_config(production);
1710                prop_assert!(
1711                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1712                    "Default ignore should match node_modules"
1713                );
1714                prop_assert!(
1715                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1716                    "Default ignore should match dist"
1717                );
1718            }
1719
1720            /// Production mode always forces dev and optional deps to Off.
1721            #[test]
1722            fn production_forces_dev_deps_off(_unused in Just(())) {
1723                let resolved = arb_resolved_config(true);
1724                prop_assert_eq!(
1725                    resolved.rules.unused_dev_dependencies,
1726                    Severity::Off,
1727                    "Production should force unused_dev_dependencies off"
1728                );
1729                prop_assert_eq!(
1730                    resolved.rules.unused_optional_dependencies,
1731                    Severity::Off,
1732                    "Production should force unused_optional_dependencies off"
1733                );
1734            }
1735
1736            /// Non-production mode preserves default severity for dev deps.
1737            #[test]
1738            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1739                let resolved = arb_resolved_config(false);
1740                prop_assert_eq!(
1741                    resolved.rules.unused_dev_dependencies,
1742                    Severity::Warn,
1743                    "Non-production should keep default dev dep severity"
1744                );
1745            }
1746
1747            /// Default cache dir is root/.fallow.
1748            #[test]
1749            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1750                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1751                let expected_cache = root.join(".fallow");
1752                let resolved = make_config(false).resolve(
1753                    root,
1754                    OutputFormat::Human,
1755                    1,
1756                    true,
1757                    true,
1758                    None,
1759                );
1760                prop_assert_eq!(
1761                    resolved.cache_dir, expected_cache,
1762                    "Default cache dir should be root/.fallow"
1763                );
1764            }
1765
1766            /// Thread count is always passed through exactly.
1767            #[test]
1768            fn threads_passed_through(threads in 1..64usize) {
1769                let resolved = make_config(false).resolve(
1770                    PathBuf::from("/project"),
1771                    OutputFormat::Human,
1772                    threads,
1773                    true,
1774                    true, None,
1775                );
1776                prop_assert_eq!(
1777                    resolved.threads, threads,
1778                    "Thread count should be passed through"
1779                );
1780            }
1781
1782            /// Custom ignore patterns are merged with defaults, not replacing them.
1783            /// Uses a pattern regex that cannot match node_modules paths, so the
1784            /// assertion proves the default pattern is what provides the match.
1785            #[test]
1786            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1787                let mut config = make_config(false);
1788                config.ignore_patterns = vec![pattern];
1789                let resolved = config.resolve(
1790                    PathBuf::from("/project"),
1791                    OutputFormat::Human,
1792                    1,
1793                    true,
1794                    true, None,
1795                );
1796                prop_assert!(
1797                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1798                    "Default node_modules ignore should still be active"
1799                );
1800            }
1801        }
1802    }
1803
1804    #[test]
1805    fn resolve_expands_boundary_preset() {
1806        use crate::config::boundaries::BoundaryPreset;
1807
1808        let mut config = make_config(false);
1809        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1810        let resolved = config.resolve(
1811            PathBuf::from("/project"),
1812            OutputFormat::Human,
1813            1,
1814            true,
1815            true,
1816            None,
1817        );
1818        assert_eq!(resolved.boundaries.zones.len(), 3);
1819        assert_eq!(resolved.boundaries.rules.len(), 3);
1820        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1821        assert_eq!(
1822            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1823            Some("adapters")
1824        );
1825    }
1826
1827    #[test]
1828    fn resolve_boundary_preset_with_user_override() {
1829        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1830
1831        let mut config = make_config(false);
1832        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1833        config.boundaries.zones = vec![BoundaryZone {
1834            name: "domain".to_string(),
1835            patterns: vec!["src/core/**".to_string()],
1836            auto_discover: vec![],
1837            root: None,
1838        }];
1839        let resolved = config.resolve(
1840            PathBuf::from("/project"),
1841            OutputFormat::Human,
1842            1,
1843            true,
1844            true,
1845            None,
1846        );
1847        assert_eq!(resolved.boundaries.zones.len(), 3);
1848        assert_eq!(
1849            resolved.boundaries.classify_zone("src/core/user.ts"),
1850            Some("domain")
1851        );
1852        assert_eq!(
1853            resolved.boundaries.classify_zone("src/domain/user.ts"),
1854            None
1855        );
1856    }
1857
1858    #[test]
1859    fn resolve_no_preset_unchanged() {
1860        let config = make_config(false);
1861        let resolved = config.resolve(
1862            PathBuf::from("/project"),
1863            OutputFormat::Human,
1864            1,
1865            true,
1866            true,
1867            None,
1868        );
1869        assert!(resolved.boundaries.is_empty());
1870    }
1871}