1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::framework::FrameworkPreset;
9use crate::workspace::WorkspaceConfig;
10
11const CONFIG_NAMES: &[&str] = &["fallow.jsonc", "fallow.json", "fallow.toml", ".fallow.toml"];
16
17#[derive(Debug, Deserialize, Serialize, JsonSchema)]
19#[serde(deny_unknown_fields)]
20pub struct FallowConfig {
21 #[serde(rename = "$schema", default, skip_serializing)]
23 #[schemars(skip)]
24 pub schema: Option<String>,
25
26 #[serde(default)]
28 pub entry: Vec<String>,
29
30 #[serde(default)]
32 pub ignore: Vec<String>,
33
34 #[serde(default)]
36 pub detect: DetectConfig,
37
38 #[serde(default)]
40 pub framework: Vec<FrameworkPreset>,
41
42 #[serde(default)]
44 pub workspaces: Option<WorkspaceConfig>,
45
46 #[serde(default)]
48 pub ignore_dependencies: Vec<String>,
49
50 #[serde(default)]
52 pub ignore_exports: Vec<IgnoreExportRule>,
53
54 #[serde(default)]
56 pub output: OutputFormat,
57
58 #[serde(default)]
60 pub duplicates: DuplicatesConfig,
61
62 #[serde(default)]
64 pub rules: RulesConfig,
65
66 #[serde(default)]
68 pub production: bool,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73pub struct DuplicatesConfig {
74 #[serde(default = "default_true")]
76 pub enabled: bool,
77
78 #[serde(default)]
80 pub mode: DetectionMode,
81
82 #[serde(default = "default_min_tokens")]
84 pub min_tokens: usize,
85
86 #[serde(default = "default_min_lines")]
88 pub min_lines: usize,
89
90 #[serde(default)]
92 pub threshold: f64,
93
94 #[serde(default)]
96 pub ignore: Vec<String>,
97
98 #[serde(default)]
100 pub skip_local: bool,
101}
102
103impl Default for DuplicatesConfig {
104 fn default() -> Self {
105 Self {
106 enabled: true,
107 mode: DetectionMode::default(),
108 min_tokens: default_min_tokens(),
109 min_lines: default_min_lines(),
110 threshold: 0.0,
111 ignore: vec![],
112 skip_local: false,
113 }
114 }
115}
116
117#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
125#[serde(rename_all = "lowercase")]
126pub enum DetectionMode {
127 Strict,
129 #[default]
131 Mild,
132 Weak,
134 Semantic,
136}
137
138impl std::fmt::Display for DetectionMode {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 Self::Strict => write!(f, "strict"),
142 Self::Mild => write!(f, "mild"),
143 Self::Weak => write!(f, "weak"),
144 Self::Semantic => write!(f, "semantic"),
145 }
146 }
147}
148
149impl std::str::FromStr for DetectionMode {
150 type Err = String;
151
152 fn from_str(s: &str) -> Result<Self, Self::Err> {
153 match s.to_lowercase().as_str() {
154 "strict" => Ok(Self::Strict),
155 "mild" => Ok(Self::Mild),
156 "weak" => Ok(Self::Weak),
157 "semantic" => Ok(Self::Semantic),
158 other => Err(format!("unknown detection mode: '{other}'")),
159 }
160 }
161}
162
163const fn default_min_tokens() -> usize {
164 50
165}
166
167const fn default_min_lines() -> usize {
168 5
169}
170
171#[derive(Debug, Deserialize, Serialize, JsonSchema)]
173pub struct DetectConfig {
174 #[serde(default = "default_true")]
176 pub unused_files: bool,
177
178 #[serde(default = "default_true")]
180 pub unused_exports: bool,
181
182 #[serde(default = "default_true")]
184 pub unused_dependencies: bool,
185
186 #[serde(default = "default_true")]
188 pub unused_dev_dependencies: bool,
189
190 #[serde(default = "default_true")]
192 pub unused_types: bool,
193
194 #[serde(default = "default_true")]
196 pub unused_enum_members: bool,
197
198 #[serde(default = "default_true")]
200 pub unused_class_members: bool,
201
202 #[serde(default = "default_true")]
204 pub unresolved_imports: bool,
205
206 #[serde(default = "default_true")]
208 pub unlisted_dependencies: bool,
209
210 #[serde(default = "default_true")]
212 pub duplicate_exports: bool,
213}
214
215impl Default for DetectConfig {
216 fn default() -> Self {
217 Self {
218 unused_files: true,
219 unused_exports: true,
220 unused_dependencies: true,
221 unused_dev_dependencies: true,
222 unused_types: true,
223 unused_enum_members: true,
224 unused_class_members: true,
225 unresolved_imports: true,
226 unlisted_dependencies: true,
227 duplicate_exports: true,
228 }
229 }
230}
231
232#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
234#[serde(rename_all = "lowercase")]
235pub enum OutputFormat {
236 #[default]
238 Human,
239 Json,
241 Sarif,
243 Compact,
245}
246
247#[derive(Debug, Deserialize, Serialize, JsonSchema)]
249pub struct IgnoreExportRule {
250 pub file: String,
252 pub exports: Vec<String>,
254}
255
256#[derive(Debug)]
258pub struct ResolvedConfig {
259 pub root: PathBuf,
260 pub entry_patterns: Vec<String>,
261 pub ignore_patterns: GlobSet,
262 pub detect: DetectConfig,
263 pub framework_rules: Vec<crate::framework::FrameworkRule>,
264 pub output: OutputFormat,
265 pub cache_dir: PathBuf,
266 pub threads: usize,
267 pub no_cache: bool,
268 pub ignore_dependencies: Vec<String>,
269 pub ignore_export_rules: Vec<IgnoreExportRule>,
270 pub duplicates: DuplicatesConfig,
271 pub rules: RulesConfig,
272 pub production: bool,
274}
275
276enum ConfigFormat {
278 Toml,
279 Json,
280 Jsonc,
281}
282
283impl ConfigFormat {
284 fn from_path(path: &Path) -> Self {
285 match path.extension().and_then(|e| e.to_str()) {
286 Some("toml") => Self::Toml,
287 Some("jsonc") => Self::Jsonc,
288 Some("json") => Self::Json,
289 _ => Self::Toml,
290 }
291 }
292}
293
294impl FallowConfig {
295 pub fn load(path: &Path) -> Result<Self, miette::Report> {
302 let content = std::fs::read_to_string(path)
303 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
304
305 match ConfigFormat::from_path(path) {
306 ConfigFormat::Toml => toml::from_str(&content).map_err(|e| {
307 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
308 }),
309 ConfigFormat::Json => serde_json::from_str(&content).map_err(|e| {
310 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
311 }),
312 ConfigFormat::Jsonc => {
313 let mut stripped = String::new();
314 json_comments::StripComments::new(content.as_bytes())
315 .read_to_string(&mut stripped)
316 .map_err(|e| {
317 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
318 })?;
319 serde_json::from_str(&stripped).map_err(|e| {
320 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
321 })
322 }
323 }
324 }
325
326 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
337 let mut dir = start;
338 loop {
339 for name in CONFIG_NAMES {
340 let candidate = dir.join(name);
341 if candidate.exists() {
342 match Self::load(&candidate) {
343 Ok(config) => return Ok(Some((config, candidate))),
344 Err(e) => {
345 return Err(format!("Failed to parse {}: {e}", candidate.display()));
346 }
347 }
348 }
349 }
350 if dir.join(".git").exists() || dir.join("package.json").exists() {
352 break;
353 }
354 dir = match dir.parent() {
355 Some(parent) => parent,
356 None => break,
357 };
358 }
359 Ok(None)
360 }
361
362 pub fn json_schema() -> serde_json::Value {
364 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
365 }
366
367 pub fn resolve(self, root: PathBuf, threads: usize, no_cache: bool) -> ResolvedConfig {
369 let mut ignore_builder = GlobSetBuilder::new();
370 for pattern in &self.ignore {
371 match Glob::new(pattern) {
372 Ok(glob) => {
373 ignore_builder.add(glob);
374 }
375 Err(e) => {
376 eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
377 }
378 }
379 }
380
381 let default_ignores = [
383 "**/node_modules/**",
384 "**/dist/**",
385 "**/build/**",
386 "**/.git/**",
387 "**/coverage/**",
388 "**/*.min.js",
389 "**/*.min.mjs",
390 ];
391 for pattern in &default_ignores {
392 if let Ok(glob) = Glob::new(pattern) {
393 ignore_builder.add(glob);
394 }
395 }
396
397 let ignore_patterns = ignore_builder.build().unwrap_or_default();
398 let cache_dir = root.join(".fallow");
399
400 let framework_rules = crate::framework::resolve_framework_rules(&self.framework);
401
402 let mut rules = self.rules;
404 rules.merge_detect(&self.detect);
405
406 let production = self.production;
408 if production {
409 rules.unused_dev_dependencies = Severity::Off;
410 }
411
412 ResolvedConfig {
413 root,
414 entry_patterns: self.entry,
415 ignore_patterns,
416 detect: self.detect,
417 framework_rules,
418 output: self.output,
419 cache_dir,
420 threads,
421 no_cache,
422 ignore_dependencies: self.ignore_dependencies,
423 ignore_export_rules: self.ignore_exports,
424 duplicates: self.duplicates,
425 rules,
426 production,
427 }
428 }
429}
430
431const fn default_true() -> bool {
432 true
433}
434
435#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
440#[serde(rename_all = "lowercase")]
441pub enum Severity {
442 #[default]
444 Error,
445 Warn,
447 Off,
449}
450
451impl std::fmt::Display for Severity {
452 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453 match self {
454 Self::Error => write!(f, "error"),
455 Self::Warn => write!(f, "warn"),
456 Self::Off => write!(f, "off"),
457 }
458 }
459}
460
461impl std::str::FromStr for Severity {
462 type Err = String;
463
464 fn from_str(s: &str) -> Result<Self, Self::Err> {
465 match s.to_lowercase().as_str() {
466 "error" => Ok(Self::Error),
467 "warn" | "warning" => Ok(Self::Warn),
468 "off" | "none" => Ok(Self::Off),
469 other => Err(format!(
470 "unknown severity: '{other}' (expected error, warn, or off)"
471 )),
472 }
473 }
474}
475
476#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
482pub struct RulesConfig {
483 #[serde(default)]
484 pub unused_files: Severity,
485 #[serde(default)]
486 pub unused_exports: Severity,
487 #[serde(default)]
488 pub unused_types: Severity,
489 #[serde(default)]
490 pub unused_dependencies: Severity,
491 #[serde(default)]
492 pub unused_dev_dependencies: Severity,
493 #[serde(default)]
494 pub unused_enum_members: Severity,
495 #[serde(default)]
496 pub unused_class_members: Severity,
497 #[serde(default)]
498 pub unresolved_imports: Severity,
499 #[serde(default)]
500 pub unlisted_dependencies: Severity,
501 #[serde(default)]
502 pub duplicate_exports: Severity,
503}
504
505impl Default for RulesConfig {
506 fn default() -> Self {
507 Self {
508 unused_files: Severity::Error,
509 unused_exports: Severity::Error,
510 unused_types: Severity::Error,
511 unused_dependencies: Severity::Error,
512 unused_dev_dependencies: Severity::Error,
513 unused_enum_members: Severity::Error,
514 unused_class_members: Severity::Error,
515 unresolved_imports: Severity::Error,
516 unlisted_dependencies: Severity::Error,
517 duplicate_exports: Severity::Error,
518 }
519 }
520}
521
522impl RulesConfig {
523 pub fn merge_detect(&mut self, detect: &DetectConfig) {
525 if !detect.unused_files {
526 self.unused_files = Severity::Off;
527 }
528 if !detect.unused_exports {
529 self.unused_exports = Severity::Off;
530 }
531 if !detect.unused_types {
532 self.unused_types = Severity::Off;
533 }
534 if !detect.unused_dependencies {
535 self.unused_dependencies = Severity::Off;
536 }
537 if !detect.unused_dev_dependencies {
538 self.unused_dev_dependencies = Severity::Off;
539 }
540 if !detect.unused_enum_members {
541 self.unused_enum_members = Severity::Off;
542 }
543 if !detect.unused_class_members {
544 self.unused_class_members = Severity::Off;
545 }
546 if !detect.unresolved_imports {
547 self.unresolved_imports = Severity::Off;
548 }
549 if !detect.unlisted_dependencies {
550 self.unlisted_dependencies = Severity::Off;
551 }
552 if !detect.duplicate_exports {
553 self.duplicate_exports = Severity::Off;
554 }
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::PackageJson;
562
563 #[test]
564 fn detect_config_default_all_true() {
565 let config = DetectConfig::default();
566 assert!(config.unused_files);
567 assert!(config.unused_exports);
568 assert!(config.unused_dependencies);
569 assert!(config.unused_dev_dependencies);
570 assert!(config.unused_types);
571 assert!(config.unused_enum_members);
572 assert!(config.unused_class_members);
573 assert!(config.unresolved_imports);
574 assert!(config.unlisted_dependencies);
575 assert!(config.duplicate_exports);
576 }
577
578 #[test]
579 fn output_format_default_is_human() {
580 let format = OutputFormat::default();
581 assert!(matches!(format, OutputFormat::Human));
582 }
583
584 #[test]
585 fn fallow_config_deserialize_minimal() {
586 let toml_str = r#"
587entry = ["src/main.ts"]
588"#;
589 let config: FallowConfig = toml::from_str(toml_str).unwrap();
590 assert_eq!(config.entry, vec!["src/main.ts"]);
591 assert!(config.ignore.is_empty());
592 assert!(config.detect.unused_files); }
594
595 #[test]
596 fn fallow_config_deserialize_detect_overrides() {
597 let toml_str = r#"
598[detect]
599unused_files = false
600unused_exports = true
601unused_dependencies = false
602"#;
603 let config: FallowConfig = toml::from_str(toml_str).unwrap();
604 assert!(!config.detect.unused_files);
605 assert!(config.detect.unused_exports);
606 assert!(!config.detect.unused_dependencies);
607 assert!(config.detect.unused_types);
609 }
610
611 #[test]
612 fn fallow_config_deserialize_ignore_exports() {
613 let toml_str = r#"
614[[ignore_exports]]
615file = "src/types/*.ts"
616exports = ["*"]
617
618[[ignore_exports]]
619file = "src/constants.ts"
620exports = ["FOO", "BAR"]
621"#;
622 let config: FallowConfig = toml::from_str(toml_str).unwrap();
623 assert_eq!(config.ignore_exports.len(), 2);
624 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
625 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
626 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
627 }
628
629 #[test]
630 fn fallow_config_deserialize_ignore_dependencies() {
631 let toml_str = r#"
632ignore_dependencies = ["autoprefixer", "postcss"]
633"#;
634 let config: FallowConfig = toml::from_str(toml_str).unwrap();
635 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
636 }
637
638 #[test]
639 fn fallow_config_resolve_default_ignores() {
640 let config = FallowConfig {
641 schema: None,
642 entry: vec![],
643 ignore: vec![],
644 detect: DetectConfig::default(),
645 framework: vec![],
646 workspaces: None,
647 ignore_dependencies: vec![],
648 ignore_exports: vec![],
649 output: OutputFormat::Human,
650 duplicates: DuplicatesConfig::default(),
651 rules: RulesConfig::default(),
652 production: false,
653 };
654 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
655
656 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
658 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
659 assert!(resolved.ignore_patterns.is_match("build/output.js"));
660 assert!(resolved.ignore_patterns.is_match(".git/config"));
661 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
662 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
663 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
664 }
665
666 #[test]
667 fn fallow_config_resolve_custom_ignores() {
668 let config = FallowConfig {
669 schema: None,
670 entry: vec!["src/**/*.ts".to_string()],
671 ignore: vec!["**/*.generated.ts".to_string()],
672 detect: DetectConfig::default(),
673 framework: vec![],
674 workspaces: None,
675 ignore_dependencies: vec![],
676 ignore_exports: vec![],
677 output: OutputFormat::Json,
678 duplicates: DuplicatesConfig::default(),
679 rules: RulesConfig::default(),
680 production: false,
681 };
682 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, false);
683
684 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
685 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
686 assert!(matches!(resolved.output, OutputFormat::Json));
687 assert!(!resolved.no_cache);
688 }
689
690 #[test]
691 fn fallow_config_resolve_cache_dir() {
692 let config = FallowConfig {
693 schema: None,
694 entry: vec![],
695 ignore: vec![],
696 detect: DetectConfig::default(),
697 framework: vec![],
698 workspaces: None,
699 ignore_dependencies: vec![],
700 ignore_exports: vec![],
701 output: OutputFormat::Human,
702 duplicates: DuplicatesConfig::default(),
703 rules: RulesConfig::default(),
704 production: false,
705 };
706 let resolved = config.resolve(PathBuf::from("/tmp/project"), 4, true);
707 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
708 assert!(resolved.no_cache);
709 }
710
711 #[test]
712 fn package_json_entry_points_main() {
713 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
714 let entries = pkg.entry_points();
715 assert!(entries.contains(&"dist/index.js".to_string()));
716 }
717
718 #[test]
719 fn package_json_entry_points_module() {
720 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
721 let entries = pkg.entry_points();
722 assert!(entries.contains(&"dist/index.mjs".to_string()));
723 }
724
725 #[test]
726 fn package_json_entry_points_types() {
727 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
728 let entries = pkg.entry_points();
729 assert!(entries.contains(&"dist/index.d.ts".to_string()));
730 }
731
732 #[test]
733 fn package_json_entry_points_bin_string() {
734 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
735 let entries = pkg.entry_points();
736 assert!(entries.contains(&"bin/cli.js".to_string()));
737 }
738
739 #[test]
740 fn package_json_entry_points_bin_object() {
741 let pkg: PackageJson =
742 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
743 .unwrap();
744 let entries = pkg.entry_points();
745 assert!(entries.contains(&"bin/cli.js".to_string()));
746 assert!(entries.contains(&"bin/serve.js".to_string()));
747 }
748
749 #[test]
750 fn package_json_entry_points_exports_string() {
751 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
752 let entries = pkg.entry_points();
753 assert!(entries.contains(&"./dist/index.js".to_string()));
754 }
755
756 #[test]
757 fn package_json_entry_points_exports_object() {
758 let pkg: PackageJson = serde_json::from_str(
759 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
760 )
761 .unwrap();
762 let entries = pkg.entry_points();
763 assert!(entries.contains(&"./dist/index.mjs".to_string()));
764 assert!(entries.contains(&"./dist/index.cjs".to_string()));
765 }
766
767 #[test]
768 fn package_json_dependency_names() {
769 let pkg: PackageJson = serde_json::from_str(
770 r#"{
771 "dependencies": {"react": "^18", "lodash": "^4"},
772 "devDependencies": {"typescript": "^5"},
773 "peerDependencies": {"react-dom": "^18"}
774 }"#,
775 )
776 .unwrap();
777
778 let all = pkg.all_dependency_names();
779 assert!(all.contains(&"react".to_string()));
780 assert!(all.contains(&"lodash".to_string()));
781 assert!(all.contains(&"typescript".to_string()));
782 assert!(all.contains(&"react-dom".to_string()));
783
784 let prod = pkg.production_dependency_names();
785 assert!(prod.contains(&"react".to_string()));
786 assert!(!prod.contains(&"typescript".to_string()));
787
788 let dev = pkg.dev_dependency_names();
789 assert!(dev.contains(&"typescript".to_string()));
790 assert!(!dev.contains(&"react".to_string()));
791 }
792
793 #[test]
794 fn package_json_no_dependencies() {
795 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
796 assert!(pkg.all_dependency_names().is_empty());
797 assert!(pkg.production_dependency_names().is_empty());
798 assert!(pkg.dev_dependency_names().is_empty());
799 assert!(pkg.entry_points().is_empty());
800 }
801
802 #[test]
803 fn rules_default_all_error() {
804 let rules = RulesConfig::default();
805 assert_eq!(rules.unused_files, Severity::Error);
806 assert_eq!(rules.unused_exports, Severity::Error);
807 assert_eq!(rules.unused_types, Severity::Error);
808 assert_eq!(rules.unused_dependencies, Severity::Error);
809 assert_eq!(rules.unused_dev_dependencies, Severity::Error);
810 assert_eq!(rules.unused_enum_members, Severity::Error);
811 assert_eq!(rules.unused_class_members, Severity::Error);
812 assert_eq!(rules.unresolved_imports, Severity::Error);
813 assert_eq!(rules.unlisted_dependencies, Severity::Error);
814 assert_eq!(rules.duplicate_exports, Severity::Error);
815 }
816
817 #[test]
818 fn rules_deserialize_mixed_severities() {
819 let toml_str = r#"
820[rules]
821unused_files = "error"
822unused_exports = "warn"
823unused_types = "off"
824"#;
825 let config: FallowConfig = toml::from_str(toml_str).unwrap();
826 assert_eq!(config.rules.unused_files, Severity::Error);
827 assert_eq!(config.rules.unused_exports, Severity::Warn);
828 assert_eq!(config.rules.unused_types, Severity::Off);
829 assert_eq!(config.rules.unresolved_imports, Severity::Error);
831 }
832
833 #[test]
834 fn detect_false_forces_severity_off() {
835 let toml_str = r#"
836[detect]
837unused_files = false
838
839[rules]
840unused_files = "error"
841"#;
842 let config: FallowConfig = toml::from_str(toml_str).unwrap();
843 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
844 assert_eq!(resolved.rules.unused_files, Severity::Off);
846 }
847
848 #[test]
849 fn rules_off_independent_of_detect() {
850 let toml_str = r#"
851[detect]
852unused_files = true
853
854[rules]
855unused_files = "off"
856"#;
857 let config: FallowConfig = toml::from_str(toml_str).unwrap();
858 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
859 assert_eq!(resolved.rules.unused_files, Severity::Off);
861 }
862
863 #[test]
864 fn severity_from_str() {
865 assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
866 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
867 assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
868 assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
869 assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
870 assert!("invalid".parse::<Severity>().is_err());
871 }
872
873 #[test]
874 fn config_without_rules_defaults_to_error() {
875 let toml_str = r#"
876entry = ["src/main.ts"]
877"#;
878 let config: FallowConfig = toml::from_str(toml_str).unwrap();
879 assert_eq!(config.rules.unused_files, Severity::Error);
880 assert_eq!(config.rules.unused_exports, Severity::Error);
881 }
882
883 #[test]
884 fn fallow_config_denies_unknown_fields() {
885 let toml_str = r#"
886unknown_field = true
887"#;
888 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
889 assert!(result.is_err());
890 }
891
892 #[test]
893 fn fallow_config_deserialize_json() {
894 let json_str = r#"{"entry": ["src/main.ts"], "detect": {"unused_files": false}}"#;
895 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
896 assert_eq!(config.entry, vec!["src/main.ts"]);
897 assert!(!config.detect.unused_files);
898 assert!(config.detect.unused_exports); }
900
901 #[test]
902 fn fallow_config_deserialize_jsonc() {
903 let jsonc_str = r#"{
904 // This is a comment
905 "entry": ["src/main.ts"],
906 "rules": {
907 "unused_files": "warn"
908 }
909 }"#;
910 let mut stripped = String::new();
911 json_comments::StripComments::new(jsonc_str.as_bytes())
912 .read_to_string(&mut stripped)
913 .unwrap();
914 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
915 assert_eq!(config.entry, vec!["src/main.ts"]);
916 assert_eq!(config.rules.unused_files, Severity::Warn);
917 }
918
919 #[test]
920 fn fallow_config_json_with_schema_field() {
921 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
922 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
923 assert_eq!(config.entry, vec!["src/main.ts"]);
924 }
925
926 #[test]
927 fn fallow_config_json_schema_generation() {
928 let schema = FallowConfig::json_schema();
929 assert!(schema.is_object());
930 let obj = schema.as_object().unwrap();
931 assert!(obj.contains_key("properties"));
932 }
933
934 #[test]
935 fn config_format_detection() {
936 assert!(matches!(
937 ConfigFormat::from_path(Path::new("fallow.toml")),
938 ConfigFormat::Toml
939 ));
940 assert!(matches!(
941 ConfigFormat::from_path(Path::new("fallow.json")),
942 ConfigFormat::Json
943 ));
944 assert!(matches!(
945 ConfigFormat::from_path(Path::new("fallow.jsonc")),
946 ConfigFormat::Jsonc
947 ));
948 assert!(matches!(
949 ConfigFormat::from_path(Path::new(".fallow.toml")),
950 ConfigFormat::Toml
951 ));
952 }
953
954 #[test]
955 fn config_names_priority_order() {
956 assert_eq!(CONFIG_NAMES[0], "fallow.jsonc");
957 assert_eq!(CONFIG_NAMES[1], "fallow.json");
958 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
959 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
960 }
961
962 #[test]
963 fn load_json_config_file() {
964 let dir = std::env::temp_dir().join("fallow-test-json-config");
965 let _ = std::fs::create_dir_all(&dir);
966 let config_path = dir.join("fallow.json");
967 std::fs::write(
968 &config_path,
969 r#"{"entry": ["src/index.ts"], "rules": {"unused_exports": "warn"}}"#,
970 )
971 .unwrap();
972
973 let config = FallowConfig::load(&config_path).unwrap();
974 assert_eq!(config.entry, vec!["src/index.ts"]);
975 assert_eq!(config.rules.unused_exports, Severity::Warn);
976
977 let _ = std::fs::remove_dir_all(&dir);
978 }
979
980 #[test]
981 fn load_jsonc_config_file() {
982 let dir = std::env::temp_dir().join("fallow-test-jsonc-config");
983 let _ = std::fs::create_dir_all(&dir);
984 let config_path = dir.join("fallow.jsonc");
985 std::fs::write(
986 &config_path,
987 r#"{
988 // Entry points for analysis
989 "entry": ["src/index.ts"],
990 /* Block comment */
991 "rules": {
992 "unused_exports": "warn"
993 }
994 }"#,
995 )
996 .unwrap();
997
998 let config = FallowConfig::load(&config_path).unwrap();
999 assert_eq!(config.entry, vec!["src/index.ts"]);
1000 assert_eq!(config.rules.unused_exports, Severity::Warn);
1001
1002 let _ = std::fs::remove_dir_all(&dir);
1003 }
1004}