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