1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::duplicates_config::DuplicatesConfig;
8use super::format::OutputFormat;
9use super::health::HealthConfig;
10use super::rules::{PartialRulesConfig, RulesConfig, Severity};
11use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
12
13use super::FallowConfig;
14
15#[derive(Debug, Deserialize, Serialize, JsonSchema)]
17pub struct IgnoreExportRule {
18 pub file: String,
20 pub exports: Vec<String>,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct ConfigOverride {
28 pub files: Vec<String>,
30 #[serde(default)]
32 pub rules: PartialRulesConfig,
33}
34
35#[derive(Debug)]
37pub struct ResolvedOverride {
38 pub matchers: Vec<globset::GlobMatcher>,
39 pub rules: PartialRulesConfig,
40}
41
42#[derive(Debug)]
44pub struct ResolvedConfig {
45 pub root: PathBuf,
46 pub entry_patterns: Vec<String>,
47 pub ignore_patterns: GlobSet,
48 pub output: OutputFormat,
49 pub cache_dir: PathBuf,
50 pub threads: usize,
51 pub no_cache: bool,
52 pub ignore_dependencies: Vec<String>,
53 pub ignore_export_rules: Vec<IgnoreExportRule>,
54 pub duplicates: DuplicatesConfig,
55 pub health: HealthConfig,
56 pub rules: RulesConfig,
57 pub production: bool,
59 pub quiet: bool,
61 pub external_plugins: Vec<ExternalPluginDef>,
63 pub overrides: Vec<ResolvedOverride>,
65 pub regression: Option<super::RegressionConfig>,
67}
68
69impl FallowConfig {
70 pub fn resolve(
72 self,
73 root: PathBuf,
74 output: OutputFormat,
75 threads: usize,
76 no_cache: bool,
77 quiet: bool,
78 ) -> ResolvedConfig {
79 let mut ignore_builder = GlobSetBuilder::new();
80 for pattern in &self.ignore_patterns {
81 match Glob::new(pattern) {
82 Ok(glob) => {
83 ignore_builder.add(glob);
84 }
85 Err(e) => {
86 tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
87 }
88 }
89 }
90
91 let default_ignores = [
95 "**/node_modules/**",
96 "**/dist/**",
97 "build/**",
98 "**/.git/**",
99 "**/coverage/**",
100 "**/*.min.js",
101 "**/*.min.mjs",
102 ];
103 for pattern in &default_ignores {
104 if let Ok(glob) = Glob::new(pattern) {
105 ignore_builder.add(glob);
106 }
107 }
108
109 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
110 let cache_dir = root.join(".fallow");
111
112 let mut rules = self.rules;
113
114 let production = self.production;
116 if production {
117 rules.unused_dev_dependencies = Severity::Off;
118 rules.unused_optional_dependencies = Severity::Off;
119 }
120
121 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
122 external_plugins.extend(self.framework);
124
125 let overrides = self
127 .overrides
128 .into_iter()
129 .filter_map(|o| {
130 let matchers: Vec<globset::GlobMatcher> = o
131 .files
132 .iter()
133 .filter_map(|pattern| match Glob::new(pattern) {
134 Ok(glob) => Some(glob.compile_matcher()),
135 Err(e) => {
136 tracing::warn!("invalid override glob pattern '{pattern}': {e}");
137 None
138 }
139 })
140 .collect();
141 if matchers.is_empty() {
142 None
143 } else {
144 Some(ResolvedOverride {
145 matchers,
146 rules: o.rules,
147 })
148 }
149 })
150 .collect();
151
152 ResolvedConfig {
153 root,
154 entry_patterns: self.entry,
155 ignore_patterns: compiled_ignore_patterns,
156 output,
157 cache_dir,
158 threads,
159 no_cache,
160 ignore_dependencies: self.ignore_dependencies,
161 ignore_export_rules: self.ignore_exports,
162 duplicates: self.duplicates,
163 health: self.health,
164 rules,
165 production,
166 quiet,
167 external_plugins,
168 overrides,
169 regression: self.regression,
170 }
171 }
172}
173
174impl ResolvedConfig {
175 #[must_use]
178 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
179 if self.overrides.is_empty() {
180 return self.rules.clone();
181 }
182
183 let relative = path.strip_prefix(&self.root).unwrap_or(path);
184 let relative_str = relative.to_string_lossy();
185
186 let mut rules = self.rules.clone();
187 for override_entry in &self.overrides {
188 let matches = override_entry
189 .matchers
190 .iter()
191 .any(|m| m.is_match(relative_str.as_ref()));
192 if matches {
193 rules.apply_partial(&override_entry.rules);
194 }
195 }
196 rules
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::config::health::HealthConfig;
204
205 #[test]
206 fn overrides_deserialize() {
207 let json_str = r#"{
208 "overrides": [{
209 "files": ["*.test.ts"],
210 "rules": {
211 "unused-exports": "off"
212 }
213 }]
214 }"#;
215 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
216 assert_eq!(config.overrides.len(), 1);
217 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
218 assert_eq!(
219 config.overrides[0].rules.unused_exports,
220 Some(Severity::Off)
221 );
222 assert_eq!(config.overrides[0].rules.unused_files, None);
223 }
224
225 #[test]
226 fn resolve_rules_for_path_no_overrides() {
227 let config = FallowConfig {
228 schema: None,
229 extends: vec![],
230 entry: vec![],
231 ignore_patterns: vec![],
232 framework: vec![],
233 workspaces: None,
234 ignore_dependencies: vec![],
235 ignore_exports: vec![],
236 duplicates: DuplicatesConfig::default(),
237 health: HealthConfig::default(),
238 rules: RulesConfig::default(),
239 production: false,
240 plugins: vec![],
241 overrides: vec![],
242 regression: None,
243 };
244 let resolved = config.resolve(
245 PathBuf::from("/project"),
246 OutputFormat::Human,
247 1,
248 true,
249 true,
250 );
251 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
252 assert_eq!(rules.unused_files, Severity::Error);
253 }
254
255 #[test]
256 fn resolve_rules_for_path_with_matching_override() {
257 let config = FallowConfig {
258 schema: None,
259 extends: vec![],
260 entry: vec![],
261 ignore_patterns: vec![],
262 framework: vec![],
263 workspaces: None,
264 ignore_dependencies: vec![],
265 ignore_exports: vec![],
266 duplicates: DuplicatesConfig::default(),
267 health: HealthConfig::default(),
268 rules: RulesConfig::default(),
269 production: false,
270 plugins: vec![],
271 overrides: vec![ConfigOverride {
272 files: vec!["*.test.ts".to_string()],
273 rules: PartialRulesConfig {
274 unused_exports: Some(Severity::Off),
275 ..Default::default()
276 },
277 }],
278 regression: None,
279 };
280 let resolved = config.resolve(
281 PathBuf::from("/project"),
282 OutputFormat::Human,
283 1,
284 true,
285 true,
286 );
287
288 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
290 assert_eq!(test_rules.unused_exports, Severity::Off);
291 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
295 assert_eq!(src_rules.unused_exports, Severity::Error);
296 }
297
298 #[test]
299 fn resolve_rules_for_path_later_override_wins() {
300 let config = FallowConfig {
301 schema: None,
302 extends: vec![],
303 entry: vec![],
304 ignore_patterns: vec![],
305 framework: vec![],
306 workspaces: None,
307 ignore_dependencies: vec![],
308 ignore_exports: vec![],
309 duplicates: DuplicatesConfig::default(),
310 health: HealthConfig::default(),
311 rules: RulesConfig::default(),
312 production: false,
313 plugins: vec![],
314 overrides: vec![
315 ConfigOverride {
316 files: vec!["*.ts".to_string()],
317 rules: PartialRulesConfig {
318 unused_files: Some(Severity::Warn),
319 ..Default::default()
320 },
321 },
322 ConfigOverride {
323 files: vec!["*.test.ts".to_string()],
324 rules: PartialRulesConfig {
325 unused_files: Some(Severity::Off),
326 ..Default::default()
327 },
328 },
329 ],
330 regression: None,
331 };
332 let resolved = config.resolve(
333 PathBuf::from("/project"),
334 OutputFormat::Human,
335 1,
336 true,
337 true,
338 );
339
340 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
342 assert_eq!(rules.unused_files, Severity::Off);
343
344 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
346 assert_eq!(rules2.unused_files, Severity::Warn);
347 }
348
349 fn make_config(production: bool) -> FallowConfig {
351 FallowConfig {
352 schema: None,
353 extends: vec![],
354 entry: vec![],
355 ignore_patterns: vec![],
356 framework: vec![],
357 workspaces: None,
358 ignore_dependencies: vec![],
359 ignore_exports: vec![],
360 duplicates: DuplicatesConfig::default(),
361 health: HealthConfig::default(),
362 rules: RulesConfig::default(),
363 production,
364 plugins: vec![],
365 overrides: vec![],
366 regression: None,
367 }
368 }
369
370 #[test]
373 fn resolve_production_forces_dev_deps_off() {
374 let resolved = make_config(true).resolve(
375 PathBuf::from("/project"),
376 OutputFormat::Human,
377 1,
378 true,
379 true,
380 );
381 assert_eq!(
382 resolved.rules.unused_dev_dependencies,
383 Severity::Off,
384 "production mode should force unused_dev_dependencies to off"
385 );
386 }
387
388 #[test]
389 fn resolve_production_forces_optional_deps_off() {
390 let resolved = make_config(true).resolve(
391 PathBuf::from("/project"),
392 OutputFormat::Human,
393 1,
394 true,
395 true,
396 );
397 assert_eq!(
398 resolved.rules.unused_optional_dependencies,
399 Severity::Off,
400 "production mode should force unused_optional_dependencies to off"
401 );
402 }
403
404 #[test]
405 fn resolve_production_preserves_other_rules() {
406 let resolved = make_config(true).resolve(
407 PathBuf::from("/project"),
408 OutputFormat::Human,
409 1,
410 true,
411 true,
412 );
413 assert_eq!(resolved.rules.unused_files, Severity::Error);
415 assert_eq!(resolved.rules.unused_exports, Severity::Error);
416 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
417 }
418
419 #[test]
420 fn resolve_non_production_keeps_dev_deps_default() {
421 let resolved = make_config(false).resolve(
422 PathBuf::from("/project"),
423 OutputFormat::Human,
424 1,
425 true,
426 true,
427 );
428 assert_eq!(
429 resolved.rules.unused_dev_dependencies,
430 Severity::Error,
431 "non-production should keep default severity"
432 );
433 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Error);
434 }
435
436 #[test]
437 fn resolve_production_flag_stored() {
438 let resolved = make_config(true).resolve(
439 PathBuf::from("/project"),
440 OutputFormat::Human,
441 1,
442 true,
443 true,
444 );
445 assert!(resolved.production);
446
447 let resolved2 = make_config(false).resolve(
448 PathBuf::from("/project"),
449 OutputFormat::Human,
450 1,
451 true,
452 true,
453 );
454 assert!(!resolved2.production);
455 }
456
457 #[test]
460 fn resolve_default_ignores_node_modules() {
461 let resolved = make_config(false).resolve(
462 PathBuf::from("/project"),
463 OutputFormat::Human,
464 1,
465 true,
466 true,
467 );
468 assert!(
469 resolved
470 .ignore_patterns
471 .is_match("node_modules/lodash/index.js")
472 );
473 assert!(
474 resolved
475 .ignore_patterns
476 .is_match("packages/a/node_modules/react/index.js")
477 );
478 }
479
480 #[test]
481 fn resolve_default_ignores_dist() {
482 let resolved = make_config(false).resolve(
483 PathBuf::from("/project"),
484 OutputFormat::Human,
485 1,
486 true,
487 true,
488 );
489 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
490 assert!(
491 resolved
492 .ignore_patterns
493 .is_match("packages/ui/dist/index.js")
494 );
495 }
496
497 #[test]
498 fn resolve_default_ignores_root_build_only() {
499 let resolved = make_config(false).resolve(
500 PathBuf::from("/project"),
501 OutputFormat::Human,
502 1,
503 true,
504 true,
505 );
506 assert!(
507 resolved.ignore_patterns.is_match("build/output.js"),
508 "root build/ should be ignored"
509 );
510 assert!(
512 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
513 "nested build/ should NOT be ignored by default"
514 );
515 }
516
517 #[test]
518 fn resolve_default_ignores_minified_files() {
519 let resolved = make_config(false).resolve(
520 PathBuf::from("/project"),
521 OutputFormat::Human,
522 1,
523 true,
524 true,
525 );
526 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
527 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
528 }
529
530 #[test]
531 fn resolve_default_ignores_git() {
532 let resolved = make_config(false).resolve(
533 PathBuf::from("/project"),
534 OutputFormat::Human,
535 1,
536 true,
537 true,
538 );
539 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
540 }
541
542 #[test]
543 fn resolve_default_ignores_coverage() {
544 let resolved = make_config(false).resolve(
545 PathBuf::from("/project"),
546 OutputFormat::Human,
547 1,
548 true,
549 true,
550 );
551 assert!(
552 resolved
553 .ignore_patterns
554 .is_match("coverage/lcov-report/index.js")
555 );
556 }
557
558 #[test]
559 fn resolve_source_files_not_ignored_by_default() {
560 let resolved = make_config(false).resolve(
561 PathBuf::from("/project"),
562 OutputFormat::Human,
563 1,
564 true,
565 true,
566 );
567 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
568 assert!(
569 !resolved
570 .ignore_patterns
571 .is_match("src/components/Button.tsx")
572 );
573 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
574 }
575
576 #[test]
579 fn resolve_custom_ignore_patterns_merged_with_defaults() {
580 let mut config = make_config(false);
581 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
582 let resolved = config.resolve(
583 PathBuf::from("/project"),
584 OutputFormat::Human,
585 1,
586 true,
587 true,
588 );
589 assert!(
591 resolved
592 .ignore_patterns
593 .is_match("src/__generated__/types.ts")
594 );
595 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
597 }
598
599 #[test]
602 fn resolve_passes_through_entry_patterns() {
603 let mut config = make_config(false);
604 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
605 let resolved = config.resolve(
606 PathBuf::from("/project"),
607 OutputFormat::Human,
608 1,
609 true,
610 true,
611 );
612 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
613 }
614
615 #[test]
616 fn resolve_passes_through_ignore_dependencies() {
617 let mut config = make_config(false);
618 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
619 let resolved = config.resolve(
620 PathBuf::from("/project"),
621 OutputFormat::Human,
622 1,
623 true,
624 true,
625 );
626 assert_eq!(
627 resolved.ignore_dependencies,
628 vec!["postcss", "autoprefixer"]
629 );
630 }
631
632 #[test]
633 fn resolve_sets_cache_dir() {
634 let resolved = make_config(false).resolve(
635 PathBuf::from("/my/project"),
636 OutputFormat::Human,
637 1,
638 true,
639 true,
640 );
641 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
642 }
643
644 #[test]
645 fn resolve_passes_through_thread_count() {
646 let resolved = make_config(false).resolve(
647 PathBuf::from("/project"),
648 OutputFormat::Human,
649 8,
650 true,
651 true,
652 );
653 assert_eq!(resolved.threads, 8);
654 }
655
656 #[test]
657 fn resolve_passes_through_quiet_flag() {
658 let resolved = make_config(false).resolve(
659 PathBuf::from("/project"),
660 OutputFormat::Human,
661 1,
662 true,
663 false,
664 );
665 assert!(!resolved.quiet);
666
667 let resolved2 = make_config(false).resolve(
668 PathBuf::from("/project"),
669 OutputFormat::Human,
670 1,
671 true,
672 true,
673 );
674 assert!(resolved2.quiet);
675 }
676
677 #[test]
678 fn resolve_passes_through_no_cache_flag() {
679 let resolved_no_cache = make_config(false).resolve(
680 PathBuf::from("/project"),
681 OutputFormat::Human,
682 1,
683 true,
684 true,
685 );
686 assert!(resolved_no_cache.no_cache);
687
688 let resolved_with_cache = make_config(false).resolve(
689 PathBuf::from("/project"),
690 OutputFormat::Human,
691 1,
692 false,
693 true,
694 );
695 assert!(!resolved_with_cache.no_cache);
696 }
697
698 #[test]
701 fn resolve_override_with_invalid_glob_skipped() {
702 let mut config = make_config(false);
703 config.overrides = vec![ConfigOverride {
704 files: vec!["[invalid".to_string()],
705 rules: PartialRulesConfig {
706 unused_files: Some(Severity::Off),
707 ..Default::default()
708 },
709 }];
710 let resolved = config.resolve(
711 PathBuf::from("/project"),
712 OutputFormat::Human,
713 1,
714 true,
715 true,
716 );
717 assert!(
719 resolved.overrides.is_empty(),
720 "override with invalid glob should be skipped"
721 );
722 }
723
724 #[test]
725 fn resolve_override_with_empty_files_skipped() {
726 let mut config = make_config(false);
727 config.overrides = vec![ConfigOverride {
728 files: vec![],
729 rules: PartialRulesConfig {
730 unused_files: Some(Severity::Off),
731 ..Default::default()
732 },
733 }];
734 let resolved = config.resolve(
735 PathBuf::from("/project"),
736 OutputFormat::Human,
737 1,
738 true,
739 true,
740 );
741 assert!(
742 resolved.overrides.is_empty(),
743 "override with no file patterns should be skipped"
744 );
745 }
746
747 #[test]
748 fn resolve_multiple_valid_overrides() {
749 let mut config = make_config(false);
750 config.overrides = vec![
751 ConfigOverride {
752 files: vec!["*.test.ts".to_string()],
753 rules: PartialRulesConfig {
754 unused_exports: Some(Severity::Off),
755 ..Default::default()
756 },
757 },
758 ConfigOverride {
759 files: vec!["*.stories.tsx".to_string()],
760 rules: PartialRulesConfig {
761 unused_files: Some(Severity::Off),
762 ..Default::default()
763 },
764 },
765 ];
766 let resolved = config.resolve(
767 PathBuf::from("/project"),
768 OutputFormat::Human,
769 1,
770 true,
771 true,
772 );
773 assert_eq!(resolved.overrides.len(), 2);
774 }
775
776 #[test]
779 fn ignore_export_rule_deserialize() {
780 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
781 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
782 assert_eq!(rule.file, "src/types/*.ts");
783 assert_eq!(rule.exports, vec!["*"]);
784 }
785
786 #[test]
787 fn ignore_export_rule_specific_exports() {
788 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
789 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
790 assert_eq!(rule.exports.len(), 3);
791 assert!(rule.exports.contains(&"FOO".to_string()));
792 }
793
794 mod proptests {
795 use super::*;
796 use proptest::prelude::*;
797
798 fn arb_resolved_config(production: bool) -> ResolvedConfig {
799 make_config(production).resolve(
800 PathBuf::from("/project"),
801 OutputFormat::Human,
802 1,
803 true,
804 true,
805 )
806 }
807
808 proptest! {
809 #[test]
811 fn resolved_config_has_default_ignores(production in any::<bool>()) {
812 let resolved = arb_resolved_config(production);
813 prop_assert!(
815 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
816 "Default ignore should match node_modules"
817 );
818 prop_assert!(
819 resolved.ignore_patterns.is_match("dist/bundle.js"),
820 "Default ignore should match dist"
821 );
822 }
823
824 #[test]
826 fn production_forces_dev_deps_off(_unused in Just(())) {
827 let resolved = arb_resolved_config(true);
828 prop_assert_eq!(
829 resolved.rules.unused_dev_dependencies,
830 Severity::Off,
831 "Production should force unused_dev_dependencies off"
832 );
833 prop_assert_eq!(
834 resolved.rules.unused_optional_dependencies,
835 Severity::Off,
836 "Production should force unused_optional_dependencies off"
837 );
838 }
839
840 #[test]
842 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
843 let resolved = arb_resolved_config(false);
844 prop_assert_eq!(
845 resolved.rules.unused_dev_dependencies,
846 Severity::Error,
847 "Non-production should keep default dev dep severity"
848 );
849 }
850
851 #[test]
853 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
854 let root = PathBuf::from(format!("/project/{dir_suffix}"));
855 let expected_cache = root.join(".fallow");
856 let resolved = make_config(false).resolve(
857 root,
858 OutputFormat::Human,
859 1,
860 true,
861 true,
862 );
863 prop_assert_eq!(
864 resolved.cache_dir, expected_cache,
865 "Cache dir should be root/.fallow"
866 );
867 }
868
869 #[test]
871 fn threads_passed_through(threads in 1..64usize) {
872 let resolved = make_config(false).resolve(
873 PathBuf::from("/project"),
874 OutputFormat::Human,
875 threads,
876 true,
877 true,
878 );
879 prop_assert_eq!(
880 resolved.threads, threads,
881 "Thread count should be passed through"
882 );
883 }
884
885 #[test]
887 fn custom_ignores_dont_replace_defaults(pattern in "[a-zA-Z0-9_*/.]{1,30}") {
888 let mut config = make_config(false);
889 config.ignore_patterns = vec![pattern];
890 let resolved = config.resolve(
891 PathBuf::from("/project"),
892 OutputFormat::Human,
893 1,
894 true,
895 true,
896 );
897 prop_assert!(
899 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
900 "Default node_modules ignore should still be active"
901 );
902 }
903 }
904 }
905}