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