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