1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::FxHashSet;
5
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
11use crate::workspace::WorkspaceConfig;
12
13const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
18
19#[derive(Debug, Deserialize, Serialize, JsonSchema)]
21#[serde(deny_unknown_fields, rename_all = "camelCase")]
22pub struct FallowConfig {
23 #[serde(rename = "$schema", default, skip_serializing)]
25 #[schemars(skip)]
26 pub schema: Option<String>,
27
28 #[serde(default, skip_serializing)]
33 pub extends: Vec<String>,
34
35 #[serde(default)]
37 pub entry: Vec<String>,
38
39 #[serde(default)]
41 pub ignore_patterns: Vec<String>,
42
43 #[serde(default)]
45 pub framework: Vec<ExternalPluginDef>,
46
47 #[serde(default)]
49 pub workspaces: Option<WorkspaceConfig>,
50
51 #[serde(default)]
53 pub ignore_dependencies: Vec<String>,
54
55 #[serde(default)]
57 pub ignore_exports: Vec<IgnoreExportRule>,
58
59 #[serde(default)]
61 pub duplicates: DuplicatesConfig,
62
63 #[serde(default)]
65 pub rules: RulesConfig,
66
67 #[serde(default)]
69 pub production: bool,
70
71 #[serde(default)]
79 pub plugins: Vec<String>,
80
81 #[serde(default)]
83 pub overrides: Vec<ConfigOverride>,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
88#[serde(rename_all = "camelCase")]
89pub struct DuplicatesConfig {
90 #[serde(default = "default_true")]
92 pub enabled: bool,
93
94 #[serde(default)]
96 pub mode: DetectionMode,
97
98 #[serde(default = "default_min_tokens")]
100 pub min_tokens: usize,
101
102 #[serde(default = "default_min_lines")]
104 pub min_lines: usize,
105
106 #[serde(default)]
108 pub threshold: f64,
109
110 #[serde(default)]
112 pub ignore: Vec<String>,
113
114 #[serde(default)]
116 pub skip_local: bool,
117
118 #[serde(default)]
124 pub cross_language: bool,
125
126 #[serde(default)]
128 pub normalization: NormalizationConfig,
129}
130
131impl Default for DuplicatesConfig {
132 fn default() -> Self {
133 Self {
134 enabled: true,
135 mode: DetectionMode::default(),
136 min_tokens: default_min_tokens(),
137 min_lines: default_min_lines(),
138 threshold: 0.0,
139 ignore: vec![],
140 skip_local: false,
141 cross_language: false,
142 normalization: NormalizationConfig::default(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct NormalizationConfig {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub ignore_identifiers: Option<bool>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub ignore_string_values: Option<bool>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub ignore_numeric_values: Option<bool>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ResolvedNormalization {
174 pub ignore_identifiers: bool,
175 pub ignore_string_values: bool,
176 pub ignore_numeric_values: bool,
177}
178
179impl ResolvedNormalization {
180 pub fn resolve(mode: DetectionMode, overrides: &NormalizationConfig) -> Self {
182 let (default_ids, default_strings, default_numbers) = match mode {
183 DetectionMode::Strict | DetectionMode::Mild => (false, false, false),
184 DetectionMode::Weak => (false, true, false),
185 DetectionMode::Semantic => (true, true, true),
186 };
187
188 Self {
189 ignore_identifiers: overrides.ignore_identifiers.unwrap_or(default_ids),
190 ignore_string_values: overrides.ignore_string_values.unwrap_or(default_strings),
191 ignore_numeric_values: overrides.ignore_numeric_values.unwrap_or(default_numbers),
192 }
193 }
194}
195
196#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
204#[serde(rename_all = "lowercase")]
205pub enum DetectionMode {
206 Strict,
208 #[default]
210 Mild,
211 Weak,
213 Semantic,
215}
216
217impl std::fmt::Display for DetectionMode {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Self::Strict => write!(f, "strict"),
221 Self::Mild => write!(f, "mild"),
222 Self::Weak => write!(f, "weak"),
223 Self::Semantic => write!(f, "semantic"),
224 }
225 }
226}
227
228impl std::str::FromStr for DetectionMode {
229 type Err = String;
230
231 fn from_str(s: &str) -> Result<Self, Self::Err> {
232 match s.to_lowercase().as_str() {
233 "strict" => Ok(Self::Strict),
234 "mild" => Ok(Self::Mild),
235 "weak" => Ok(Self::Weak),
236 "semantic" => Ok(Self::Semantic),
237 other => Err(format!("unknown detection mode: '{other}'")),
238 }
239 }
240}
241
242const fn default_min_tokens() -> usize {
243 50
244}
245
246const fn default_min_lines() -> usize {
247 5
248}
249
250#[derive(Debug, Default, Clone)]
254pub enum OutputFormat {
255 #[default]
257 Human,
258 Json,
260 Sarif,
262 Compact,
264 Markdown,
266}
267
268#[derive(Debug, Deserialize, Serialize, JsonSchema)]
270pub struct IgnoreExportRule {
271 pub file: String,
273 pub exports: Vec<String>,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
279#[serde(rename_all = "camelCase")]
280pub struct ConfigOverride {
281 pub files: Vec<String>,
283 #[serde(default)]
285 pub rules: PartialRulesConfig,
286}
287
288#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
290#[serde(rename_all = "kebab-case")]
291pub struct PartialRulesConfig {
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub unused_files: Option<Severity>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub unused_exports: Option<Severity>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub unused_types: Option<Severity>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub unused_dependencies: Option<Severity>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub unused_dev_dependencies: Option<Severity>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub unused_enum_members: Option<Severity>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub unused_class_members: Option<Severity>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub unresolved_imports: Option<Severity>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub unlisted_dependencies: Option<Severity>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub duplicate_exports: Option<Severity>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub circular_dependencies: Option<Severity>,
314}
315
316#[derive(Debug)]
318pub struct ResolvedOverride {
319 pub matchers: Vec<globset::GlobMatcher>,
320 pub rules: PartialRulesConfig,
321}
322
323#[derive(Debug)]
325pub struct ResolvedConfig {
326 pub root: PathBuf,
327 pub entry_patterns: Vec<String>,
328 pub ignore_patterns: GlobSet,
329 pub output: OutputFormat,
330 pub cache_dir: PathBuf,
331 pub threads: usize,
332 pub no_cache: bool,
333 pub ignore_dependencies: Vec<String>,
334 pub ignore_export_rules: Vec<IgnoreExportRule>,
335 pub duplicates: DuplicatesConfig,
336 pub rules: RulesConfig,
337 pub production: bool,
339 pub external_plugins: Vec<ExternalPluginDef>,
341 pub overrides: Vec<ResolvedOverride>,
343}
344
345enum ConfigFormat {
347 Toml,
348 Json,
349}
350
351impl ConfigFormat {
352 fn from_path(path: &Path) -> Self {
353 match path.extension().and_then(|e| e.to_str()) {
354 Some("json") => Self::Json,
355 _ => Self::Toml,
356 }
357 }
358}
359
360const MAX_EXTENDS_DEPTH: usize = 10;
361
362fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
365 match (base, overlay) {
366 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
367 for (key, value) in overlay_map {
368 if let Some(base_value) = base_map.get_mut(&key) {
369 deep_merge_json(base_value, value);
370 } else {
371 base_map.insert(key, value);
372 }
373 }
374 }
375 (base, overlay) => {
376 *base = overlay;
377 }
378 }
379}
380
381fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
382 let content = std::fs::read_to_string(path)
383 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
384
385 match ConfigFormat::from_path(path) {
386 ConfigFormat::Toml => {
387 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
388 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
389 })?;
390 serde_json::to_value(toml_value).map_err(|e| {
391 miette::miette!(
392 "Failed to convert TOML to JSON for {}: {}",
393 path.display(),
394 e
395 )
396 })
397 }
398 ConfigFormat::Json => {
399 let mut stripped = String::new();
400 json_comments::StripComments::new(content.as_bytes())
401 .read_to_string(&mut stripped)
402 .map_err(|e| {
403 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
404 })?;
405 serde_json::from_str(&stripped).map_err(|e| {
406 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
407 })
408 }
409 }
410}
411
412fn resolve_extends(
413 path: &Path,
414 visited: &mut FxHashSet<PathBuf>,
415 depth: usize,
416) -> Result<serde_json::Value, miette::Report> {
417 if depth >= MAX_EXTENDS_DEPTH {
418 return Err(miette::miette!(
419 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
420 path.display()
421 ));
422 }
423
424 let canonical = path.canonicalize().map_err(|e| {
425 miette::miette!(
426 "Config file not found or unresolvable: {}: {}",
427 path.display(),
428 e
429 )
430 })?;
431
432 if !visited.insert(canonical) {
433 return Err(miette::miette!(
434 "Circular extends detected: {} was already visited in the extends chain",
435 path.display()
436 ));
437 }
438
439 let mut value = parse_config_to_value(path)?;
440
441 let extends = value
442 .as_object_mut()
443 .and_then(|obj| obj.remove("extends"))
444 .and_then(|v| match v {
445 serde_json::Value::Array(arr) => Some(
446 arr.into_iter()
447 .filter_map(|v| v.as_str().map(String::from))
448 .collect::<Vec<_>>(),
449 ),
450 serde_json::Value::String(s) => Some(vec![s]),
451 _ => None,
452 })
453 .unwrap_or_default();
454
455 if extends.is_empty() {
456 return Ok(value);
457 }
458
459 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
460 let mut merged = serde_json::Value::Object(serde_json::Map::new());
461
462 for extend_path_str in &extends {
463 if Path::new(extend_path_str).is_absolute() {
464 return Err(miette::miette!(
465 "extends paths must be relative, got absolute path: {} (in {})",
466 extend_path_str,
467 path.display()
468 ));
469 }
470 let extend_path = config_dir.join(extend_path_str);
471 if !extend_path.exists() {
472 return Err(miette::miette!(
473 "Extended config file not found: {} (referenced from {})",
474 extend_path.display(),
475 path.display()
476 ));
477 }
478 let base = resolve_extends(&extend_path, visited, depth + 1)?;
479 deep_merge_json(&mut merged, base);
480 }
481
482 deep_merge_json(&mut merged, value);
483 Ok(merged)
484}
485
486impl FallowConfig {
487 pub fn load(path: &Path) -> Result<Self, miette::Report> {
496 let mut visited = FxHashSet::default();
497 let merged = resolve_extends(path, &mut visited, 0)?;
498
499 serde_json::from_value(merged).map_err(|e| {
500 miette::miette!(
501 "Failed to deserialize config from {}: {}",
502 path.display(),
503 e
504 )
505 })
506 }
507
508 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
519 let mut dir = start;
520 loop {
521 for name in CONFIG_NAMES {
522 let candidate = dir.join(name);
523 if candidate.exists() {
524 match Self::load(&candidate) {
525 Ok(config) => return Ok(Some((config, candidate))),
526 Err(e) => {
527 return Err(format!("Failed to parse {}: {e}", candidate.display()));
528 }
529 }
530 }
531 }
532 if dir.join(".git").exists() || dir.join("package.json").exists() {
534 break;
535 }
536 dir = match dir.parent() {
537 Some(parent) => parent,
538 None => break,
539 };
540 }
541 Ok(None)
542 }
543
544 pub fn json_schema() -> serde_json::Value {
546 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
547 }
548
549 #[expect(clippy::print_stderr)]
551 pub fn resolve(
552 self,
553 root: PathBuf,
554 output: OutputFormat,
555 threads: usize,
556 no_cache: bool,
557 ) -> ResolvedConfig {
558 let mut ignore_builder = GlobSetBuilder::new();
559 for pattern in &self.ignore_patterns {
560 match Glob::new(pattern) {
561 Ok(glob) => {
562 ignore_builder.add(glob);
563 }
564 Err(e) => {
565 eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
566 }
567 }
568 }
569
570 let default_ignores = [
574 "**/node_modules/**",
575 "**/dist/**",
576 "build/**",
577 "**/.git/**",
578 "**/coverage/**",
579 "**/*.min.js",
580 "**/*.min.mjs",
581 ];
582 for pattern in &default_ignores {
583 if let Ok(glob) = Glob::new(pattern) {
584 ignore_builder.add(glob);
585 }
586 }
587
588 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
589 let cache_dir = root.join(".fallow");
590
591 let mut rules = self.rules;
592
593 let production = self.production;
595 if production {
596 rules.unused_dev_dependencies = Severity::Off;
597 }
598
599 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
600 external_plugins.extend(self.framework);
602
603 let overrides = self
605 .overrides
606 .into_iter()
607 .filter_map(|o| {
608 let matchers: Vec<globset::GlobMatcher> = o
609 .files
610 .iter()
611 .filter_map(|pattern| match Glob::new(pattern) {
612 Ok(glob) => Some(glob.compile_matcher()),
613 Err(e) => {
614 eprintln!("Warning: Invalid override glob pattern '{pattern}': {e}");
615 None
616 }
617 })
618 .collect();
619 if matchers.is_empty() {
620 None
621 } else {
622 Some(ResolvedOverride {
623 matchers,
624 rules: o.rules,
625 })
626 }
627 })
628 .collect();
629
630 ResolvedConfig {
631 root,
632 entry_patterns: self.entry,
633 ignore_patterns: compiled_ignore_patterns,
634 output,
635 cache_dir,
636 threads,
637 no_cache,
638 ignore_dependencies: self.ignore_dependencies,
639 ignore_export_rules: self.ignore_exports,
640 duplicates: self.duplicates,
641 rules,
642 production,
643 external_plugins,
644 overrides,
645 }
646 }
647}
648
649impl ResolvedConfig {
650 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
653 if self.overrides.is_empty() {
654 return self.rules.clone();
655 }
656
657 let relative = path.strip_prefix(&self.root).unwrap_or(path);
658 let relative_str = relative.to_string_lossy();
659
660 let mut rules = self.rules.clone();
661 for override_entry in &self.overrides {
662 let matches = override_entry
663 .matchers
664 .iter()
665 .any(|m| m.is_match(relative_str.as_ref()));
666 if matches {
667 rules.apply_partial(&override_entry.rules);
668 }
669 }
670 rules
671 }
672}
673
674const fn default_true() -> bool {
675 true
676}
677
678#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
683#[serde(rename_all = "lowercase")]
684pub enum Severity {
685 #[default]
687 Error,
688 Warn,
690 Off,
692}
693
694impl std::fmt::Display for Severity {
695 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
696 match self {
697 Self::Error => write!(f, "error"),
698 Self::Warn => write!(f, "warn"),
699 Self::Off => write!(f, "off"),
700 }
701 }
702}
703
704impl std::str::FromStr for Severity {
705 type Err = String;
706
707 fn from_str(s: &str) -> Result<Self, Self::Err> {
708 match s.to_lowercase().as_str() {
709 "error" => Ok(Self::Error),
710 "warn" | "warning" => Ok(Self::Warn),
711 "off" | "none" => Ok(Self::Off),
712 other => Err(format!(
713 "unknown severity: '{other}' (expected error, warn, or off)"
714 )),
715 }
716 }
717}
718
719#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
726#[serde(rename_all = "kebab-case")]
727pub struct RulesConfig {
728 #[serde(default)]
729 pub unused_files: Severity,
730 #[serde(default)]
731 pub unused_exports: Severity,
732 #[serde(default)]
733 pub unused_types: Severity,
734 #[serde(default)]
735 pub unused_dependencies: Severity,
736 #[serde(default)]
737 pub unused_dev_dependencies: Severity,
738 #[serde(default)]
739 pub unused_enum_members: Severity,
740 #[serde(default)]
741 pub unused_class_members: Severity,
742 #[serde(default)]
743 pub unresolved_imports: Severity,
744 #[serde(default)]
745 pub unlisted_dependencies: Severity,
746 #[serde(default)]
747 pub duplicate_exports: Severity,
748 #[serde(default)]
749 pub circular_dependencies: Severity,
750}
751
752impl Default for RulesConfig {
753 fn default() -> Self {
754 Self {
755 unused_files: Severity::Error,
756 unused_exports: Severity::Error,
757 unused_types: Severity::Error,
758 unused_dependencies: Severity::Error,
759 unused_dev_dependencies: Severity::Error,
760 unused_enum_members: Severity::Error,
761 unused_class_members: Severity::Error,
762 unresolved_imports: Severity::Error,
763 unlisted_dependencies: Severity::Error,
764 duplicate_exports: Severity::Error,
765 circular_dependencies: Severity::Error,
766 }
767 }
768}
769
770impl RulesConfig {
771 pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
773 if let Some(s) = partial.unused_files {
774 self.unused_files = s;
775 }
776 if let Some(s) = partial.unused_exports {
777 self.unused_exports = s;
778 }
779 if let Some(s) = partial.unused_types {
780 self.unused_types = s;
781 }
782 if let Some(s) = partial.unused_dependencies {
783 self.unused_dependencies = s;
784 }
785 if let Some(s) = partial.unused_dev_dependencies {
786 self.unused_dev_dependencies = s;
787 }
788 if let Some(s) = partial.unused_enum_members {
789 self.unused_enum_members = s;
790 }
791 if let Some(s) = partial.unused_class_members {
792 self.unused_class_members = s;
793 }
794 if let Some(s) = partial.unresolved_imports {
795 self.unresolved_imports = s;
796 }
797 if let Some(s) = partial.unlisted_dependencies {
798 self.unlisted_dependencies = s;
799 }
800 if let Some(s) = partial.duplicate_exports {
801 self.duplicate_exports = s;
802 }
803 if let Some(s) = partial.circular_dependencies {
804 self.circular_dependencies = s;
805 }
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use crate::PackageJson;
813
814 fn test_dir(name: &str) -> PathBuf {
816 use std::sync::atomic::{AtomicU64, Ordering};
817 static COUNTER: AtomicU64 = AtomicU64::new(0);
818 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
819 let dir = std::env::temp_dir().join(format!("fallow-{name}-{id}"));
820 let _ = std::fs::remove_dir_all(&dir);
821 std::fs::create_dir_all(&dir).unwrap();
822 dir
823 }
824
825 #[test]
826 fn output_format_default_is_human() {
827 let format = OutputFormat::default();
828 assert!(matches!(format, OutputFormat::Human));
829 }
830
831 #[test]
832 fn fallow_config_deserialize_minimal() {
833 let toml_str = r#"
834entry = ["src/main.ts"]
835"#;
836 let config: FallowConfig = toml::from_str(toml_str).unwrap();
837 assert_eq!(config.entry, vec!["src/main.ts"]);
838 assert!(config.ignore_patterns.is_empty());
839 }
840
841 #[test]
842 fn fallow_config_deserialize_ignore_exports() {
843 let toml_str = r#"
844[[ignoreExports]]
845file = "src/types/*.ts"
846exports = ["*"]
847
848[[ignoreExports]]
849file = "src/constants.ts"
850exports = ["FOO", "BAR"]
851"#;
852 let config: FallowConfig = toml::from_str(toml_str).unwrap();
853 assert_eq!(config.ignore_exports.len(), 2);
854 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
855 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
856 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
857 }
858
859 #[test]
860 fn fallow_config_deserialize_ignore_dependencies() {
861 let toml_str = r#"
862ignoreDependencies = ["autoprefixer", "postcss"]
863"#;
864 let config: FallowConfig = toml::from_str(toml_str).unwrap();
865 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
866 }
867
868 #[test]
869 fn fallow_config_resolve_default_ignores() {
870 let config = FallowConfig {
871 schema: None,
872 extends: vec![],
873 entry: vec![],
874 ignore_patterns: vec![],
875 framework: vec![],
876 workspaces: None,
877 ignore_dependencies: vec![],
878 ignore_exports: vec![],
879 duplicates: DuplicatesConfig::default(),
880 rules: RulesConfig::default(),
881 production: false,
882 plugins: vec![],
883 overrides: vec![],
884 };
885 let resolved = config.resolve(PathBuf::from("/tmp/test"), OutputFormat::Human, 4, true);
886
887 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
889 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
890 assert!(resolved.ignore_patterns.is_match("build/output.js"));
891 assert!(resolved.ignore_patterns.is_match(".git/config"));
892 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
893 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
894 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
895 }
896
897 #[test]
898 fn fallow_config_resolve_custom_ignores() {
899 let config = FallowConfig {
900 schema: None,
901 extends: vec![],
902 entry: vec!["src/**/*.ts".to_string()],
903 ignore_patterns: vec!["**/*.generated.ts".to_string()],
904 framework: vec![],
905 workspaces: None,
906 ignore_dependencies: vec![],
907 ignore_exports: vec![],
908 duplicates: DuplicatesConfig::default(),
909 rules: RulesConfig::default(),
910 production: false,
911 plugins: vec![],
912 overrides: vec![],
913 };
914 let resolved = config.resolve(PathBuf::from("/tmp/test"), OutputFormat::Json, 4, false);
915
916 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
917 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
918 assert!(matches!(resolved.output, OutputFormat::Json));
919 assert!(!resolved.no_cache);
920 }
921
922 #[test]
923 fn fallow_config_resolve_cache_dir() {
924 let config = FallowConfig {
925 schema: None,
926 extends: vec![],
927 entry: vec![],
928 ignore_patterns: vec![],
929 framework: vec![],
930 workspaces: None,
931 ignore_dependencies: vec![],
932 ignore_exports: vec![],
933 duplicates: DuplicatesConfig::default(),
934 rules: RulesConfig::default(),
935 production: false,
936 plugins: vec![],
937 overrides: vec![],
938 };
939 let resolved = config.resolve(PathBuf::from("/tmp/project"), OutputFormat::Human, 4, true);
940 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
941 assert!(resolved.no_cache);
942 }
943
944 #[test]
945 fn package_json_entry_points_main() {
946 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
947 let entries = pkg.entry_points();
948 assert!(entries.contains(&"dist/index.js".to_string()));
949 }
950
951 #[test]
952 fn package_json_entry_points_module() {
953 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
954 let entries = pkg.entry_points();
955 assert!(entries.contains(&"dist/index.mjs".to_string()));
956 }
957
958 #[test]
959 fn package_json_entry_points_types() {
960 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
961 let entries = pkg.entry_points();
962 assert!(entries.contains(&"dist/index.d.ts".to_string()));
963 }
964
965 #[test]
966 fn package_json_entry_points_bin_string() {
967 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
968 let entries = pkg.entry_points();
969 assert!(entries.contains(&"bin/cli.js".to_string()));
970 }
971
972 #[test]
973 fn package_json_entry_points_bin_object() {
974 let pkg: PackageJson =
975 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
976 .unwrap();
977 let entries = pkg.entry_points();
978 assert!(entries.contains(&"bin/cli.js".to_string()));
979 assert!(entries.contains(&"bin/serve.js".to_string()));
980 }
981
982 #[test]
983 fn package_json_entry_points_exports_string() {
984 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
985 let entries = pkg.entry_points();
986 assert!(entries.contains(&"./dist/index.js".to_string()));
987 }
988
989 #[test]
990 fn package_json_entry_points_exports_object() {
991 let pkg: PackageJson = serde_json::from_str(
992 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
993 )
994 .unwrap();
995 let entries = pkg.entry_points();
996 assert!(entries.contains(&"./dist/index.mjs".to_string()));
997 assert!(entries.contains(&"./dist/index.cjs".to_string()));
998 }
999
1000 #[test]
1001 fn package_json_dependency_names() {
1002 let pkg: PackageJson = serde_json::from_str(
1003 r#"{
1004 "dependencies": {"react": "^18", "lodash": "^4"},
1005 "devDependencies": {"typescript": "^5"},
1006 "peerDependencies": {"react-dom": "^18"}
1007 }"#,
1008 )
1009 .unwrap();
1010
1011 let all = pkg.all_dependency_names();
1012 assert!(all.contains(&"react".to_string()));
1013 assert!(all.contains(&"lodash".to_string()));
1014 assert!(all.contains(&"typescript".to_string()));
1015 assert!(all.contains(&"react-dom".to_string()));
1016
1017 let prod = pkg.production_dependency_names();
1018 assert!(prod.contains(&"react".to_string()));
1019 assert!(!prod.contains(&"typescript".to_string()));
1020
1021 let dev = pkg.dev_dependency_names();
1022 assert!(dev.contains(&"typescript".to_string()));
1023 assert!(!dev.contains(&"react".to_string()));
1024 }
1025
1026 #[test]
1027 fn package_json_no_dependencies() {
1028 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1029 assert!(pkg.all_dependency_names().is_empty());
1030 assert!(pkg.production_dependency_names().is_empty());
1031 assert!(pkg.dev_dependency_names().is_empty());
1032 assert!(pkg.entry_points().is_empty());
1033 }
1034
1035 #[test]
1036 fn rules_default_all_error() {
1037 let rules = RulesConfig::default();
1038 assert_eq!(rules.unused_files, Severity::Error);
1039 assert_eq!(rules.unused_exports, Severity::Error);
1040 assert_eq!(rules.unused_types, Severity::Error);
1041 assert_eq!(rules.unused_dependencies, Severity::Error);
1042 assert_eq!(rules.unused_dev_dependencies, Severity::Error);
1043 assert_eq!(rules.unused_enum_members, Severity::Error);
1044 assert_eq!(rules.unused_class_members, Severity::Error);
1045 assert_eq!(rules.unresolved_imports, Severity::Error);
1046 assert_eq!(rules.unlisted_dependencies, Severity::Error);
1047 assert_eq!(rules.duplicate_exports, Severity::Error);
1048 }
1049
1050 #[test]
1051 fn rules_deserialize_kebab_case() {
1052 let json_str = r#"{
1053 "rules": {
1054 "unused-files": "error",
1055 "unused-exports": "warn",
1056 "unused-types": "off"
1057 }
1058 }"#;
1059 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1060 assert_eq!(config.rules.unused_files, Severity::Error);
1061 assert_eq!(config.rules.unused_exports, Severity::Warn);
1062 assert_eq!(config.rules.unused_types, Severity::Off);
1063 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1065 }
1066
1067 #[test]
1068 fn rules_deserialize_toml_kebab_case() {
1069 let toml_str = r#"
1070[rules]
1071unused-files = "error"
1072unused-exports = "warn"
1073unused-types = "off"
1074"#;
1075 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1076 assert_eq!(config.rules.unused_files, Severity::Error);
1077 assert_eq!(config.rules.unused_exports, Severity::Warn);
1078 assert_eq!(config.rules.unused_types, Severity::Off);
1079 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1081 }
1082
1083 #[test]
1084 fn severity_from_str() {
1085 assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
1086 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
1087 assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
1088 assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
1089 assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
1090 assert!("invalid".parse::<Severity>().is_err());
1091 }
1092
1093 #[test]
1094 fn config_without_rules_defaults_to_error() {
1095 let toml_str = r#"
1096entry = ["src/main.ts"]
1097"#;
1098 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1099 assert_eq!(config.rules.unused_files, Severity::Error);
1100 assert_eq!(config.rules.unused_exports, Severity::Error);
1101 }
1102
1103 #[test]
1104 fn fallow_config_denies_unknown_fields() {
1105 let toml_str = r#"
1106unknown_field = true
1107"#;
1108 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1109 assert!(result.is_err());
1110 }
1111
1112 #[test]
1113 fn fallow_config_deserialize_json() {
1114 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1115 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1116 assert_eq!(config.entry, vec!["src/main.ts"]);
1117 }
1118
1119 #[test]
1120 fn fallow_config_deserialize_jsonc() {
1121 let jsonc_str = r#"{
1122 // This is a comment
1123 "entry": ["src/main.ts"],
1124 "rules": {
1125 "unused-files": "warn"
1126 }
1127 }"#;
1128 let mut stripped = String::new();
1129 json_comments::StripComments::new(jsonc_str.as_bytes())
1130 .read_to_string(&mut stripped)
1131 .unwrap();
1132 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1133 assert_eq!(config.entry, vec!["src/main.ts"]);
1134 assert_eq!(config.rules.unused_files, Severity::Warn);
1135 }
1136
1137 #[test]
1138 fn fallow_config_json_with_schema_field() {
1139 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1140 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1141 assert_eq!(config.entry, vec!["src/main.ts"]);
1142 }
1143
1144 #[test]
1145 fn fallow_config_json_schema_generation() {
1146 let schema = FallowConfig::json_schema();
1147 assert!(schema.is_object());
1148 let obj = schema.as_object().unwrap();
1149 assert!(obj.contains_key("properties"));
1150 }
1151
1152 #[test]
1153 fn config_format_detection() {
1154 assert!(matches!(
1155 ConfigFormat::from_path(Path::new("fallow.toml")),
1156 ConfigFormat::Toml
1157 ));
1158 assert!(matches!(
1159 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1160 ConfigFormat::Json
1161 ));
1162 assert!(matches!(
1163 ConfigFormat::from_path(Path::new(".fallow.toml")),
1164 ConfigFormat::Toml
1165 ));
1166 }
1167
1168 #[test]
1169 fn config_names_priority_order() {
1170 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1171 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
1172 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
1173 }
1174
1175 #[test]
1176 fn load_json_config_file() {
1177 let dir = test_dir("json-config");
1178 let config_path = dir.join(".fallowrc.json");
1179 std::fs::write(
1180 &config_path,
1181 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1182 )
1183 .unwrap();
1184
1185 let config = FallowConfig::load(&config_path).unwrap();
1186 assert_eq!(config.entry, vec!["src/index.ts"]);
1187 assert_eq!(config.rules.unused_exports, Severity::Warn);
1188
1189 let _ = std::fs::remove_dir_all(&dir);
1190 }
1191
1192 #[test]
1193 fn load_jsonc_config_file() {
1194 let dir = test_dir("jsonc-config");
1195 let config_path = dir.join(".fallowrc.json");
1196 std::fs::write(
1197 &config_path,
1198 r#"{
1199 // Entry points for analysis
1200 "entry": ["src/index.ts"],
1201 /* Block comment */
1202 "rules": {
1203 "unused-exports": "warn"
1204 }
1205 }"#,
1206 )
1207 .unwrap();
1208
1209 let config = FallowConfig::load(&config_path).unwrap();
1210 assert_eq!(config.entry, vec!["src/index.ts"]);
1211 assert_eq!(config.rules.unused_exports, Severity::Warn);
1212
1213 let _ = std::fs::remove_dir_all(&dir);
1214 }
1215
1216 #[test]
1217 fn json_config_ignore_dependencies_camel_case() {
1218 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1219 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1220 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1221 }
1222
1223 #[test]
1224 fn json_config_all_fields() {
1225 let json_str = r#"{
1226 "ignoreDependencies": ["lodash"],
1227 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1228 "rules": {
1229 "unused-files": "off",
1230 "unused-exports": "warn",
1231 "unused-dependencies": "error",
1232 "unused-dev-dependencies": "off",
1233 "unused-types": "warn",
1234 "unused-enum-members": "error",
1235 "unused-class-members": "off",
1236 "unresolved-imports": "warn",
1237 "unlisted-dependencies": "error",
1238 "duplicate-exports": "off"
1239 },
1240 "duplicates": {
1241 "minTokens": 100,
1242 "minLines": 10,
1243 "skipLocal": true
1244 }
1245 }"#;
1246 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1247 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1248 assert_eq!(config.rules.unused_files, Severity::Off);
1249 assert_eq!(config.rules.unused_exports, Severity::Warn);
1250 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1251 assert_eq!(config.duplicates.min_tokens, 100);
1252 assert_eq!(config.duplicates.min_lines, 10);
1253 assert!(config.duplicates.skip_local);
1254 }
1255
1256 #[test]
1259 fn extends_single_base() {
1260 let dir = test_dir("extends-single");
1261
1262 std::fs::write(
1263 dir.join("base.json"),
1264 r#"{"rules": {"unused-files": "warn"}}"#,
1265 )
1266 .unwrap();
1267 std::fs::write(
1268 dir.join(".fallowrc.json"),
1269 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1270 )
1271 .unwrap();
1272
1273 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1274 assert_eq!(config.rules.unused_files, Severity::Warn);
1275 assert_eq!(config.entry, vec!["src/index.ts"]);
1276 assert_eq!(config.rules.unused_exports, Severity::Error);
1278
1279 let _ = std::fs::remove_dir_all(&dir);
1280 }
1281
1282 #[test]
1283 fn extends_overlay_overrides_base() {
1284 let dir = test_dir("extends-overlay");
1285
1286 std::fs::write(
1287 dir.join("base.json"),
1288 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1289 )
1290 .unwrap();
1291 std::fs::write(
1292 dir.join(".fallowrc.json"),
1293 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1294 )
1295 .unwrap();
1296
1297 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1298 assert_eq!(config.rules.unused_files, Severity::Error);
1300 assert_eq!(config.rules.unused_exports, Severity::Off);
1302
1303 let _ = std::fs::remove_dir_all(&dir);
1304 }
1305
1306 #[test]
1307 fn extends_chained() {
1308 let dir = test_dir("extends-chained");
1309
1310 std::fs::write(
1311 dir.join("grandparent.json"),
1312 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1313 )
1314 .unwrap();
1315 std::fs::write(
1316 dir.join("parent.json"),
1317 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1318 )
1319 .unwrap();
1320 std::fs::write(
1321 dir.join(".fallowrc.json"),
1322 r#"{"extends": ["parent.json"]}"#,
1323 )
1324 .unwrap();
1325
1326 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1327 assert_eq!(config.rules.unused_files, Severity::Warn);
1329 assert_eq!(config.rules.unused_exports, Severity::Warn);
1331
1332 let _ = std::fs::remove_dir_all(&dir);
1333 }
1334
1335 #[test]
1336 fn extends_circular_detected() {
1337 let dir = test_dir("extends-circular");
1338
1339 std::fs::write(dir.join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1340 std::fs::write(dir.join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1341
1342 let result = FallowConfig::load(&dir.join("a.json"));
1343 assert!(result.is_err());
1344 let err_msg = format!("{}", result.unwrap_err());
1345 assert!(
1346 err_msg.contains("Circular extends"),
1347 "Expected circular error, got: {err_msg}"
1348 );
1349
1350 let _ = std::fs::remove_dir_all(&dir);
1351 }
1352
1353 #[test]
1354 fn extends_missing_file_errors() {
1355 let dir = test_dir("extends-missing");
1356
1357 std::fs::write(
1358 dir.join(".fallowrc.json"),
1359 r#"{"extends": ["nonexistent.json"]}"#,
1360 )
1361 .unwrap();
1362
1363 let result = FallowConfig::load(&dir.join(".fallowrc.json"));
1364 assert!(result.is_err());
1365 let err_msg = format!("{}", result.unwrap_err());
1366 assert!(
1367 err_msg.contains("not found"),
1368 "Expected not found error, got: {err_msg}"
1369 );
1370
1371 let _ = std::fs::remove_dir_all(&dir);
1372 }
1373
1374 #[test]
1375 fn extends_string_sugar() {
1376 let dir = test_dir("extends-string");
1377
1378 std::fs::write(dir.join("base.json"), r#"{"ignorePatterns": ["gen/**"]}"#).unwrap();
1379 std::fs::write(dir.join(".fallowrc.json"), r#"{"extends": "base.json"}"#).unwrap();
1381
1382 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1383 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1384
1385 let _ = std::fs::remove_dir_all(&dir);
1386 }
1387
1388 #[test]
1389 fn extends_deep_merge_preserves_arrays() {
1390 let dir = test_dir("extends-array");
1391
1392 std::fs::write(dir.join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1393 std::fs::write(
1394 dir.join(".fallowrc.json"),
1395 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1396 )
1397 .unwrap();
1398
1399 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1400 assert_eq!(config.entry, vec!["src/b.ts"]);
1402
1403 let _ = std::fs::remove_dir_all(&dir);
1404 }
1405
1406 #[test]
1409 fn overrides_deserialize() {
1410 let json_str = r#"{
1411 "overrides": [{
1412 "files": ["*.test.ts"],
1413 "rules": {
1414 "unused-exports": "off"
1415 }
1416 }]
1417 }"#;
1418 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1419 assert_eq!(config.overrides.len(), 1);
1420 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1421 assert_eq!(
1422 config.overrides[0].rules.unused_exports,
1423 Some(Severity::Off)
1424 );
1425 assert_eq!(config.overrides[0].rules.unused_files, None);
1426 }
1427
1428 #[test]
1429 fn apply_partial_only_some_fields() {
1430 let mut rules = RulesConfig::default();
1431 let partial = PartialRulesConfig {
1432 unused_files: Some(Severity::Warn),
1433 unused_exports: Some(Severity::Off),
1434 ..Default::default()
1435 };
1436 rules.apply_partial(&partial);
1437 assert_eq!(rules.unused_files, Severity::Warn);
1438 assert_eq!(rules.unused_exports, Severity::Off);
1439 assert_eq!(rules.unused_types, Severity::Error);
1441 assert_eq!(rules.unresolved_imports, Severity::Error);
1442 }
1443
1444 #[test]
1445 fn resolve_rules_for_path_no_overrides() {
1446 let config = FallowConfig {
1447 schema: None,
1448 extends: vec![],
1449 entry: vec![],
1450 ignore_patterns: vec![],
1451 framework: vec![],
1452 workspaces: None,
1453 ignore_dependencies: vec![],
1454 ignore_exports: vec![],
1455 duplicates: DuplicatesConfig::default(),
1456 rules: RulesConfig::default(),
1457 production: false,
1458 plugins: vec![],
1459 overrides: vec![],
1460 };
1461 let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1462 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1463 assert_eq!(rules.unused_files, Severity::Error);
1464 }
1465
1466 #[test]
1467 fn resolve_rules_for_path_with_matching_override() {
1468 let config = FallowConfig {
1469 schema: None,
1470 extends: vec![],
1471 entry: vec![],
1472 ignore_patterns: vec![],
1473 framework: vec![],
1474 workspaces: None,
1475 ignore_dependencies: vec![],
1476 ignore_exports: vec![],
1477 duplicates: DuplicatesConfig::default(),
1478 rules: RulesConfig::default(),
1479 production: false,
1480 plugins: vec![],
1481 overrides: vec![ConfigOverride {
1482 files: vec!["*.test.ts".to_string()],
1483 rules: PartialRulesConfig {
1484 unused_exports: Some(Severity::Off),
1485 ..Default::default()
1486 },
1487 }],
1488 };
1489 let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1490
1491 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1493 assert_eq!(test_rules.unused_exports, Severity::Off);
1494 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1498 assert_eq!(src_rules.unused_exports, Severity::Error);
1499 }
1500
1501 #[test]
1502 fn resolve_rules_for_path_later_override_wins() {
1503 let config = FallowConfig {
1504 schema: None,
1505 extends: vec![],
1506 entry: vec![],
1507 ignore_patterns: vec![],
1508 framework: vec![],
1509 workspaces: None,
1510 ignore_dependencies: vec![],
1511 ignore_exports: vec![],
1512 duplicates: DuplicatesConfig::default(),
1513 rules: RulesConfig::default(),
1514 production: false,
1515 plugins: vec![],
1516 overrides: vec![
1517 ConfigOverride {
1518 files: vec!["*.ts".to_string()],
1519 rules: PartialRulesConfig {
1520 unused_files: Some(Severity::Warn),
1521 ..Default::default()
1522 },
1523 },
1524 ConfigOverride {
1525 files: vec!["*.test.ts".to_string()],
1526 rules: PartialRulesConfig {
1527 unused_files: Some(Severity::Off),
1528 ..Default::default()
1529 },
1530 },
1531 ],
1532 };
1533 let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1534
1535 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1537 assert_eq!(rules.unused_files, Severity::Off);
1538
1539 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1541 assert_eq!(rules2.unused_files, Severity::Warn);
1542 }
1543}