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