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