1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::boundaries::ResolvedBoundaryConfig;
8use super::duplicates_config::DuplicatesConfig;
9use super::format::OutputFormat;
10use super::health::HealthConfig;
11use super::rules::{PartialRulesConfig, RulesConfig, Severity};
12use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
13
14use super::FallowConfig;
15
16#[derive(Debug, Deserialize, Serialize, JsonSchema)]
18pub struct IgnoreExportRule {
19 pub file: String,
21 pub exports: Vec<String>,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
27#[serde(rename_all = "camelCase")]
28pub struct ConfigOverride {
29 pub files: Vec<String>,
31 #[serde(default)]
33 pub rules: PartialRulesConfig,
34}
35
36#[derive(Debug)]
38pub struct ResolvedOverride {
39 pub matchers: Vec<globset::GlobMatcher>,
40 pub rules: PartialRulesConfig,
41}
42
43#[derive(Debug)]
45pub struct ResolvedConfig {
46 pub root: PathBuf,
47 pub entry_patterns: Vec<String>,
48 pub ignore_patterns: GlobSet,
49 pub output: OutputFormat,
50 pub cache_dir: PathBuf,
51 pub threads: usize,
52 pub no_cache: bool,
53 pub ignore_dependencies: Vec<String>,
54 pub ignore_export_rules: Vec<IgnoreExportRule>,
55 pub duplicates: DuplicatesConfig,
56 pub health: HealthConfig,
57 pub rules: RulesConfig,
58 pub boundaries: ResolvedBoundaryConfig,
60 pub production: bool,
62 pub quiet: bool,
64 pub external_plugins: Vec<ExternalPluginDef>,
66 pub dynamically_loaded: Vec<String>,
68 pub overrides: Vec<ResolvedOverride>,
70 pub regression: Option<super::RegressionConfig>,
72 pub codeowners: Option<String>,
74 pub public_packages: Vec<String>,
77}
78
79impl FallowConfig {
80 pub fn resolve(
82 self,
83 root: PathBuf,
84 output: OutputFormat,
85 threads: usize,
86 no_cache: bool,
87 quiet: bool,
88 ) -> ResolvedConfig {
89 let mut ignore_builder = GlobSetBuilder::new();
90 for pattern in &self.ignore_patterns {
91 match Glob::new(pattern) {
92 Ok(glob) => {
93 ignore_builder.add(glob);
94 }
95 Err(e) => {
96 tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
97 }
98 }
99 }
100
101 let default_ignores = [
105 "**/node_modules/**",
106 "**/dist/**",
107 "build/**",
108 "**/.git/**",
109 "**/coverage/**",
110 "**/*.min.js",
111 "**/*.min.mjs",
112 ];
113 for pattern in &default_ignores {
114 if let Ok(glob) = Glob::new(pattern) {
115 ignore_builder.add(glob);
116 }
117 }
118
119 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
120 let cache_dir = root.join(".fallow");
121
122 let mut rules = self.rules;
123
124 let production = self.production;
126 if production {
127 rules.unused_dev_dependencies = Severity::Off;
128 rules.unused_optional_dependencies = Severity::Off;
129 }
130
131 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
132 external_plugins.extend(self.framework);
134
135 let mut boundaries = self.boundaries;
138 if boundaries.preset.is_some() {
139 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
140 .filter(|r| {
141 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
142 })
143 .unwrap_or_else(|| "src".to_owned());
144 if source_root != "src" {
145 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
146 }
147 boundaries.expand(&source_root);
148 }
149
150 let validation_errors = boundaries.validate_zone_references();
152 for (rule_idx, zone_name) in &validation_errors {
153 tracing::error!(
154 "boundary rule {} references undefined zone '{zone_name}'",
155 rule_idx
156 );
157 }
158 let boundaries = boundaries.resolve();
159
160 let overrides = self
162 .overrides
163 .into_iter()
164 .filter_map(|o| {
165 let matchers: Vec<globset::GlobMatcher> = o
166 .files
167 .iter()
168 .filter_map(|pattern| match Glob::new(pattern) {
169 Ok(glob) => Some(glob.compile_matcher()),
170 Err(e) => {
171 tracing::warn!("invalid override glob pattern '{pattern}': {e}");
172 None
173 }
174 })
175 .collect();
176 if matchers.is_empty() {
177 None
178 } else {
179 Some(ResolvedOverride {
180 matchers,
181 rules: o.rules,
182 })
183 }
184 })
185 .collect();
186
187 ResolvedConfig {
188 root,
189 entry_patterns: self.entry,
190 ignore_patterns: compiled_ignore_patterns,
191 output,
192 cache_dir,
193 threads,
194 no_cache,
195 ignore_dependencies: self.ignore_dependencies,
196 ignore_export_rules: self.ignore_exports,
197 duplicates: self.duplicates,
198 health: self.health,
199 rules,
200 boundaries,
201 production,
202 quiet,
203 external_plugins,
204 dynamically_loaded: self.dynamically_loaded,
205 overrides,
206 regression: self.regression,
207 codeowners: self.codeowners,
208 public_packages: self.public_packages,
209 }
210 }
211}
212
213impl ResolvedConfig {
214 #[must_use]
217 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
218 if self.overrides.is_empty() {
219 return self.rules.clone();
220 }
221
222 let relative = path.strip_prefix(&self.root).unwrap_or(path);
223 let relative_str = relative.to_string_lossy();
224
225 let mut rules = self.rules.clone();
226 for override_entry in &self.overrides {
227 let matches = override_entry
228 .matchers
229 .iter()
230 .any(|m| m.is_match(relative_str.as_ref()));
231 if matches {
232 rules.apply_partial(&override_entry.rules);
233 }
234 }
235 rules
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::config::boundaries::BoundaryConfig;
243 use crate::config::health::HealthConfig;
244
245 #[test]
246 fn overrides_deserialize() {
247 let json_str = r#"{
248 "overrides": [{
249 "files": ["*.test.ts"],
250 "rules": {
251 "unused-exports": "off"
252 }
253 }]
254 }"#;
255 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
256 assert_eq!(config.overrides.len(), 1);
257 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
258 assert_eq!(
259 config.overrides[0].rules.unused_exports,
260 Some(Severity::Off)
261 );
262 assert_eq!(config.overrides[0].rules.unused_files, None);
263 }
264
265 #[test]
266 fn resolve_rules_for_path_no_overrides() {
267 let config = FallowConfig {
268 schema: None,
269 extends: vec![],
270 entry: vec![],
271 ignore_patterns: vec![],
272 framework: vec![],
273 workspaces: None,
274 ignore_dependencies: vec![],
275 ignore_exports: vec![],
276 duplicates: DuplicatesConfig::default(),
277 health: HealthConfig::default(),
278 rules: RulesConfig::default(),
279 boundaries: BoundaryConfig::default(),
280 production: false,
281 plugins: vec![],
282 dynamically_loaded: vec![],
283 overrides: vec![],
284 regression: None,
285 codeowners: None,
286 public_packages: vec![],
287 };
288 let resolved = config.resolve(
289 PathBuf::from("/project"),
290 OutputFormat::Human,
291 1,
292 true,
293 true,
294 );
295 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
296 assert_eq!(rules.unused_files, Severity::Error);
297 }
298
299 #[test]
300 fn resolve_rules_for_path_with_matching_override() {
301 let config = FallowConfig {
302 schema: None,
303 extends: vec![],
304 entry: vec![],
305 ignore_patterns: vec![],
306 framework: vec![],
307 workspaces: None,
308 ignore_dependencies: vec![],
309 ignore_exports: vec![],
310 duplicates: DuplicatesConfig::default(),
311 health: HealthConfig::default(),
312 rules: RulesConfig::default(),
313 boundaries: BoundaryConfig::default(),
314 production: false,
315 plugins: vec![],
316 dynamically_loaded: vec![],
317 overrides: vec![ConfigOverride {
318 files: vec!["*.test.ts".to_string()],
319 rules: PartialRulesConfig {
320 unused_exports: Some(Severity::Off),
321 ..Default::default()
322 },
323 }],
324 regression: None,
325 codeowners: None,
326 public_packages: vec![],
327 };
328 let resolved = config.resolve(
329 PathBuf::from("/project"),
330 OutputFormat::Human,
331 1,
332 true,
333 true,
334 );
335
336 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
338 assert_eq!(test_rules.unused_exports, Severity::Off);
339 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
343 assert_eq!(src_rules.unused_exports, Severity::Error);
344 }
345
346 #[test]
347 fn resolve_rules_for_path_later_override_wins() {
348 let config = FallowConfig {
349 schema: None,
350 extends: vec![],
351 entry: vec![],
352 ignore_patterns: vec![],
353 framework: vec![],
354 workspaces: None,
355 ignore_dependencies: vec![],
356 ignore_exports: vec![],
357 duplicates: DuplicatesConfig::default(),
358 health: HealthConfig::default(),
359 rules: RulesConfig::default(),
360 boundaries: BoundaryConfig::default(),
361 production: false,
362 plugins: vec![],
363 dynamically_loaded: vec![],
364 overrides: vec![
365 ConfigOverride {
366 files: vec!["*.ts".to_string()],
367 rules: PartialRulesConfig {
368 unused_files: Some(Severity::Warn),
369 ..Default::default()
370 },
371 },
372 ConfigOverride {
373 files: vec!["*.test.ts".to_string()],
374 rules: PartialRulesConfig {
375 unused_files: Some(Severity::Off),
376 ..Default::default()
377 },
378 },
379 ],
380 regression: None,
381 codeowners: None,
382 public_packages: vec![],
383 };
384 let resolved = config.resolve(
385 PathBuf::from("/project"),
386 OutputFormat::Human,
387 1,
388 true,
389 true,
390 );
391
392 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
394 assert_eq!(rules.unused_files, Severity::Off);
395
396 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
398 assert_eq!(rules2.unused_files, Severity::Warn);
399 }
400
401 fn make_config(production: bool) -> FallowConfig {
403 FallowConfig {
404 schema: None,
405 extends: vec![],
406 entry: vec![],
407 ignore_patterns: vec![],
408 framework: vec![],
409 workspaces: None,
410 ignore_dependencies: vec![],
411 ignore_exports: vec![],
412 duplicates: DuplicatesConfig::default(),
413 health: HealthConfig::default(),
414 rules: RulesConfig::default(),
415 boundaries: BoundaryConfig::default(),
416 production,
417 plugins: vec![],
418 dynamically_loaded: vec![],
419 overrides: vec![],
420 regression: None,
421 codeowners: None,
422 public_packages: vec![],
423 }
424 }
425
426 #[test]
429 fn resolve_production_forces_dev_deps_off() {
430 let resolved = make_config(true).resolve(
431 PathBuf::from("/project"),
432 OutputFormat::Human,
433 1,
434 true,
435 true,
436 );
437 assert_eq!(
438 resolved.rules.unused_dev_dependencies,
439 Severity::Off,
440 "production mode should force unused_dev_dependencies to off"
441 );
442 }
443
444 #[test]
445 fn resolve_production_forces_optional_deps_off() {
446 let resolved = make_config(true).resolve(
447 PathBuf::from("/project"),
448 OutputFormat::Human,
449 1,
450 true,
451 true,
452 );
453 assert_eq!(
454 resolved.rules.unused_optional_dependencies,
455 Severity::Off,
456 "production mode should force unused_optional_dependencies to off"
457 );
458 }
459
460 #[test]
461 fn resolve_production_preserves_other_rules() {
462 let resolved = make_config(true).resolve(
463 PathBuf::from("/project"),
464 OutputFormat::Human,
465 1,
466 true,
467 true,
468 );
469 assert_eq!(resolved.rules.unused_files, Severity::Error);
471 assert_eq!(resolved.rules.unused_exports, Severity::Error);
472 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
473 }
474
475 #[test]
476 fn resolve_non_production_keeps_dev_deps_default() {
477 let resolved = make_config(false).resolve(
478 PathBuf::from("/project"),
479 OutputFormat::Human,
480 1,
481 true,
482 true,
483 );
484 assert_eq!(
485 resolved.rules.unused_dev_dependencies,
486 Severity::Warn,
487 "non-production should keep default severity"
488 );
489 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
490 }
491
492 #[test]
493 fn resolve_production_flag_stored() {
494 let resolved = make_config(true).resolve(
495 PathBuf::from("/project"),
496 OutputFormat::Human,
497 1,
498 true,
499 true,
500 );
501 assert!(resolved.production);
502
503 let resolved2 = make_config(false).resolve(
504 PathBuf::from("/project"),
505 OutputFormat::Human,
506 1,
507 true,
508 true,
509 );
510 assert!(!resolved2.production);
511 }
512
513 #[test]
516 fn resolve_default_ignores_node_modules() {
517 let resolved = make_config(false).resolve(
518 PathBuf::from("/project"),
519 OutputFormat::Human,
520 1,
521 true,
522 true,
523 );
524 assert!(
525 resolved
526 .ignore_patterns
527 .is_match("node_modules/lodash/index.js")
528 );
529 assert!(
530 resolved
531 .ignore_patterns
532 .is_match("packages/a/node_modules/react/index.js")
533 );
534 }
535
536 #[test]
537 fn resolve_default_ignores_dist() {
538 let resolved = make_config(false).resolve(
539 PathBuf::from("/project"),
540 OutputFormat::Human,
541 1,
542 true,
543 true,
544 );
545 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
546 assert!(
547 resolved
548 .ignore_patterns
549 .is_match("packages/ui/dist/index.js")
550 );
551 }
552
553 #[test]
554 fn resolve_default_ignores_root_build_only() {
555 let resolved = make_config(false).resolve(
556 PathBuf::from("/project"),
557 OutputFormat::Human,
558 1,
559 true,
560 true,
561 );
562 assert!(
563 resolved.ignore_patterns.is_match("build/output.js"),
564 "root build/ should be ignored"
565 );
566 assert!(
568 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
569 "nested build/ should NOT be ignored by default"
570 );
571 }
572
573 #[test]
574 fn resolve_default_ignores_minified_files() {
575 let resolved = make_config(false).resolve(
576 PathBuf::from("/project"),
577 OutputFormat::Human,
578 1,
579 true,
580 true,
581 );
582 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
583 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
584 }
585
586 #[test]
587 fn resolve_default_ignores_git() {
588 let resolved = make_config(false).resolve(
589 PathBuf::from("/project"),
590 OutputFormat::Human,
591 1,
592 true,
593 true,
594 );
595 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
596 }
597
598 #[test]
599 fn resolve_default_ignores_coverage() {
600 let resolved = make_config(false).resolve(
601 PathBuf::from("/project"),
602 OutputFormat::Human,
603 1,
604 true,
605 true,
606 );
607 assert!(
608 resolved
609 .ignore_patterns
610 .is_match("coverage/lcov-report/index.js")
611 );
612 }
613
614 #[test]
615 fn resolve_source_files_not_ignored_by_default() {
616 let resolved = make_config(false).resolve(
617 PathBuf::from("/project"),
618 OutputFormat::Human,
619 1,
620 true,
621 true,
622 );
623 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
624 assert!(
625 !resolved
626 .ignore_patterns
627 .is_match("src/components/Button.tsx")
628 );
629 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
630 }
631
632 #[test]
635 fn resolve_custom_ignore_patterns_merged_with_defaults() {
636 let mut config = make_config(false);
637 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
638 let resolved = config.resolve(
639 PathBuf::from("/project"),
640 OutputFormat::Human,
641 1,
642 true,
643 true,
644 );
645 assert!(
647 resolved
648 .ignore_patterns
649 .is_match("src/__generated__/types.ts")
650 );
651 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
653 }
654
655 #[test]
658 fn resolve_passes_through_entry_patterns() {
659 let mut config = make_config(false);
660 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
661 let resolved = config.resolve(
662 PathBuf::from("/project"),
663 OutputFormat::Human,
664 1,
665 true,
666 true,
667 );
668 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
669 }
670
671 #[test]
672 fn resolve_passes_through_ignore_dependencies() {
673 let mut config = make_config(false);
674 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
675 let resolved = config.resolve(
676 PathBuf::from("/project"),
677 OutputFormat::Human,
678 1,
679 true,
680 true,
681 );
682 assert_eq!(
683 resolved.ignore_dependencies,
684 vec!["postcss", "autoprefixer"]
685 );
686 }
687
688 #[test]
689 fn resolve_sets_cache_dir() {
690 let resolved = make_config(false).resolve(
691 PathBuf::from("/my/project"),
692 OutputFormat::Human,
693 1,
694 true,
695 true,
696 );
697 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
698 }
699
700 #[test]
701 fn resolve_passes_through_thread_count() {
702 let resolved = make_config(false).resolve(
703 PathBuf::from("/project"),
704 OutputFormat::Human,
705 8,
706 true,
707 true,
708 );
709 assert_eq!(resolved.threads, 8);
710 }
711
712 #[test]
713 fn resolve_passes_through_quiet_flag() {
714 let resolved = make_config(false).resolve(
715 PathBuf::from("/project"),
716 OutputFormat::Human,
717 1,
718 true,
719 false,
720 );
721 assert!(!resolved.quiet);
722
723 let resolved2 = make_config(false).resolve(
724 PathBuf::from("/project"),
725 OutputFormat::Human,
726 1,
727 true,
728 true,
729 );
730 assert!(resolved2.quiet);
731 }
732
733 #[test]
734 fn resolve_passes_through_no_cache_flag() {
735 let resolved_no_cache = make_config(false).resolve(
736 PathBuf::from("/project"),
737 OutputFormat::Human,
738 1,
739 true,
740 true,
741 );
742 assert!(resolved_no_cache.no_cache);
743
744 let resolved_with_cache = make_config(false).resolve(
745 PathBuf::from("/project"),
746 OutputFormat::Human,
747 1,
748 false,
749 true,
750 );
751 assert!(!resolved_with_cache.no_cache);
752 }
753
754 #[test]
757 fn resolve_override_with_invalid_glob_skipped() {
758 let mut config = make_config(false);
759 config.overrides = vec![ConfigOverride {
760 files: vec!["[invalid".to_string()],
761 rules: PartialRulesConfig {
762 unused_files: Some(Severity::Off),
763 ..Default::default()
764 },
765 }];
766 let resolved = config.resolve(
767 PathBuf::from("/project"),
768 OutputFormat::Human,
769 1,
770 true,
771 true,
772 );
773 assert!(
775 resolved.overrides.is_empty(),
776 "override with invalid glob should be skipped"
777 );
778 }
779
780 #[test]
781 fn resolve_override_with_empty_files_skipped() {
782 let mut config = make_config(false);
783 config.overrides = vec![ConfigOverride {
784 files: vec![],
785 rules: PartialRulesConfig {
786 unused_files: Some(Severity::Off),
787 ..Default::default()
788 },
789 }];
790 let resolved = config.resolve(
791 PathBuf::from("/project"),
792 OutputFormat::Human,
793 1,
794 true,
795 true,
796 );
797 assert!(
798 resolved.overrides.is_empty(),
799 "override with no file patterns should be skipped"
800 );
801 }
802
803 #[test]
804 fn resolve_multiple_valid_overrides() {
805 let mut config = make_config(false);
806 config.overrides = vec![
807 ConfigOverride {
808 files: vec!["*.test.ts".to_string()],
809 rules: PartialRulesConfig {
810 unused_exports: Some(Severity::Off),
811 ..Default::default()
812 },
813 },
814 ConfigOverride {
815 files: vec!["*.stories.tsx".to_string()],
816 rules: PartialRulesConfig {
817 unused_files: Some(Severity::Off),
818 ..Default::default()
819 },
820 },
821 ];
822 let resolved = config.resolve(
823 PathBuf::from("/project"),
824 OutputFormat::Human,
825 1,
826 true,
827 true,
828 );
829 assert_eq!(resolved.overrides.len(), 2);
830 }
831
832 #[test]
835 fn ignore_export_rule_deserialize() {
836 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
837 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
838 assert_eq!(rule.file, "src/types/*.ts");
839 assert_eq!(rule.exports, vec!["*"]);
840 }
841
842 #[test]
843 fn ignore_export_rule_specific_exports() {
844 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
845 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
846 assert_eq!(rule.exports.len(), 3);
847 assert!(rule.exports.contains(&"FOO".to_string()));
848 }
849
850 mod proptests {
851 use super::*;
852 use proptest::prelude::*;
853
854 fn arb_resolved_config(production: bool) -> ResolvedConfig {
855 make_config(production).resolve(
856 PathBuf::from("/project"),
857 OutputFormat::Human,
858 1,
859 true,
860 true,
861 )
862 }
863
864 proptest! {
865 #[test]
867 fn resolved_config_has_default_ignores(production in any::<bool>()) {
868 let resolved = arb_resolved_config(production);
869 prop_assert!(
871 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
872 "Default ignore should match node_modules"
873 );
874 prop_assert!(
875 resolved.ignore_patterns.is_match("dist/bundle.js"),
876 "Default ignore should match dist"
877 );
878 }
879
880 #[test]
882 fn production_forces_dev_deps_off(_unused in Just(())) {
883 let resolved = arb_resolved_config(true);
884 prop_assert_eq!(
885 resolved.rules.unused_dev_dependencies,
886 Severity::Off,
887 "Production should force unused_dev_dependencies off"
888 );
889 prop_assert_eq!(
890 resolved.rules.unused_optional_dependencies,
891 Severity::Off,
892 "Production should force unused_optional_dependencies off"
893 );
894 }
895
896 #[test]
898 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
899 let resolved = arb_resolved_config(false);
900 prop_assert_eq!(
901 resolved.rules.unused_dev_dependencies,
902 Severity::Warn,
903 "Non-production should keep default dev dep severity"
904 );
905 }
906
907 #[test]
909 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
910 let root = PathBuf::from(format!("/project/{dir_suffix}"));
911 let expected_cache = root.join(".fallow");
912 let resolved = make_config(false).resolve(
913 root,
914 OutputFormat::Human,
915 1,
916 true,
917 true,
918 );
919 prop_assert_eq!(
920 resolved.cache_dir, expected_cache,
921 "Cache dir should be root/.fallow"
922 );
923 }
924
925 #[test]
927 fn threads_passed_through(threads in 1..64usize) {
928 let resolved = make_config(false).resolve(
929 PathBuf::from("/project"),
930 OutputFormat::Human,
931 threads,
932 true,
933 true,
934 );
935 prop_assert_eq!(
936 resolved.threads, threads,
937 "Thread count should be passed through"
938 );
939 }
940
941 #[test]
943 fn custom_ignores_dont_replace_defaults(pattern in "[a-zA-Z0-9_*/.]{1,30}") {
944 let mut config = make_config(false);
945 config.ignore_patterns = vec![pattern];
946 let resolved = config.resolve(
947 PathBuf::from("/project"),
948 OutputFormat::Human,
949 1,
950 true,
951 true,
952 );
953 prop_assert!(
955 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
956 "Default node_modules ignore should still be active"
957 );
958 }
959 }
960 }
961
962 #[test]
965 fn resolve_expands_boundary_preset() {
966 use crate::config::boundaries::BoundaryPreset;
967
968 let mut config = make_config(false);
969 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
970 let resolved = config.resolve(
971 PathBuf::from("/project"),
972 OutputFormat::Human,
973 1,
974 true,
975 true,
976 );
977 assert_eq!(resolved.boundaries.zones.len(), 3);
979 assert_eq!(resolved.boundaries.rules.len(), 3);
980 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
981 assert_eq!(
982 resolved.boundaries.classify_zone("src/adapters/http.ts"),
983 Some("adapters")
984 );
985 }
986
987 #[test]
988 fn resolve_boundary_preset_with_user_override() {
989 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
990
991 let mut config = make_config(false);
992 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
993 config.boundaries.zones = vec![BoundaryZone {
994 name: "domain".to_string(),
995 patterns: vec!["src/core/**".to_string()],
996 root: None,
997 }];
998 let resolved = config.resolve(
999 PathBuf::from("/project"),
1000 OutputFormat::Human,
1001 1,
1002 true,
1003 true,
1004 );
1005 assert_eq!(resolved.boundaries.zones.len(), 3);
1007 assert_eq!(
1009 resolved.boundaries.classify_zone("src/core/user.ts"),
1010 Some("domain")
1011 );
1012 assert_eq!(
1014 resolved.boundaries.classify_zone("src/domain/user.ts"),
1015 None
1016 );
1017 }
1018
1019 #[test]
1020 fn resolve_no_preset_unchanged() {
1021 let config = make_config(false);
1022 let resolved = config.resolve(
1023 PathBuf::from("/project"),
1024 OutputFormat::Human,
1025 1,
1026 true,
1027 true,
1028 );
1029 assert!(resolved.boundaries.is_empty());
1030 }
1031}