1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::FxHashSet;
5
6use super::FallowConfig;
7
8pub(super) const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
13
14pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
15
16pub(super) enum ConfigFormat {
18 Toml,
19 Json,
20}
21
22impl ConfigFormat {
23 pub(super) fn from_path(path: &Path) -> Self {
24 match path.extension().and_then(|e| e.to_str()) {
25 Some("json") => Self::Json,
26 _ => Self::Toml,
27 }
28 }
29}
30
31pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
34 match (base, overlay) {
35 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
36 for (key, value) in overlay_map {
37 if let Some(base_value) = base_map.get_mut(&key) {
38 deep_merge_json(base_value, value);
39 } else {
40 base_map.insert(key, value);
41 }
42 }
43 }
44 (base, overlay) => {
45 *base = overlay;
46 }
47 }
48}
49
50pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
51 let content = std::fs::read_to_string(path)
52 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
53
54 match ConfigFormat::from_path(path) {
55 ConfigFormat::Toml => {
56 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
57 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
58 })?;
59 serde_json::to_value(toml_value).map_err(|e| {
60 miette::miette!(
61 "Failed to convert TOML to JSON for {}: {}",
62 path.display(),
63 e
64 )
65 })
66 }
67 ConfigFormat::Json => {
68 let mut stripped = String::new();
69 json_comments::StripComments::new(content.as_bytes())
70 .read_to_string(&mut stripped)
71 .map_err(|e| {
72 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
73 })?;
74 serde_json::from_str(&stripped).map_err(|e| {
75 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
76 })
77 }
78 }
79}
80
81pub(super) fn resolve_extends(
82 path: &Path,
83 visited: &mut FxHashSet<PathBuf>,
84 depth: usize,
85) -> Result<serde_json::Value, miette::Report> {
86 if depth >= MAX_EXTENDS_DEPTH {
87 return Err(miette::miette!(
88 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
89 path.display()
90 ));
91 }
92
93 let canonical = path.canonicalize().map_err(|e| {
94 miette::miette!(
95 "Config file not found or unresolvable: {}: {}",
96 path.display(),
97 e
98 )
99 })?;
100
101 if !visited.insert(canonical) {
102 return Err(miette::miette!(
103 "Circular extends detected: {} was already visited in the extends chain",
104 path.display()
105 ));
106 }
107
108 let mut value = parse_config_to_value(path)?;
109
110 let extends = value
111 .as_object_mut()
112 .and_then(|obj| obj.remove("extends"))
113 .and_then(|v| match v {
114 serde_json::Value::Array(arr) => Some(
115 arr.into_iter()
116 .filter_map(|v| v.as_str().map(String::from))
117 .collect::<Vec<_>>(),
118 ),
119 serde_json::Value::String(s) => Some(vec![s]),
120 _ => None,
121 })
122 .unwrap_or_default();
123
124 if extends.is_empty() {
125 return Ok(value);
126 }
127
128 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
129 let mut merged = serde_json::Value::Object(serde_json::Map::new());
130
131 for extend_path_str in &extends {
132 if Path::new(extend_path_str).is_absolute() {
133 return Err(miette::miette!(
134 "extends paths must be relative, got absolute path: {} (in {})",
135 extend_path_str,
136 path.display()
137 ));
138 }
139 let extend_path = config_dir.join(extend_path_str);
140 if !extend_path.exists() {
141 return Err(miette::miette!(
142 "Extended config file not found: {} (referenced from {})",
143 extend_path.display(),
144 path.display()
145 ));
146 }
147 let base = resolve_extends(&extend_path, visited, depth + 1)?;
148 deep_merge_json(&mut merged, base);
149 }
150
151 deep_merge_json(&mut merged, value);
152 Ok(merged)
153}
154
155impl FallowConfig {
156 pub fn load(path: &Path) -> Result<Self, miette::Report> {
169 let mut visited = FxHashSet::default();
170 let merged = resolve_extends(path, &mut visited, 0)?;
171
172 serde_json::from_value(merged).map_err(|e| {
173 miette::miette!(
174 "Failed to deserialize config from {}: {}",
175 path.display(),
176 e
177 )
178 })
179 }
180
181 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
198 let mut dir = start;
199 loop {
200 for name in CONFIG_NAMES {
201 let candidate = dir.join(name);
202 if candidate.exists() {
203 return Some(candidate);
204 }
205 }
206 if dir.join(".git").exists() || dir.join("package.json").exists() {
207 break;
208 }
209 dir = dir.parent()?;
210 }
211 None
212 }
213
214 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
215 let mut dir = start;
216 loop {
217 for name in CONFIG_NAMES {
218 let candidate = dir.join(name);
219 if candidate.exists() {
220 match Self::load(&candidate) {
221 Ok(config) => return Ok(Some((config, candidate))),
222 Err(e) => {
223 return Err(format!("Failed to parse {}: {e}", candidate.display()));
224 }
225 }
226 }
227 }
228 if dir.join(".git").exists() || dir.join("package.json").exists() {
230 break;
231 }
232 dir = match dir.parent() {
233 Some(parent) => parent,
234 None => break,
235 };
236 }
237 Ok(None)
238 }
239
240 #[must_use]
242 pub fn json_schema() -> serde_json::Value {
243 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use std::io::Read as _;
250
251 use super::*;
252 use crate::PackageJson;
253 use crate::config::duplicates_config::DuplicatesConfig;
254 use crate::config::format::OutputFormat;
255 use crate::config::health::HealthConfig;
256 use crate::config::rules::{RulesConfig, Severity};
257
258 fn test_dir(_name: &str) -> tempfile::TempDir {
260 tempfile::tempdir().expect("create temp dir")
261 }
262
263 #[test]
264 fn fallow_config_deserialize_minimal() {
265 let toml_str = r#"
266entry = ["src/main.ts"]
267"#;
268 let config: FallowConfig = toml::from_str(toml_str).unwrap();
269 assert_eq!(config.entry, vec!["src/main.ts"]);
270 assert!(config.ignore_patterns.is_empty());
271 }
272
273 #[test]
274 fn fallow_config_deserialize_ignore_exports() {
275 let toml_str = r#"
276[[ignoreExports]]
277file = "src/types/*.ts"
278exports = ["*"]
279
280[[ignoreExports]]
281file = "src/constants.ts"
282exports = ["FOO", "BAR"]
283"#;
284 let config: FallowConfig = toml::from_str(toml_str).unwrap();
285 assert_eq!(config.ignore_exports.len(), 2);
286 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
287 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
288 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
289 }
290
291 #[test]
292 fn fallow_config_deserialize_ignore_dependencies() {
293 let toml_str = r#"
294ignoreDependencies = ["autoprefixer", "postcss"]
295"#;
296 let config: FallowConfig = toml::from_str(toml_str).unwrap();
297 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
298 }
299
300 #[test]
301 fn fallow_config_resolve_default_ignores() {
302 let config = FallowConfig {
303 schema: None,
304 extends: vec![],
305 entry: vec![],
306 ignore_patterns: vec![],
307 framework: vec![],
308 workspaces: None,
309 ignore_dependencies: vec![],
310 ignore_exports: vec![],
311 duplicates: DuplicatesConfig::default(),
312 health: HealthConfig::default(),
313 rules: RulesConfig::default(),
314 production: false,
315 plugins: vec![],
316 overrides: vec![],
317 regression: None,
318 };
319 let resolved = config.resolve(
320 PathBuf::from("/tmp/test"),
321 OutputFormat::Human,
322 4,
323 true,
324 true,
325 );
326
327 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
329 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
330 assert!(resolved.ignore_patterns.is_match("build/output.js"));
331 assert!(resolved.ignore_patterns.is_match(".git/config"));
332 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
333 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
334 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
335 }
336
337 #[test]
338 fn fallow_config_resolve_custom_ignores() {
339 let config = FallowConfig {
340 schema: None,
341 extends: vec![],
342 entry: vec!["src/**/*.ts".to_string()],
343 ignore_patterns: vec!["**/*.generated.ts".to_string()],
344 framework: vec![],
345 workspaces: None,
346 ignore_dependencies: vec![],
347 ignore_exports: vec![],
348 duplicates: DuplicatesConfig::default(),
349 health: HealthConfig::default(),
350 rules: RulesConfig::default(),
351 production: false,
352 plugins: vec![],
353 overrides: vec![],
354 regression: None,
355 };
356 let resolved = config.resolve(
357 PathBuf::from("/tmp/test"),
358 OutputFormat::Json,
359 4,
360 false,
361 true,
362 );
363
364 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
365 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
366 assert!(matches!(resolved.output, OutputFormat::Json));
367 assert!(!resolved.no_cache);
368 }
369
370 #[test]
371 fn fallow_config_resolve_cache_dir() {
372 let config = FallowConfig {
373 schema: None,
374 extends: vec![],
375 entry: vec![],
376 ignore_patterns: vec![],
377 framework: vec![],
378 workspaces: None,
379 ignore_dependencies: vec![],
380 ignore_exports: vec![],
381 duplicates: DuplicatesConfig::default(),
382 health: HealthConfig::default(),
383 rules: RulesConfig::default(),
384 production: false,
385 plugins: vec![],
386 overrides: vec![],
387 regression: None,
388 };
389 let resolved = config.resolve(
390 PathBuf::from("/tmp/project"),
391 OutputFormat::Human,
392 4,
393 true,
394 true,
395 );
396 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
397 assert!(resolved.no_cache);
398 }
399
400 #[test]
401 fn package_json_entry_points_main() {
402 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
403 let entries = pkg.entry_points();
404 assert!(entries.contains(&"dist/index.js".to_string()));
405 }
406
407 #[test]
408 fn package_json_entry_points_module() {
409 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
410 let entries = pkg.entry_points();
411 assert!(entries.contains(&"dist/index.mjs".to_string()));
412 }
413
414 #[test]
415 fn package_json_entry_points_types() {
416 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
417 let entries = pkg.entry_points();
418 assert!(entries.contains(&"dist/index.d.ts".to_string()));
419 }
420
421 #[test]
422 fn package_json_entry_points_bin_string() {
423 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
424 let entries = pkg.entry_points();
425 assert!(entries.contains(&"bin/cli.js".to_string()));
426 }
427
428 #[test]
429 fn package_json_entry_points_bin_object() {
430 let pkg: PackageJson =
431 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
432 .unwrap();
433 let entries = pkg.entry_points();
434 assert!(entries.contains(&"bin/cli.js".to_string()));
435 assert!(entries.contains(&"bin/serve.js".to_string()));
436 }
437
438 #[test]
439 fn package_json_entry_points_exports_string() {
440 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
441 let entries = pkg.entry_points();
442 assert!(entries.contains(&"./dist/index.js".to_string()));
443 }
444
445 #[test]
446 fn package_json_entry_points_exports_object() {
447 let pkg: PackageJson = serde_json::from_str(
448 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
449 )
450 .unwrap();
451 let entries = pkg.entry_points();
452 assert!(entries.contains(&"./dist/index.mjs".to_string()));
453 assert!(entries.contains(&"./dist/index.cjs".to_string()));
454 }
455
456 #[test]
457 fn package_json_dependency_names() {
458 let pkg: PackageJson = serde_json::from_str(
459 r#"{
460 "dependencies": {"react": "^18", "lodash": "^4"},
461 "devDependencies": {"typescript": "^5"},
462 "peerDependencies": {"react-dom": "^18"}
463 }"#,
464 )
465 .unwrap();
466
467 let all = pkg.all_dependency_names();
468 assert!(all.contains(&"react".to_string()));
469 assert!(all.contains(&"lodash".to_string()));
470 assert!(all.contains(&"typescript".to_string()));
471 assert!(all.contains(&"react-dom".to_string()));
472
473 let prod = pkg.production_dependency_names();
474 assert!(prod.contains(&"react".to_string()));
475 assert!(!prod.contains(&"typescript".to_string()));
476
477 let dev = pkg.dev_dependency_names();
478 assert!(dev.contains(&"typescript".to_string()));
479 assert!(!dev.contains(&"react".to_string()));
480 }
481
482 #[test]
483 fn package_json_no_dependencies() {
484 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
485 assert!(pkg.all_dependency_names().is_empty());
486 assert!(pkg.production_dependency_names().is_empty());
487 assert!(pkg.dev_dependency_names().is_empty());
488 assert!(pkg.entry_points().is_empty());
489 }
490
491 #[test]
492 fn rules_deserialize_toml_kebab_case() {
493 let toml_str = r#"
494[rules]
495unused-files = "error"
496unused-exports = "warn"
497unused-types = "off"
498"#;
499 let config: FallowConfig = toml::from_str(toml_str).unwrap();
500 assert_eq!(config.rules.unused_files, Severity::Error);
501 assert_eq!(config.rules.unused_exports, Severity::Warn);
502 assert_eq!(config.rules.unused_types, Severity::Off);
503 assert_eq!(config.rules.unresolved_imports, Severity::Error);
505 }
506
507 #[test]
508 fn config_without_rules_defaults_to_error() {
509 let toml_str = r#"
510entry = ["src/main.ts"]
511"#;
512 let config: FallowConfig = toml::from_str(toml_str).unwrap();
513 assert_eq!(config.rules.unused_files, Severity::Error);
514 assert_eq!(config.rules.unused_exports, Severity::Error);
515 }
516
517 #[test]
518 fn fallow_config_denies_unknown_fields() {
519 let toml_str = r"
520unknown_field = true
521";
522 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
523 assert!(result.is_err());
524 }
525
526 #[test]
527 fn fallow_config_deserialize_json() {
528 let json_str = r#"{"entry": ["src/main.ts"]}"#;
529 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
530 assert_eq!(config.entry, vec!["src/main.ts"]);
531 }
532
533 #[test]
534 fn fallow_config_deserialize_jsonc() {
535 let jsonc_str = r#"{
536 // This is a comment
537 "entry": ["src/main.ts"],
538 "rules": {
539 "unused-files": "warn"
540 }
541 }"#;
542 let mut stripped = String::new();
543 json_comments::StripComments::new(jsonc_str.as_bytes())
544 .read_to_string(&mut stripped)
545 .unwrap();
546 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
547 assert_eq!(config.entry, vec!["src/main.ts"]);
548 assert_eq!(config.rules.unused_files, Severity::Warn);
549 }
550
551 #[test]
552 fn fallow_config_json_with_schema_field() {
553 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
554 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
555 assert_eq!(config.entry, vec!["src/main.ts"]);
556 }
557
558 #[test]
559 fn fallow_config_json_schema_generation() {
560 let schema = FallowConfig::json_schema();
561 assert!(schema.is_object());
562 let obj = schema.as_object().unwrap();
563 assert!(obj.contains_key("properties"));
564 }
565
566 #[test]
567 fn config_format_detection() {
568 assert!(matches!(
569 ConfigFormat::from_path(Path::new("fallow.toml")),
570 ConfigFormat::Toml
571 ));
572 assert!(matches!(
573 ConfigFormat::from_path(Path::new(".fallowrc.json")),
574 ConfigFormat::Json
575 ));
576 assert!(matches!(
577 ConfigFormat::from_path(Path::new(".fallow.toml")),
578 ConfigFormat::Toml
579 ));
580 }
581
582 #[test]
583 fn config_names_priority_order() {
584 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
585 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
586 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
587 }
588
589 #[test]
590 fn load_json_config_file() {
591 let dir = test_dir("json-config");
592 let config_path = dir.path().join(".fallowrc.json");
593 std::fs::write(
594 &config_path,
595 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
596 )
597 .unwrap();
598
599 let config = FallowConfig::load(&config_path).unwrap();
600 assert_eq!(config.entry, vec!["src/index.ts"]);
601 assert_eq!(config.rules.unused_exports, Severity::Warn);
602 }
603
604 #[test]
605 fn load_jsonc_config_file() {
606 let dir = test_dir("jsonc-config");
607 let config_path = dir.path().join(".fallowrc.json");
608 std::fs::write(
609 &config_path,
610 r#"{
611 // Entry points for analysis
612 "entry": ["src/index.ts"],
613 /* Block comment */
614 "rules": {
615 "unused-exports": "warn"
616 }
617 }"#,
618 )
619 .unwrap();
620
621 let config = FallowConfig::load(&config_path).unwrap();
622 assert_eq!(config.entry, vec!["src/index.ts"]);
623 assert_eq!(config.rules.unused_exports, Severity::Warn);
624 }
625
626 #[test]
627 fn json_config_ignore_dependencies_camel_case() {
628 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
629 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
630 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
631 }
632
633 #[test]
634 fn json_config_all_fields() {
635 let json_str = r#"{
636 "ignoreDependencies": ["lodash"],
637 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
638 "rules": {
639 "unused-files": "off",
640 "unused-exports": "warn",
641 "unused-dependencies": "error",
642 "unused-dev-dependencies": "off",
643 "unused-types": "warn",
644 "unused-enum-members": "error",
645 "unused-class-members": "off",
646 "unresolved-imports": "warn",
647 "unlisted-dependencies": "error",
648 "duplicate-exports": "off"
649 },
650 "duplicates": {
651 "minTokens": 100,
652 "minLines": 10,
653 "skipLocal": true
654 }
655 }"#;
656 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
657 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
658 assert_eq!(config.rules.unused_files, Severity::Off);
659 assert_eq!(config.rules.unused_exports, Severity::Warn);
660 assert_eq!(config.rules.unused_dependencies, Severity::Error);
661 assert_eq!(config.duplicates.min_tokens, 100);
662 assert_eq!(config.duplicates.min_lines, 10);
663 assert!(config.duplicates.skip_local);
664 }
665
666 #[test]
669 fn extends_single_base() {
670 let dir = test_dir("extends-single");
671
672 std::fs::write(
673 dir.path().join("base.json"),
674 r#"{"rules": {"unused-files": "warn"}}"#,
675 )
676 .unwrap();
677 std::fs::write(
678 dir.path().join(".fallowrc.json"),
679 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
680 )
681 .unwrap();
682
683 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
684 assert_eq!(config.rules.unused_files, Severity::Warn);
685 assert_eq!(config.entry, vec!["src/index.ts"]);
686 assert_eq!(config.rules.unused_exports, Severity::Error);
688 }
689
690 #[test]
691 fn extends_overlay_overrides_base() {
692 let dir = test_dir("extends-overlay");
693
694 std::fs::write(
695 dir.path().join("base.json"),
696 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
697 )
698 .unwrap();
699 std::fs::write(
700 dir.path().join(".fallowrc.json"),
701 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
702 )
703 .unwrap();
704
705 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
706 assert_eq!(config.rules.unused_files, Severity::Error);
708 assert_eq!(config.rules.unused_exports, Severity::Off);
710 }
711
712 #[test]
713 fn extends_chained() {
714 let dir = test_dir("extends-chained");
715
716 std::fs::write(
717 dir.path().join("grandparent.json"),
718 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
719 )
720 .unwrap();
721 std::fs::write(
722 dir.path().join("parent.json"),
723 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
724 )
725 .unwrap();
726 std::fs::write(
727 dir.path().join(".fallowrc.json"),
728 r#"{"extends": ["parent.json"]}"#,
729 )
730 .unwrap();
731
732 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
733 assert_eq!(config.rules.unused_files, Severity::Warn);
735 assert_eq!(config.rules.unused_exports, Severity::Warn);
737 }
738
739 #[test]
740 fn extends_circular_detected() {
741 let dir = test_dir("extends-circular");
742
743 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
744 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
745
746 let result = FallowConfig::load(&dir.path().join("a.json"));
747 assert!(result.is_err());
748 let err_msg = format!("{}", result.unwrap_err());
749 assert!(
750 err_msg.contains("Circular extends"),
751 "Expected circular error, got: {err_msg}"
752 );
753 }
754
755 #[test]
756 fn extends_missing_file_errors() {
757 let dir = test_dir("extends-missing");
758
759 std::fs::write(
760 dir.path().join(".fallowrc.json"),
761 r#"{"extends": ["nonexistent.json"]}"#,
762 )
763 .unwrap();
764
765 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
766 assert!(result.is_err());
767 let err_msg = format!("{}", result.unwrap_err());
768 assert!(
769 err_msg.contains("not found"),
770 "Expected not found error, got: {err_msg}"
771 );
772 }
773
774 #[test]
775 fn extends_string_sugar() {
776 let dir = test_dir("extends-string");
777
778 std::fs::write(
779 dir.path().join("base.json"),
780 r#"{"ignorePatterns": ["gen/**"]}"#,
781 )
782 .unwrap();
783 std::fs::write(
785 dir.path().join(".fallowrc.json"),
786 r#"{"extends": "base.json"}"#,
787 )
788 .unwrap();
789
790 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
791 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
792 }
793
794 #[test]
795 fn extends_deep_merge_preserves_arrays() {
796 let dir = test_dir("extends-array");
797
798 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
799 std::fs::write(
800 dir.path().join(".fallowrc.json"),
801 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
802 )
803 .unwrap();
804
805 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
806 assert_eq!(config.entry, vec!["src/b.ts"]);
808 }
809
810 #[test]
813 fn deep_merge_scalar_overlay_replaces_base() {
814 let mut base = serde_json::json!("hello");
815 deep_merge_json(&mut base, serde_json::json!("world"));
816 assert_eq!(base, serde_json::json!("world"));
817 }
818
819 #[test]
820 fn deep_merge_array_overlay_replaces_base() {
821 let mut base = serde_json::json!(["a", "b"]);
822 deep_merge_json(&mut base, serde_json::json!(["c"]));
823 assert_eq!(base, serde_json::json!(["c"]));
824 }
825
826 #[test]
827 fn deep_merge_nested_object_merge() {
828 let mut base = serde_json::json!({
829 "level1": {
830 "level2": {
831 "a": 1,
832 "b": 2
833 }
834 }
835 });
836 let overlay = serde_json::json!({
837 "level1": {
838 "level2": {
839 "b": 99,
840 "c": 3
841 }
842 }
843 });
844 deep_merge_json(&mut base, overlay);
845 assert_eq!(base["level1"]["level2"]["a"], 1);
846 assert_eq!(base["level1"]["level2"]["b"], 99);
847 assert_eq!(base["level1"]["level2"]["c"], 3);
848 }
849
850 #[test]
851 fn deep_merge_overlay_adds_new_fields() {
852 let mut base = serde_json::json!({"existing": true});
853 let overlay = serde_json::json!({"new_field": "added", "another": 42});
854 deep_merge_json(&mut base, overlay);
855 assert_eq!(base["existing"], true);
856 assert_eq!(base["new_field"], "added");
857 assert_eq!(base["another"], 42);
858 }
859
860 #[test]
861 fn deep_merge_null_overlay_replaces_object() {
862 let mut base = serde_json::json!({"key": "value"});
863 deep_merge_json(&mut base, serde_json::json!(null));
864 assert_eq!(base, serde_json::json!(null));
865 }
866
867 #[test]
868 fn deep_merge_empty_object_overlay_preserves_base() {
869 let mut base = serde_json::json!({"a": 1, "b": 2});
870 deep_merge_json(&mut base, serde_json::json!({}));
871 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
872 }
873
874 #[test]
877 fn rules_severity_error_warn_off_from_json() {
878 let json_str = r#"{
879 "rules": {
880 "unused-files": "error",
881 "unused-exports": "warn",
882 "unused-types": "off"
883 }
884 }"#;
885 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
886 assert_eq!(config.rules.unused_files, Severity::Error);
887 assert_eq!(config.rules.unused_exports, Severity::Warn);
888 assert_eq!(config.rules.unused_types, Severity::Off);
889 }
890
891 #[test]
892 fn rules_omitted_default_to_error() {
893 let json_str = r#"{
894 "rules": {
895 "unused-files": "warn"
896 }
897 }"#;
898 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
899 assert_eq!(config.rules.unused_files, Severity::Warn);
900 assert_eq!(config.rules.unused_exports, Severity::Error);
902 assert_eq!(config.rules.unused_types, Severity::Error);
903 assert_eq!(config.rules.unused_dependencies, Severity::Error);
904 assert_eq!(config.rules.unresolved_imports, Severity::Error);
905 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
906 assert_eq!(config.rules.duplicate_exports, Severity::Error);
907 assert_eq!(config.rules.circular_dependencies, Severity::Error);
908 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
910 }
911
912 #[test]
915 fn find_and_load_returns_none_when_no_config() {
916 let dir = test_dir("find-none");
917 std::fs::create_dir(dir.path().join(".git")).unwrap();
919
920 let result = FallowConfig::find_and_load(dir.path()).unwrap();
921 assert!(result.is_none());
922 }
923
924 #[test]
925 fn find_and_load_finds_fallowrc_json() {
926 let dir = test_dir("find-json");
927 std::fs::create_dir(dir.path().join(".git")).unwrap();
928 std::fs::write(
929 dir.path().join(".fallowrc.json"),
930 r#"{"entry": ["src/main.ts"]}"#,
931 )
932 .unwrap();
933
934 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
935 assert_eq!(config.entry, vec!["src/main.ts"]);
936 assert!(path.ends_with(".fallowrc.json"));
937 }
938
939 #[test]
940 fn find_and_load_prefers_fallowrc_json_over_toml() {
941 let dir = test_dir("find-priority");
942 std::fs::create_dir(dir.path().join(".git")).unwrap();
943 std::fs::write(
944 dir.path().join(".fallowrc.json"),
945 r#"{"entry": ["from-json.ts"]}"#,
946 )
947 .unwrap();
948 std::fs::write(
949 dir.path().join("fallow.toml"),
950 "entry = [\"from-toml.ts\"]\n",
951 )
952 .unwrap();
953
954 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
955 assert_eq!(config.entry, vec!["from-json.ts"]);
956 assert!(path.ends_with(".fallowrc.json"));
957 }
958
959 #[test]
960 fn find_and_load_finds_fallow_toml() {
961 let dir = test_dir("find-toml");
962 std::fs::create_dir(dir.path().join(".git")).unwrap();
963 std::fs::write(
964 dir.path().join("fallow.toml"),
965 "entry = [\"src/index.ts\"]\n",
966 )
967 .unwrap();
968
969 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
970 assert_eq!(config.entry, vec!["src/index.ts"]);
971 }
972
973 #[test]
974 fn find_and_load_stops_at_git_dir() {
975 let dir = test_dir("find-git-stop");
976 let sub = dir.path().join("sub");
977 std::fs::create_dir(&sub).unwrap();
978 std::fs::create_dir(dir.path().join(".git")).unwrap();
980 let result = FallowConfig::find_and_load(&sub).unwrap();
984 assert!(result.is_none());
985 }
986
987 #[test]
988 fn find_and_load_stops_at_package_json() {
989 let dir = test_dir("find-pkg-stop");
990 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
991
992 let result = FallowConfig::find_and_load(dir.path()).unwrap();
993 assert!(result.is_none());
994 }
995
996 #[test]
997 fn find_and_load_returns_error_for_invalid_config() {
998 let dir = test_dir("find-invalid");
999 std::fs::create_dir(dir.path().join(".git")).unwrap();
1000 std::fs::write(
1001 dir.path().join(".fallowrc.json"),
1002 r"{ this is not valid json }",
1003 )
1004 .unwrap();
1005
1006 let result = FallowConfig::find_and_load(dir.path());
1007 assert!(result.is_err());
1008 }
1009
1010 #[test]
1013 fn load_toml_config_file() {
1014 let dir = test_dir("toml-config");
1015 let config_path = dir.path().join("fallow.toml");
1016 std::fs::write(
1017 &config_path,
1018 r#"
1019entry = ["src/index.ts"]
1020ignorePatterns = ["dist/**"]
1021
1022[rules]
1023unused-files = "warn"
1024
1025[duplicates]
1026minTokens = 100
1027"#,
1028 )
1029 .unwrap();
1030
1031 let config = FallowConfig::load(&config_path).unwrap();
1032 assert_eq!(config.entry, vec!["src/index.ts"]);
1033 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1034 assert_eq!(config.rules.unused_files, Severity::Warn);
1035 assert_eq!(config.duplicates.min_tokens, 100);
1036 }
1037
1038 #[test]
1041 fn extends_absolute_path_rejected() {
1042 let dir = test_dir("extends-absolute");
1043
1044 #[cfg(unix)]
1046 let abs_path = "/absolute/path/config.json";
1047 #[cfg(windows)]
1048 let abs_path = "C:\\absolute\\path\\config.json";
1049
1050 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1051 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1052
1053 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1054 assert!(result.is_err());
1055 let err_msg = format!("{}", result.unwrap_err());
1056 assert!(
1057 err_msg.contains("must be relative"),
1058 "Expected 'must be relative' error, got: {err_msg}"
1059 );
1060 }
1061
1062 #[test]
1065 fn resolve_production_mode_disables_dev_deps() {
1066 let config = FallowConfig {
1067 schema: None,
1068 extends: vec![],
1069 entry: vec![],
1070 ignore_patterns: vec![],
1071 framework: vec![],
1072 workspaces: None,
1073 ignore_dependencies: vec![],
1074 ignore_exports: vec![],
1075 duplicates: DuplicatesConfig::default(),
1076 health: HealthConfig::default(),
1077 rules: RulesConfig::default(),
1078 production: true,
1079 plugins: vec![],
1080 overrides: vec![],
1081 regression: None,
1082 };
1083 let resolved = config.resolve(
1084 PathBuf::from("/tmp/test"),
1085 OutputFormat::Human,
1086 4,
1087 false,
1088 true,
1089 );
1090 assert!(resolved.production);
1091 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1092 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1093 assert_eq!(resolved.rules.unused_files, Severity::Error);
1095 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1096 }
1097
1098 #[test]
1101 fn config_format_defaults_to_toml_for_unknown() {
1102 assert!(matches!(
1103 ConfigFormat::from_path(Path::new("config.yaml")),
1104 ConfigFormat::Toml
1105 ));
1106 assert!(matches!(
1107 ConfigFormat::from_path(Path::new("config")),
1108 ConfigFormat::Toml
1109 ));
1110 }
1111
1112 #[test]
1115 fn deep_merge_object_over_scalar_replaces() {
1116 let mut base = serde_json::json!("just a string");
1117 let overlay = serde_json::json!({"key": "value"});
1118 deep_merge_json(&mut base, overlay);
1119 assert_eq!(base, serde_json::json!({"key": "value"}));
1120 }
1121
1122 #[test]
1123 fn deep_merge_scalar_over_object_replaces() {
1124 let mut base = serde_json::json!({"key": "value"});
1125 let overlay = serde_json::json!(42);
1126 deep_merge_json(&mut base, overlay);
1127 assert_eq!(base, serde_json::json!(42));
1128 }
1129
1130 #[test]
1133 fn extends_non_string_non_array_ignored() {
1134 let dir = test_dir("extends-numeric");
1135 std::fs::write(
1136 dir.path().join(".fallowrc.json"),
1137 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1138 )
1139 .unwrap();
1140
1141 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1143 assert_eq!(config.entry, vec!["src/index.ts"]);
1144 }
1145
1146 #[test]
1149 fn extends_multiple_bases_later_wins() {
1150 let dir = test_dir("extends-multi-base");
1151
1152 std::fs::write(
1153 dir.path().join("base-a.json"),
1154 r#"{"rules": {"unused-files": "warn"}}"#,
1155 )
1156 .unwrap();
1157 std::fs::write(
1158 dir.path().join("base-b.json"),
1159 r#"{"rules": {"unused-files": "off"}}"#,
1160 )
1161 .unwrap();
1162 std::fs::write(
1163 dir.path().join(".fallowrc.json"),
1164 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1165 )
1166 .unwrap();
1167
1168 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1169 assert_eq!(config.rules.unused_files, Severity::Off);
1171 }
1172
1173 #[test]
1176 fn fallow_config_deserialize_production() {
1177 let json_str = r#"{"production": true}"#;
1178 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1179 assert!(config.production);
1180 }
1181
1182 #[test]
1183 fn fallow_config_production_defaults_false() {
1184 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1185 assert!(!config.production);
1186 }
1187
1188 #[test]
1191 fn package_json_optional_dependency_names() {
1192 let pkg: PackageJson = serde_json::from_str(
1193 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1194 )
1195 .unwrap();
1196 let opt = pkg.optional_dependency_names();
1197 assert_eq!(opt.len(), 2);
1198 assert!(opt.contains(&"fsevents".to_string()));
1199 assert!(opt.contains(&"chokidar".to_string()));
1200 }
1201
1202 #[test]
1203 fn package_json_optional_deps_empty_when_missing() {
1204 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1205 assert!(pkg.optional_dependency_names().is_empty());
1206 }
1207}