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