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