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