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