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