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