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