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