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