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
24static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
26
27fn 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
39fn 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
57pub struct IgnoreExportRule {
58 pub file: String,
60 pub exports: Vec<String>,
62}
63
64#[derive(Debug)]
66pub struct CompiledIgnoreExportRule {
67 pub matcher: globset::GlobMatcher,
68 pub exports: Vec<String>,
69}
70
71#[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#[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 #[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#[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#[derive(Debug)]
122pub struct CompiledIgnoreDependencyOverrideRule {
123 pub package: String,
124 pub source: Option<String>,
125}
126
127impl CompiledIgnoreDependencyOverrideRule {
128 #[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#[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#[derive(Debug)]
154pub struct ResolvedOverride {
155 pub matchers: Vec<globset::GlobMatcher>,
156 pub rules: PartialRulesConfig,
157}
158
159#[derive(Debug)]
161pub struct ResolvedConfig {
162 pub root: PathBuf,
163 pub entry_patterns: Vec<String>,
164 pub ignore_patterns: GlobSet,
165 pub output: OutputFormat,
166 pub cache_dir: PathBuf,
167 pub threads: usize,
168 pub no_cache: bool,
169 pub cache_max_size_mb: Option<u32>,
170 pub cache_config_hash: u64,
171 pub ignore_dependencies: Vec<String>,
172 pub ignore_unresolved_imports: Vec<GlobMatcher>,
173 pub ignore_export_rules: Vec<IgnoreExportRule>,
174 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
175 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
176 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
177 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
178 pub used_class_members: Vec<UsedClassMemberRule>,
179 pub ignore_decorators: Vec<String>,
180 pub duplicates: DuplicatesConfig,
181 pub health: HealthConfig,
182 pub rules: RulesConfig,
183 pub boundaries: ResolvedBoundaryConfig,
184 pub 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 pub max_file_size_bytes: Option<u64>,
213}
214
215pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
219
220pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
222
223#[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
236fn 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#[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#[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
424fn 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 #[expect(
553 clippy::too_many_arguments,
554 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"
555 )]
556 pub fn resolve(
557 self,
558 root: PathBuf,
559 output: OutputFormat,
560 threads: usize,
561 no_cache: bool,
562 quiet: bool,
563 cache_max_size_mb: Option<u32>,
564 ) -> ResolvedConfig {
565 let compiled_ignores = compile_ignore_settings(&self);
566
567 let production_rules = resolve_production_rules(self.production, self.rules);
568
569 let plugins =
570 resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
571
572 let cache = resolve_cache_settings(
573 &root,
574 self.cache.dir,
575 self.cache.max_size_mb,
576 cache_max_size_mb,
577 no_cache,
578 &plugins.external_plugins,
579 );
580
581 let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
582
583 ResolvedConfig {
584 root,
585 entry_patterns: self.entry,
586 ignore_patterns: compiled_ignores.patterns,
587 output,
588 cache_dir: cache.dir,
589 threads,
590 no_cache,
591 cache_max_size_mb: cache.max_size_mb,
592 cache_config_hash: cache.config_hash,
593 ignore_dependencies: self.ignore_dependencies,
594 ignore_unresolved_imports: compiled_ignores.unresolved_imports,
595 ignore_export_rules: self.ignore_exports,
596 compiled_ignore_exports: compiled_ignores.exports,
597 compiled_ignore_catalog_references: compiled_ignores.catalog_references,
598 compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
599 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
600 used_class_members: self.used_class_members,
601 ignore_decorators: self.ignore_decorators,
602 duplicates: self.duplicates,
603 health: self.health,
604 rules: production_rules.rules,
605 boundaries: path_policy.boundaries,
606 rule_packs: plugins.rule_packs,
607 production: production_rules.production,
608 quiet,
609 external_plugins: plugins.external_plugins,
610 dynamically_loaded: self.dynamically_loaded,
611 overrides: path_policy.overrides,
612 regression: self.regression,
613 audit: self.audit,
614 codeowners: self.codeowners,
615 public_packages: self.public_packages,
616 flags: self.flags,
617 security: normalize_security_config(self.security),
618 fix: self.fix,
619 resolve: self.resolve,
620 include_entry_exports: self.include_entry_exports,
621 auto_imports: self.auto_imports,
622 max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
623 }
624 }
625}
626
627impl ResolvedConfig {
628 #[must_use]
631 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
632 if self.overrides.is_empty() {
633 return self.rules.clone();
634 }
635
636 let relative = path.strip_prefix(&self.root).unwrap_or(path);
637 let relative_str = relative.to_string_lossy();
638
639 let mut rules = self.rules.clone();
640 for override_entry in &self.overrides {
641 let matches = override_entry
642 .matchers
643 .iter()
644 .any(|m| m.is_match(relative_str.as_ref()));
645 if matches {
646 rules.apply_partial(&override_entry.rules);
647 }
648 }
649 rules
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::CacheConfig;
657 use crate::config::boundaries::BoundaryConfig;
658 use crate::config::health::HealthConfig;
659
660 #[test]
661 fn overrides_deserialize() {
662 let json_str = r#"{
663 "overrides": [{
664 "files": ["*.test.ts"],
665 "rules": {
666 "unused-exports": "off"
667 }
668 }]
669 }"#;
670 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
671 assert_eq!(config.overrides.len(), 1);
672 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
673 assert_eq!(
674 config.overrides[0].rules.unused_exports,
675 Some(Severity::Off)
676 );
677 assert_eq!(config.overrides[0].rules.unused_files, None);
678 }
679
680 #[test]
681 fn resolve_rules_for_path_no_overrides() {
682 let config = FallowConfig {
683 schema: None,
684 extends: vec![],
685 entry: vec![],
686 ignore_patterns: vec![],
687 framework: vec![],
688 workspaces: None,
689 ignore_dependencies: vec![],
690 ignore_unresolved_imports: vec![],
691 ignore_exports: vec![],
692 ignore_catalog_references: vec![],
693 ignore_dependency_overrides: vec![],
694 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
695 used_class_members: vec![],
696 ignore_decorators: vec![],
697 duplicates: DuplicatesConfig::default(),
698 health: HealthConfig::default(),
699 rules: RulesConfig::default(),
700 boundaries: BoundaryConfig::default(),
701 production: false.into(),
702 plugins: vec![],
703 rule_packs: vec![],
704 dynamically_loaded: vec![],
705 overrides: vec![],
706 regression: None,
707 audit: crate::config::AuditConfig::default(),
708 codeowners: None,
709 public_packages: vec![],
710 flags: FlagsConfig::default(),
711 security: SecurityConfig::default(),
712 fix: crate::config::FixConfig::default(),
713 resolve: ResolveConfig::default(),
714 sealed: false,
715 include_entry_exports: false,
716 auto_imports: false,
717 cache: CacheConfig::default(),
718 };
719 let resolved = config.resolve(
720 PathBuf::from("/project"),
721 OutputFormat::Human,
722 1,
723 true,
724 true,
725 None,
726 );
727 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
728 assert_eq!(rules.unused_files, Severity::Error);
729 }
730
731 #[test]
732 fn resolve_rules_for_path_with_matching_override() {
733 let config = FallowConfig {
734 schema: None,
735 extends: vec![],
736 entry: vec![],
737 ignore_patterns: vec![],
738 framework: vec![],
739 workspaces: None,
740 ignore_dependencies: vec![],
741 ignore_unresolved_imports: vec![],
742 ignore_exports: vec![],
743 ignore_catalog_references: vec![],
744 ignore_dependency_overrides: vec![],
745 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
746 used_class_members: vec![],
747 ignore_decorators: vec![],
748 duplicates: DuplicatesConfig::default(),
749 health: HealthConfig::default(),
750 rules: RulesConfig::default(),
751 boundaries: BoundaryConfig::default(),
752 production: false.into(),
753 plugins: vec![],
754 rule_packs: vec![],
755 dynamically_loaded: vec![],
756 overrides: vec![ConfigOverride {
757 files: vec!["*.test.ts".to_string()],
758 rules: PartialRulesConfig {
759 unused_exports: Some(Severity::Off),
760 ..Default::default()
761 },
762 }],
763 regression: None,
764 audit: crate::config::AuditConfig::default(),
765 codeowners: None,
766 public_packages: vec![],
767 flags: FlagsConfig::default(),
768 security: SecurityConfig::default(),
769 fix: crate::config::FixConfig::default(),
770 resolve: ResolveConfig::default(),
771 sealed: false,
772 include_entry_exports: false,
773 auto_imports: false,
774 cache: CacheConfig::default(),
775 };
776 let resolved = config.resolve(
777 PathBuf::from("/project"),
778 OutputFormat::Human,
779 1,
780 true,
781 true,
782 None,
783 );
784
785 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
786 assert_eq!(test_rules.unused_exports, Severity::Off);
787 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
790 assert_eq!(src_rules.unused_exports, Severity::Error);
791 }
792
793 #[test]
794 fn resolve_rules_for_path_later_override_wins() {
795 let config = FallowConfig {
796 schema: None,
797 extends: vec![],
798 entry: vec![],
799 ignore_patterns: vec![],
800 framework: vec![],
801 workspaces: None,
802 ignore_dependencies: vec![],
803 ignore_unresolved_imports: vec![],
804 ignore_exports: vec![],
805 ignore_catalog_references: vec![],
806 ignore_dependency_overrides: vec![],
807 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
808 used_class_members: vec![],
809 ignore_decorators: vec![],
810 duplicates: DuplicatesConfig::default(),
811 health: HealthConfig::default(),
812 rules: RulesConfig::default(),
813 boundaries: BoundaryConfig::default(),
814 production: false.into(),
815 plugins: vec![],
816 rule_packs: vec![],
817 dynamically_loaded: vec![],
818 overrides: vec![
819 ConfigOverride {
820 files: vec!["*.ts".to_string()],
821 rules: PartialRulesConfig {
822 unused_files: Some(Severity::Warn),
823 ..Default::default()
824 },
825 },
826 ConfigOverride {
827 files: vec!["*.test.ts".to_string()],
828 rules: PartialRulesConfig {
829 unused_files: Some(Severity::Off),
830 ..Default::default()
831 },
832 },
833 ],
834 regression: None,
835 audit: crate::config::AuditConfig::default(),
836 codeowners: None,
837 public_packages: vec![],
838 flags: FlagsConfig::default(),
839 security: SecurityConfig::default(),
840 fix: crate::config::FixConfig::default(),
841 resolve: ResolveConfig::default(),
842 sealed: false,
843 include_entry_exports: false,
844 auto_imports: false,
845 cache: CacheConfig::default(),
846 };
847 let resolved = config.resolve(
848 PathBuf::from("/project"),
849 OutputFormat::Human,
850 1,
851 true,
852 true,
853 None,
854 );
855
856 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
857 assert_eq!(rules.unused_files, Severity::Off);
858
859 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
860 assert_eq!(rules2.unused_files, Severity::Warn);
861 }
862
863 #[test]
864 fn resolve_keeps_inter_file_rule_override_after_warning() {
865 let config = FallowConfig {
866 schema: None,
867 extends: vec![],
868 entry: vec![],
869 ignore_patterns: vec![],
870 framework: vec![],
871 workspaces: None,
872 ignore_dependencies: vec![],
873 ignore_unresolved_imports: vec![],
874 ignore_exports: vec![],
875 ignore_catalog_references: vec![],
876 ignore_dependency_overrides: vec![],
877 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
878 used_class_members: vec![],
879 ignore_decorators: vec![],
880 duplicates: DuplicatesConfig::default(),
881 health: HealthConfig::default(),
882 rules: RulesConfig::default(),
883 boundaries: BoundaryConfig::default(),
884 production: false.into(),
885 plugins: vec![],
886 rule_packs: vec![],
887 dynamically_loaded: vec![],
888 overrides: vec![ConfigOverride {
889 files: vec!["**/ui/**".to_string()],
890 rules: PartialRulesConfig {
891 duplicate_exports: Some(Severity::Off),
892 unused_files: Some(Severity::Warn),
893 ..Default::default()
894 },
895 }],
896 regression: None,
897 audit: crate::config::AuditConfig::default(),
898 codeowners: None,
899 public_packages: vec![],
900 flags: FlagsConfig::default(),
901 security: SecurityConfig::default(),
902 fix: crate::config::FixConfig::default(),
903 resolve: ResolveConfig::default(),
904 sealed: false,
905 include_entry_exports: false,
906 auto_imports: false,
907 cache: CacheConfig::default(),
908 };
909 let resolved = config.resolve(
910 PathBuf::from("/project"),
911 OutputFormat::Human,
912 1,
913 true,
914 true,
915 None,
916 );
917 assert_eq!(
918 resolved.overrides.len(),
919 1,
920 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
921 );
922 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
923 assert_eq!(rules.unused_files, Severity::Warn);
924 }
925
926 #[test]
927 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
928 reset_inter_file_warn_dedup_for_test();
929 let files_a = vec!["__test_dedup_a/*".to_string()];
930 let files_b = vec!["__test_dedup_b/*".to_string()];
931
932 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
933 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
934 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
935
936 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
937 assert!(!record_inter_file_warn_seen(
938 "circular-dependency",
939 &files_a
940 ));
941
942 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
943
944 let files_reordered = vec![
945 "__test_dedup_b/*".to_string(),
946 "__test_dedup_a/*".to_string(),
947 ];
948 let files_natural = vec![
949 "__test_dedup_a/*".to_string(),
950 "__test_dedup_b/*".to_string(),
951 ];
952 reset_inter_file_warn_dedup_for_test();
953 assert!(record_inter_file_warn_seen(
954 "duplicate-exports",
955 &files_natural
956 ));
957 assert!(!record_inter_file_warn_seen(
958 "duplicate-exports",
959 &files_reordered
960 ));
961 }
962
963 #[test]
964 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
965 reset_inter_file_warn_dedup_for_test();
966 let files = vec!["__test_resolve_dedup/**".to_string()];
967 let build_config = || FallowConfig {
968 schema: None,
969 extends: vec![],
970 entry: vec![],
971 ignore_patterns: vec![],
972 framework: vec![],
973 workspaces: None,
974 ignore_dependencies: vec![],
975 ignore_unresolved_imports: vec![],
976 ignore_exports: vec![],
977 ignore_catalog_references: vec![],
978 ignore_dependency_overrides: vec![],
979 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
980 used_class_members: vec![],
981 ignore_decorators: vec![],
982 duplicates: DuplicatesConfig::default(),
983 health: HealthConfig::default(),
984 rules: RulesConfig::default(),
985 boundaries: BoundaryConfig::default(),
986 production: false.into(),
987 plugins: vec![],
988 rule_packs: vec![],
989 dynamically_loaded: vec![],
990 overrides: vec![ConfigOverride {
991 files: files.clone(),
992 rules: PartialRulesConfig {
993 duplicate_exports: Some(Severity::Off),
994 ..Default::default()
995 },
996 }],
997 regression: None,
998 audit: crate::config::AuditConfig::default(),
999 codeowners: None,
1000 public_packages: vec![],
1001 flags: FlagsConfig::default(),
1002 security: SecurityConfig::default(),
1003 fix: crate::config::FixConfig::default(),
1004 resolve: ResolveConfig::default(),
1005 sealed: false,
1006 include_entry_exports: false,
1007 auto_imports: false,
1008 cache: CacheConfig::default(),
1009 };
1010 for _ in 0..10 {
1011 let _ = build_config().resolve(
1012 PathBuf::from("/project"),
1013 OutputFormat::Human,
1014 1,
1015 true,
1016 true,
1017 None,
1018 );
1019 }
1020 assert!(
1021 !record_inter_file_warn_seen("duplicate-exports", &files),
1022 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1023 );
1024 }
1025
1026 fn make_config(production: bool) -> FallowConfig {
1028 FallowConfig {
1029 schema: None,
1030 extends: vec![],
1031 entry: vec![],
1032 ignore_patterns: vec![],
1033 framework: vec![],
1034 workspaces: None,
1035 ignore_dependencies: vec![],
1036 ignore_unresolved_imports: vec![],
1037 ignore_exports: vec![],
1038 ignore_catalog_references: vec![],
1039 ignore_dependency_overrides: vec![],
1040 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1041 used_class_members: vec![],
1042 ignore_decorators: vec![],
1043 duplicates: DuplicatesConfig::default(),
1044 health: HealthConfig::default(),
1045 rules: RulesConfig::default(),
1046 boundaries: BoundaryConfig::default(),
1047 production: production.into(),
1048 plugins: vec![],
1049 rule_packs: vec![],
1050 dynamically_loaded: vec![],
1051 overrides: vec![],
1052 regression: None,
1053 audit: crate::config::AuditConfig::default(),
1054 codeowners: None,
1055 public_packages: vec![],
1056 flags: FlagsConfig::default(),
1057 security: SecurityConfig::default(),
1058 fix: crate::config::FixConfig::default(),
1059 resolve: ResolveConfig::default(),
1060 sealed: false,
1061 include_entry_exports: false,
1062 auto_imports: false,
1063 cache: CacheConfig::default(),
1064 }
1065 }
1066
1067 #[test]
1068 fn resolve_production_forces_dev_deps_off() {
1069 let resolved = make_config(true).resolve(
1070 PathBuf::from("/project"),
1071 OutputFormat::Human,
1072 1,
1073 true,
1074 true,
1075 None,
1076 );
1077 assert_eq!(
1078 resolved.rules.unused_dev_dependencies,
1079 Severity::Off,
1080 "production mode should force unused_dev_dependencies to off"
1081 );
1082 }
1083
1084 #[test]
1085 fn resolve_production_forces_optional_deps_off() {
1086 let resolved = make_config(true).resolve(
1087 PathBuf::from("/project"),
1088 OutputFormat::Human,
1089 1,
1090 true,
1091 true,
1092 None,
1093 );
1094 assert_eq!(
1095 resolved.rules.unused_optional_dependencies,
1096 Severity::Off,
1097 "production mode should force unused_optional_dependencies to off"
1098 );
1099 }
1100
1101 #[test]
1102 fn resolve_production_preserves_other_rules() {
1103 let resolved = make_config(true).resolve(
1104 PathBuf::from("/project"),
1105 OutputFormat::Human,
1106 1,
1107 true,
1108 true,
1109 None,
1110 );
1111 assert_eq!(resolved.rules.unused_files, Severity::Error);
1112 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1113 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1114 }
1115
1116 #[test]
1117 fn resolve_non_production_keeps_dev_deps_default() {
1118 let resolved = make_config(false).resolve(
1119 PathBuf::from("/project"),
1120 OutputFormat::Human,
1121 1,
1122 true,
1123 true,
1124 None,
1125 );
1126 assert_eq!(
1127 resolved.rules.unused_dev_dependencies,
1128 Severity::Warn,
1129 "non-production should keep default severity"
1130 );
1131 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1132 }
1133
1134 #[test]
1135 fn resolve_production_flag_stored() {
1136 let resolved = make_config(true).resolve(
1137 PathBuf::from("/project"),
1138 OutputFormat::Human,
1139 1,
1140 true,
1141 true,
1142 None,
1143 );
1144 assert!(resolved.production);
1145
1146 let resolved2 = make_config(false).resolve(
1147 PathBuf::from("/project"),
1148 OutputFormat::Human,
1149 1,
1150 true,
1151 true,
1152 None,
1153 );
1154 assert!(!resolved2.production);
1155 }
1156
1157 #[test]
1158 fn resolve_default_ignores_node_modules() {
1159 let resolved = make_config(false).resolve(
1160 PathBuf::from("/project"),
1161 OutputFormat::Human,
1162 1,
1163 true,
1164 true,
1165 None,
1166 );
1167 assert!(
1168 resolved
1169 .ignore_patterns
1170 .is_match("node_modules/lodash/index.js")
1171 );
1172 assert!(
1173 resolved
1174 .ignore_patterns
1175 .is_match("packages/a/node_modules/react/index.js")
1176 );
1177 }
1178
1179 #[test]
1180 fn resolve_default_ignores_dist() {
1181 let resolved = make_config(false).resolve(
1182 PathBuf::from("/project"),
1183 OutputFormat::Human,
1184 1,
1185 true,
1186 true,
1187 None,
1188 );
1189 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1190 assert!(
1191 resolved
1192 .ignore_patterns
1193 .is_match("packages/ui/dist/index.js")
1194 );
1195 }
1196
1197 #[test]
1198 fn resolve_default_ignores_root_build_only() {
1199 let resolved = make_config(false).resolve(
1200 PathBuf::from("/project"),
1201 OutputFormat::Human,
1202 1,
1203 true,
1204 true,
1205 None,
1206 );
1207 assert!(
1208 resolved.ignore_patterns.is_match("build/output.js"),
1209 "root build/ should be ignored"
1210 );
1211 assert!(
1212 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1213 "nested build/ should NOT be ignored by default"
1214 );
1215 }
1216
1217 #[test]
1218 fn resolve_default_ignores_minified_files() {
1219 let resolved = make_config(false).resolve(
1220 PathBuf::from("/project"),
1221 OutputFormat::Human,
1222 1,
1223 true,
1224 true,
1225 None,
1226 );
1227 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1228 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1229 assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1230 assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1231 assert!(
1232 resolved
1233 .ignore_patterns
1234 .is_match("src/vendor/app.bundle.js")
1235 );
1236 assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1238 assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1239 }
1240
1241 #[test]
1242 fn resolve_max_file_size_bytes_default_and_unlimited() {
1243 assert_eq!(
1245 resolve_max_file_size_bytes(None),
1246 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1247 );
1248 assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1250 assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1252 assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1253 }
1254
1255 #[test]
1256 fn resolve_sets_default_max_file_size() {
1257 let resolved = make_config(false).resolve(
1258 PathBuf::from("/project"),
1259 OutputFormat::Human,
1260 1,
1261 true,
1262 true,
1263 None,
1264 );
1265 assert_eq!(
1266 resolved.max_file_size_bytes,
1267 Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1268 );
1269 }
1270
1271 #[test]
1272 fn resolve_default_ignores_git() {
1273 let resolved = make_config(false).resolve(
1274 PathBuf::from("/project"),
1275 OutputFormat::Human,
1276 1,
1277 true,
1278 true,
1279 None,
1280 );
1281 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1282 }
1283
1284 #[test]
1285 fn resolve_default_ignores_coverage() {
1286 let resolved = make_config(false).resolve(
1287 PathBuf::from("/project"),
1288 OutputFormat::Human,
1289 1,
1290 true,
1291 true,
1292 None,
1293 );
1294 assert!(
1295 resolved
1296 .ignore_patterns
1297 .is_match("coverage/lcov-report/index.js")
1298 );
1299 }
1300
1301 #[test]
1302 fn resolve_source_files_not_ignored_by_default() {
1303 let resolved = make_config(false).resolve(
1304 PathBuf::from("/project"),
1305 OutputFormat::Human,
1306 1,
1307 true,
1308 true,
1309 None,
1310 );
1311 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1312 assert!(
1313 !resolved
1314 .ignore_patterns
1315 .is_match("src/components/Button.tsx")
1316 );
1317 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1318 }
1319
1320 #[test]
1321 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1322 let mut config = make_config(false);
1323 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1324 let resolved = config.resolve(
1325 PathBuf::from("/project"),
1326 OutputFormat::Human,
1327 1,
1328 true,
1329 true,
1330 None,
1331 );
1332 assert!(
1333 resolved
1334 .ignore_patterns
1335 .is_match("src/__generated__/types.ts")
1336 );
1337 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1338 }
1339
1340 #[test]
1341 fn resolve_passes_through_entry_patterns() {
1342 let mut config = make_config(false);
1343 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1344 let resolved = config.resolve(
1345 PathBuf::from("/project"),
1346 OutputFormat::Human,
1347 1,
1348 true,
1349 true,
1350 None,
1351 );
1352 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1353 }
1354
1355 #[test]
1356 fn resolve_passes_through_ignore_dependencies() {
1357 let mut config = make_config(false);
1358 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1359 let resolved = config.resolve(
1360 PathBuf::from("/project"),
1361 OutputFormat::Human,
1362 1,
1363 true,
1364 true,
1365 None,
1366 );
1367 assert_eq!(
1368 resolved.ignore_dependencies,
1369 vec!["postcss", "autoprefixer"]
1370 );
1371 }
1372
1373 #[test]
1374 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1375 let mut config = make_config(false);
1376 config.ignore_unresolved_imports = vec![
1377 "@example/icons".to_string(),
1378 "@example/icons/**".to_string(),
1379 "../generated/**".to_string(),
1380 ];
1381 let resolved = config.resolve(
1382 PathBuf::from("/project"),
1383 OutputFormat::Human,
1384 1,
1385 true,
1386 true,
1387 None,
1388 );
1389
1390 assert!(
1391 resolved
1392 .ignore_unresolved_imports
1393 .iter()
1394 .any(|matcher| matcher.is_match("@example/icons"))
1395 );
1396 assert!(
1397 resolved
1398 .ignore_unresolved_imports
1399 .iter()
1400 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1401 );
1402 assert!(
1403 resolved
1404 .ignore_unresolved_imports
1405 .iter()
1406 .any(|matcher| matcher.is_match("../generated/client"))
1407 );
1408 }
1409
1410 #[test]
1411 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1412 let mut config = make_config(false);
1413 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1414 let resolved = config.resolve(
1415 PathBuf::from("/project"),
1416 OutputFormat::Human,
1417 1,
1418 true,
1419 true,
1420 None,
1421 );
1422
1423 assert!(
1424 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1425 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1426 );
1427 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1428 }
1429
1430 #[test]
1431 fn resolve_sets_cache_dir() {
1432 let resolved = make_config(false).resolve(
1433 PathBuf::from("/my/project"),
1434 OutputFormat::Human,
1435 1,
1436 true,
1437 true,
1438 None,
1439 );
1440 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1441 }
1442
1443 #[test]
1444 fn resolve_uses_relative_configured_cache_dir_from_root() {
1445 let config = FallowConfig {
1446 cache: crate::CacheConfig {
1447 dir: Some(PathBuf::from(".cache/fallow")),
1448 ..Default::default()
1449 },
1450 ..make_config(false)
1451 };
1452 let resolved = config.resolve(
1453 PathBuf::from("/my/project"),
1454 OutputFormat::Human,
1455 1,
1456 false,
1457 true,
1458 None,
1459 );
1460 assert_eq!(
1461 resolved.cache_dir,
1462 PathBuf::from("/my/project/.cache/fallow")
1463 );
1464 }
1465
1466 #[test]
1467 fn resolve_keeps_absolute_configured_cache_dir() {
1468 let config = FallowConfig {
1469 cache: crate::CacheConfig {
1470 dir: Some(PathBuf::from("/tmp/fallow-cache")),
1471 ..Default::default()
1472 },
1473 ..make_config(false)
1474 };
1475 let resolved = config.resolve(
1476 PathBuf::from("/my/project"),
1477 OutputFormat::Human,
1478 1,
1479 false,
1480 true,
1481 None,
1482 );
1483 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1484 }
1485
1486 #[test]
1487 fn resolve_passes_through_thread_count() {
1488 let resolved = make_config(false).resolve(
1489 PathBuf::from("/project"),
1490 OutputFormat::Human,
1491 8,
1492 true,
1493 true,
1494 None,
1495 );
1496 assert_eq!(resolved.threads, 8);
1497 }
1498
1499 #[test]
1500 fn resolve_passes_through_quiet_flag() {
1501 let resolved = make_config(false).resolve(
1502 PathBuf::from("/project"),
1503 OutputFormat::Human,
1504 1,
1505 true,
1506 false,
1507 None,
1508 );
1509 assert!(!resolved.quiet);
1510
1511 let resolved2 = make_config(false).resolve(
1512 PathBuf::from("/project"),
1513 OutputFormat::Human,
1514 1,
1515 true,
1516 true,
1517 None,
1518 );
1519 assert!(resolved2.quiet);
1520 }
1521
1522 #[test]
1523 fn resolve_passes_through_no_cache_flag() {
1524 let resolved_no_cache = make_config(false).resolve(
1525 PathBuf::from("/project"),
1526 OutputFormat::Human,
1527 1,
1528 true,
1529 true,
1530 None,
1531 );
1532 assert!(resolved_no_cache.no_cache);
1533
1534 let resolved_with_cache = make_config(false).resolve(
1535 PathBuf::from("/project"),
1536 OutputFormat::Human,
1537 1,
1538 false,
1539 true,
1540 None,
1541 );
1542 assert!(!resolved_with_cache.no_cache);
1543 }
1544
1545 #[test]
1546 #[should_panic(expected = "validated at config load time")]
1547 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1548 let mut config = make_config(false);
1549 config.overrides = vec![ConfigOverride {
1550 files: vec!["[invalid".to_string()],
1551 rules: PartialRulesConfig {
1552 unused_files: Some(Severity::Off),
1553 ..Default::default()
1554 },
1555 }];
1556 let _ = config.resolve(
1557 PathBuf::from("/project"),
1558 OutputFormat::Human,
1559 1,
1560 true,
1561 true,
1562 None,
1563 );
1564 }
1565
1566 #[test]
1567 fn resolve_override_with_empty_files_skipped() {
1568 let mut config = make_config(false);
1569 config.overrides = vec![ConfigOverride {
1570 files: vec![],
1571 rules: PartialRulesConfig {
1572 unused_files: Some(Severity::Off),
1573 ..Default::default()
1574 },
1575 }];
1576 let resolved = config.resolve(
1577 PathBuf::from("/project"),
1578 OutputFormat::Human,
1579 1,
1580 true,
1581 true,
1582 None,
1583 );
1584 assert!(
1585 resolved.overrides.is_empty(),
1586 "override with no file patterns should be skipped"
1587 );
1588 }
1589
1590 #[test]
1591 fn resolve_multiple_valid_overrides() {
1592 let mut config = make_config(false);
1593 config.overrides = vec![
1594 ConfigOverride {
1595 files: vec!["*.test.ts".to_string()],
1596 rules: PartialRulesConfig {
1597 unused_exports: Some(Severity::Off),
1598 ..Default::default()
1599 },
1600 },
1601 ConfigOverride {
1602 files: vec!["*.stories.tsx".to_string()],
1603 rules: PartialRulesConfig {
1604 unused_files: Some(Severity::Off),
1605 ..Default::default()
1606 },
1607 },
1608 ];
1609 let resolved = config.resolve(
1610 PathBuf::from("/project"),
1611 OutputFormat::Human,
1612 1,
1613 true,
1614 true,
1615 None,
1616 );
1617 assert_eq!(resolved.overrides.len(), 2);
1618 }
1619
1620 #[test]
1621 fn ignore_export_rule_deserialize() {
1622 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1623 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1624 assert_eq!(rule.file, "src/types/*.ts");
1625 assert_eq!(rule.exports, vec!["*"]);
1626 }
1627
1628 #[test]
1629 fn ignore_export_rule_specific_exports() {
1630 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1631 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1632 assert_eq!(rule.exports.len(), 3);
1633 assert!(rule.exports.contains(&"FOO".to_string()));
1634 }
1635
1636 mod proptests {
1637 use super::*;
1638 use proptest::prelude::*;
1639
1640 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1641 make_config(production).resolve(
1642 PathBuf::from("/project"),
1643 OutputFormat::Human,
1644 1,
1645 true,
1646 true,
1647 None,
1648 )
1649 }
1650
1651 proptest! {
1652 #[test]
1654 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1655 let resolved = arb_resolved_config(production);
1656 prop_assert!(
1657 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1658 "Default ignore should match node_modules"
1659 );
1660 prop_assert!(
1661 resolved.ignore_patterns.is_match("dist/bundle.js"),
1662 "Default ignore should match dist"
1663 );
1664 }
1665
1666 #[test]
1668 fn production_forces_dev_deps_off(_unused in Just(())) {
1669 let resolved = arb_resolved_config(true);
1670 prop_assert_eq!(
1671 resolved.rules.unused_dev_dependencies,
1672 Severity::Off,
1673 "Production should force unused_dev_dependencies off"
1674 );
1675 prop_assert_eq!(
1676 resolved.rules.unused_optional_dependencies,
1677 Severity::Off,
1678 "Production should force unused_optional_dependencies off"
1679 );
1680 }
1681
1682 #[test]
1684 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1685 let resolved = arb_resolved_config(false);
1686 prop_assert_eq!(
1687 resolved.rules.unused_dev_dependencies,
1688 Severity::Warn,
1689 "Non-production should keep default dev dep severity"
1690 );
1691 }
1692
1693 #[test]
1695 fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1696 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1697 let expected_cache = root.join(".fallow");
1698 let resolved = make_config(false).resolve(
1699 root,
1700 OutputFormat::Human,
1701 1,
1702 true,
1703 true,
1704 None,
1705 );
1706 prop_assert_eq!(
1707 resolved.cache_dir, expected_cache,
1708 "Default cache dir should be root/.fallow"
1709 );
1710 }
1711
1712 #[test]
1714 fn threads_passed_through(threads in 1..64usize) {
1715 let resolved = make_config(false).resolve(
1716 PathBuf::from("/project"),
1717 OutputFormat::Human,
1718 threads,
1719 true,
1720 true, None,
1721 );
1722 prop_assert_eq!(
1723 resolved.threads, threads,
1724 "Thread count should be passed through"
1725 );
1726 }
1727
1728 #[test]
1732 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1733 let mut config = make_config(false);
1734 config.ignore_patterns = vec![pattern];
1735 let resolved = config.resolve(
1736 PathBuf::from("/project"),
1737 OutputFormat::Human,
1738 1,
1739 true,
1740 true, None,
1741 );
1742 prop_assert!(
1743 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1744 "Default node_modules ignore should still be active"
1745 );
1746 }
1747 }
1748 }
1749
1750 #[test]
1751 fn resolve_expands_boundary_preset() {
1752 use crate::config::boundaries::BoundaryPreset;
1753
1754 let mut config = make_config(false);
1755 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1756 let resolved = config.resolve(
1757 PathBuf::from("/project"),
1758 OutputFormat::Human,
1759 1,
1760 true,
1761 true,
1762 None,
1763 );
1764 assert_eq!(resolved.boundaries.zones.len(), 3);
1765 assert_eq!(resolved.boundaries.rules.len(), 3);
1766 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1767 assert_eq!(
1768 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1769 Some("adapters")
1770 );
1771 }
1772
1773 #[test]
1774 fn resolve_boundary_preset_with_user_override() {
1775 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1776
1777 let mut config = make_config(false);
1778 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1779 config.boundaries.zones = vec![BoundaryZone {
1780 name: "domain".to_string(),
1781 patterns: vec!["src/core/**".to_string()],
1782 auto_discover: vec![],
1783 root: None,
1784 }];
1785 let resolved = config.resolve(
1786 PathBuf::from("/project"),
1787 OutputFormat::Human,
1788 1,
1789 true,
1790 true,
1791 None,
1792 );
1793 assert_eq!(resolved.boundaries.zones.len(), 3);
1794 assert_eq!(
1795 resolved.boundaries.classify_zone("src/core/user.ts"),
1796 Some("domain")
1797 );
1798 assert_eq!(
1799 resolved.boundaries.classify_zone("src/domain/user.ts"),
1800 None
1801 );
1802 }
1803
1804 #[test]
1805 fn resolve_no_preset_unchanged() {
1806 let config = make_config(false);
1807 let resolved = config.resolve(
1808 PathBuf::from("/project"),
1809 OutputFormat::Human,
1810 1,
1811 true,
1812 true,
1813 None,
1814 );
1815 assert!(resolved.boundaries.is_empty());
1816 }
1817}