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