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