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> {
165 let mut visited = FxHashSet::default();
166 let merged = resolve_extends(path, &mut visited, 0)?;
167
168 serde_json::from_value(merged).map_err(|e| {
169 miette::miette!(
170 "Failed to deserialize config from {}: {}",
171 path.display(),
172 e
173 )
174 })
175 }
176
177 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
188 let mut dir = start;
189 loop {
190 for name in CONFIG_NAMES {
191 let candidate = dir.join(name);
192 if candidate.exists() {
193 match Self::load(&candidate) {
194 Ok(config) => return Ok(Some((config, candidate))),
195 Err(e) => {
196 return Err(format!("Failed to parse {}: {e}", candidate.display()));
197 }
198 }
199 }
200 }
201 if dir.join(".git").exists() || dir.join("package.json").exists() {
203 break;
204 }
205 dir = match dir.parent() {
206 Some(parent) => parent,
207 None => break,
208 };
209 }
210 Ok(None)
211 }
212
213 pub fn json_schema() -> serde_json::Value {
215 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use std::io::Read as _;
222
223 use super::*;
224 use crate::PackageJson;
225 use crate::config::duplicates_config::DuplicatesConfig;
226 use crate::config::format::OutputFormat;
227 use crate::config::health::HealthConfig;
228 use crate::config::rules::{RulesConfig, Severity};
229
230 fn test_dir(_name: &str) -> tempfile::TempDir {
232 tempfile::tempdir().expect("create temp dir")
233 }
234
235 #[test]
236 fn fallow_config_deserialize_minimal() {
237 let toml_str = r#"
238entry = ["src/main.ts"]
239"#;
240 let config: FallowConfig = toml::from_str(toml_str).unwrap();
241 assert_eq!(config.entry, vec!["src/main.ts"]);
242 assert!(config.ignore_patterns.is_empty());
243 }
244
245 #[test]
246 fn fallow_config_deserialize_ignore_exports() {
247 let toml_str = r#"
248[[ignoreExports]]
249file = "src/types/*.ts"
250exports = ["*"]
251
252[[ignoreExports]]
253file = "src/constants.ts"
254exports = ["FOO", "BAR"]
255"#;
256 let config: FallowConfig = toml::from_str(toml_str).unwrap();
257 assert_eq!(config.ignore_exports.len(), 2);
258 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
259 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
260 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
261 }
262
263 #[test]
264 fn fallow_config_deserialize_ignore_dependencies() {
265 let toml_str = r#"
266ignoreDependencies = ["autoprefixer", "postcss"]
267"#;
268 let config: FallowConfig = toml::from_str(toml_str).unwrap();
269 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
270 }
271
272 #[test]
273 fn fallow_config_resolve_default_ignores() {
274 let config = FallowConfig {
275 schema: None,
276 extends: vec![],
277 entry: vec![],
278 ignore_patterns: vec![],
279 framework: vec![],
280 workspaces: None,
281 ignore_dependencies: vec![],
282 ignore_exports: vec![],
283 duplicates: DuplicatesConfig::default(),
284 health: HealthConfig::default(),
285 rules: RulesConfig::default(),
286 production: false,
287 plugins: vec![],
288 overrides: vec![],
289 };
290 let resolved = config.resolve(
291 PathBuf::from("/tmp/test"),
292 OutputFormat::Human,
293 4,
294 true,
295 true,
296 );
297
298 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
300 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
301 assert!(resolved.ignore_patterns.is_match("build/output.js"));
302 assert!(resolved.ignore_patterns.is_match(".git/config"));
303 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
304 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
305 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
306 }
307
308 #[test]
309 fn fallow_config_resolve_custom_ignores() {
310 let config = FallowConfig {
311 schema: None,
312 extends: vec![],
313 entry: vec!["src/**/*.ts".to_string()],
314 ignore_patterns: vec!["**/*.generated.ts".to_string()],
315 framework: vec![],
316 workspaces: None,
317 ignore_dependencies: vec![],
318 ignore_exports: vec![],
319 duplicates: DuplicatesConfig::default(),
320 health: HealthConfig::default(),
321 rules: RulesConfig::default(),
322 production: false,
323 plugins: vec![],
324 overrides: vec![],
325 };
326 let resolved = config.resolve(
327 PathBuf::from("/tmp/test"),
328 OutputFormat::Json,
329 4,
330 false,
331 true,
332 );
333
334 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
335 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
336 assert!(matches!(resolved.output, OutputFormat::Json));
337 assert!(!resolved.no_cache);
338 }
339
340 #[test]
341 fn fallow_config_resolve_cache_dir() {
342 let config = FallowConfig {
343 schema: None,
344 extends: vec![],
345 entry: vec![],
346 ignore_patterns: vec![],
347 framework: vec![],
348 workspaces: None,
349 ignore_dependencies: vec![],
350 ignore_exports: vec![],
351 duplicates: DuplicatesConfig::default(),
352 health: HealthConfig::default(),
353 rules: RulesConfig::default(),
354 production: false,
355 plugins: vec![],
356 overrides: vec![],
357 };
358 let resolved = config.resolve(
359 PathBuf::from("/tmp/project"),
360 OutputFormat::Human,
361 4,
362 true,
363 true,
364 );
365 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
366 assert!(resolved.no_cache);
367 }
368
369 #[test]
370 fn package_json_entry_points_main() {
371 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
372 let entries = pkg.entry_points();
373 assert!(entries.contains(&"dist/index.js".to_string()));
374 }
375
376 #[test]
377 fn package_json_entry_points_module() {
378 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
379 let entries = pkg.entry_points();
380 assert!(entries.contains(&"dist/index.mjs".to_string()));
381 }
382
383 #[test]
384 fn package_json_entry_points_types() {
385 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
386 let entries = pkg.entry_points();
387 assert!(entries.contains(&"dist/index.d.ts".to_string()));
388 }
389
390 #[test]
391 fn package_json_entry_points_bin_string() {
392 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
393 let entries = pkg.entry_points();
394 assert!(entries.contains(&"bin/cli.js".to_string()));
395 }
396
397 #[test]
398 fn package_json_entry_points_bin_object() {
399 let pkg: PackageJson =
400 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
401 .unwrap();
402 let entries = pkg.entry_points();
403 assert!(entries.contains(&"bin/cli.js".to_string()));
404 assert!(entries.contains(&"bin/serve.js".to_string()));
405 }
406
407 #[test]
408 fn package_json_entry_points_exports_string() {
409 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
410 let entries = pkg.entry_points();
411 assert!(entries.contains(&"./dist/index.js".to_string()));
412 }
413
414 #[test]
415 fn package_json_entry_points_exports_object() {
416 let pkg: PackageJson = serde_json::from_str(
417 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
418 )
419 .unwrap();
420 let entries = pkg.entry_points();
421 assert!(entries.contains(&"./dist/index.mjs".to_string()));
422 assert!(entries.contains(&"./dist/index.cjs".to_string()));
423 }
424
425 #[test]
426 fn package_json_dependency_names() {
427 let pkg: PackageJson = serde_json::from_str(
428 r#"{
429 "dependencies": {"react": "^18", "lodash": "^4"},
430 "devDependencies": {"typescript": "^5"},
431 "peerDependencies": {"react-dom": "^18"}
432 }"#,
433 )
434 .unwrap();
435
436 let all = pkg.all_dependency_names();
437 assert!(all.contains(&"react".to_string()));
438 assert!(all.contains(&"lodash".to_string()));
439 assert!(all.contains(&"typescript".to_string()));
440 assert!(all.contains(&"react-dom".to_string()));
441
442 let prod = pkg.production_dependency_names();
443 assert!(prod.contains(&"react".to_string()));
444 assert!(!prod.contains(&"typescript".to_string()));
445
446 let dev = pkg.dev_dependency_names();
447 assert!(dev.contains(&"typescript".to_string()));
448 assert!(!dev.contains(&"react".to_string()));
449 }
450
451 #[test]
452 fn package_json_no_dependencies() {
453 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
454 assert!(pkg.all_dependency_names().is_empty());
455 assert!(pkg.production_dependency_names().is_empty());
456 assert!(pkg.dev_dependency_names().is_empty());
457 assert!(pkg.entry_points().is_empty());
458 }
459
460 #[test]
461 fn rules_deserialize_toml_kebab_case() {
462 let toml_str = r#"
463[rules]
464unused-files = "error"
465unused-exports = "warn"
466unused-types = "off"
467"#;
468 let config: FallowConfig = toml::from_str(toml_str).unwrap();
469 assert_eq!(config.rules.unused_files, Severity::Error);
470 assert_eq!(config.rules.unused_exports, Severity::Warn);
471 assert_eq!(config.rules.unused_types, Severity::Off);
472 assert_eq!(config.rules.unresolved_imports, Severity::Error);
474 }
475
476 #[test]
477 fn config_without_rules_defaults_to_error() {
478 let toml_str = r#"
479entry = ["src/main.ts"]
480"#;
481 let config: FallowConfig = toml::from_str(toml_str).unwrap();
482 assert_eq!(config.rules.unused_files, Severity::Error);
483 assert_eq!(config.rules.unused_exports, Severity::Error);
484 }
485
486 #[test]
487 fn fallow_config_denies_unknown_fields() {
488 let toml_str = r#"
489unknown_field = true
490"#;
491 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
492 assert!(result.is_err());
493 }
494
495 #[test]
496 fn fallow_config_deserialize_json() {
497 let json_str = r#"{"entry": ["src/main.ts"]}"#;
498 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
499 assert_eq!(config.entry, vec!["src/main.ts"]);
500 }
501
502 #[test]
503 fn fallow_config_deserialize_jsonc() {
504 let jsonc_str = r#"{
505 // This is a comment
506 "entry": ["src/main.ts"],
507 "rules": {
508 "unused-files": "warn"
509 }
510 }"#;
511 let mut stripped = String::new();
512 json_comments::StripComments::new(jsonc_str.as_bytes())
513 .read_to_string(&mut stripped)
514 .unwrap();
515 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
516 assert_eq!(config.entry, vec!["src/main.ts"]);
517 assert_eq!(config.rules.unused_files, Severity::Warn);
518 }
519
520 #[test]
521 fn fallow_config_json_with_schema_field() {
522 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
523 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
524 assert_eq!(config.entry, vec!["src/main.ts"]);
525 }
526
527 #[test]
528 fn fallow_config_json_schema_generation() {
529 let schema = FallowConfig::json_schema();
530 assert!(schema.is_object());
531 let obj = schema.as_object().unwrap();
532 assert!(obj.contains_key("properties"));
533 }
534
535 #[test]
536 fn config_format_detection() {
537 assert!(matches!(
538 ConfigFormat::from_path(Path::new("fallow.toml")),
539 ConfigFormat::Toml
540 ));
541 assert!(matches!(
542 ConfigFormat::from_path(Path::new(".fallowrc.json")),
543 ConfigFormat::Json
544 ));
545 assert!(matches!(
546 ConfigFormat::from_path(Path::new(".fallow.toml")),
547 ConfigFormat::Toml
548 ));
549 }
550
551 #[test]
552 fn config_names_priority_order() {
553 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
554 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
555 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
556 }
557
558 #[test]
559 fn load_json_config_file() {
560 let dir = test_dir("json-config");
561 let config_path = dir.path().join(".fallowrc.json");
562 std::fs::write(
563 &config_path,
564 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
565 )
566 .unwrap();
567
568 let config = FallowConfig::load(&config_path).unwrap();
569 assert_eq!(config.entry, vec!["src/index.ts"]);
570 assert_eq!(config.rules.unused_exports, Severity::Warn);
571 }
572
573 #[test]
574 fn load_jsonc_config_file() {
575 let dir = test_dir("jsonc-config");
576 let config_path = dir.path().join(".fallowrc.json");
577 std::fs::write(
578 &config_path,
579 r#"{
580 // Entry points for analysis
581 "entry": ["src/index.ts"],
582 /* Block comment */
583 "rules": {
584 "unused-exports": "warn"
585 }
586 }"#,
587 )
588 .unwrap();
589
590 let config = FallowConfig::load(&config_path).unwrap();
591 assert_eq!(config.entry, vec!["src/index.ts"]);
592 assert_eq!(config.rules.unused_exports, Severity::Warn);
593 }
594
595 #[test]
596 fn json_config_ignore_dependencies_camel_case() {
597 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
598 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
599 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
600 }
601
602 #[test]
603 fn json_config_all_fields() {
604 let json_str = r#"{
605 "ignoreDependencies": ["lodash"],
606 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
607 "rules": {
608 "unused-files": "off",
609 "unused-exports": "warn",
610 "unused-dependencies": "error",
611 "unused-dev-dependencies": "off",
612 "unused-types": "warn",
613 "unused-enum-members": "error",
614 "unused-class-members": "off",
615 "unresolved-imports": "warn",
616 "unlisted-dependencies": "error",
617 "duplicate-exports": "off"
618 },
619 "duplicates": {
620 "minTokens": 100,
621 "minLines": 10,
622 "skipLocal": true
623 }
624 }"#;
625 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
626 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
627 assert_eq!(config.rules.unused_files, Severity::Off);
628 assert_eq!(config.rules.unused_exports, Severity::Warn);
629 assert_eq!(config.rules.unused_dependencies, Severity::Error);
630 assert_eq!(config.duplicates.min_tokens, 100);
631 assert_eq!(config.duplicates.min_lines, 10);
632 assert!(config.duplicates.skip_local);
633 }
634
635 #[test]
638 fn extends_single_base() {
639 let dir = test_dir("extends-single");
640
641 std::fs::write(
642 dir.path().join("base.json"),
643 r#"{"rules": {"unused-files": "warn"}}"#,
644 )
645 .unwrap();
646 std::fs::write(
647 dir.path().join(".fallowrc.json"),
648 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
649 )
650 .unwrap();
651
652 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
653 assert_eq!(config.rules.unused_files, Severity::Warn);
654 assert_eq!(config.entry, vec!["src/index.ts"]);
655 assert_eq!(config.rules.unused_exports, Severity::Error);
657 }
658
659 #[test]
660 fn extends_overlay_overrides_base() {
661 let dir = test_dir("extends-overlay");
662
663 std::fs::write(
664 dir.path().join("base.json"),
665 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
666 )
667 .unwrap();
668 std::fs::write(
669 dir.path().join(".fallowrc.json"),
670 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
671 )
672 .unwrap();
673
674 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
675 assert_eq!(config.rules.unused_files, Severity::Error);
677 assert_eq!(config.rules.unused_exports, Severity::Off);
679 }
680
681 #[test]
682 fn extends_chained() {
683 let dir = test_dir("extends-chained");
684
685 std::fs::write(
686 dir.path().join("grandparent.json"),
687 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
688 )
689 .unwrap();
690 std::fs::write(
691 dir.path().join("parent.json"),
692 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
693 )
694 .unwrap();
695 std::fs::write(
696 dir.path().join(".fallowrc.json"),
697 r#"{"extends": ["parent.json"]}"#,
698 )
699 .unwrap();
700
701 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
702 assert_eq!(config.rules.unused_files, Severity::Warn);
704 assert_eq!(config.rules.unused_exports, Severity::Warn);
706 }
707
708 #[test]
709 fn extends_circular_detected() {
710 let dir = test_dir("extends-circular");
711
712 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
713 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
714
715 let result = FallowConfig::load(&dir.path().join("a.json"));
716 assert!(result.is_err());
717 let err_msg = format!("{}", result.unwrap_err());
718 assert!(
719 err_msg.contains("Circular extends"),
720 "Expected circular error, got: {err_msg}"
721 );
722 }
723
724 #[test]
725 fn extends_missing_file_errors() {
726 let dir = test_dir("extends-missing");
727
728 std::fs::write(
729 dir.path().join(".fallowrc.json"),
730 r#"{"extends": ["nonexistent.json"]}"#,
731 )
732 .unwrap();
733
734 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
735 assert!(result.is_err());
736 let err_msg = format!("{}", result.unwrap_err());
737 assert!(
738 err_msg.contains("not found"),
739 "Expected not found error, got: {err_msg}"
740 );
741 }
742
743 #[test]
744 fn extends_string_sugar() {
745 let dir = test_dir("extends-string");
746
747 std::fs::write(
748 dir.path().join("base.json"),
749 r#"{"ignorePatterns": ["gen/**"]}"#,
750 )
751 .unwrap();
752 std::fs::write(
754 dir.path().join(".fallowrc.json"),
755 r#"{"extends": "base.json"}"#,
756 )
757 .unwrap();
758
759 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
760 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
761 }
762
763 #[test]
764 fn extends_deep_merge_preserves_arrays() {
765 let dir = test_dir("extends-array");
766
767 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
768 std::fs::write(
769 dir.path().join(".fallowrc.json"),
770 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
771 )
772 .unwrap();
773
774 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
775 assert_eq!(config.entry, vec!["src/b.ts"]);
777 }
778
779 #[test]
782 fn deep_merge_scalar_overlay_replaces_base() {
783 let mut base = serde_json::json!("hello");
784 deep_merge_json(&mut base, serde_json::json!("world"));
785 assert_eq!(base, serde_json::json!("world"));
786 }
787
788 #[test]
789 fn deep_merge_array_overlay_replaces_base() {
790 let mut base = serde_json::json!(["a", "b"]);
791 deep_merge_json(&mut base, serde_json::json!(["c"]));
792 assert_eq!(base, serde_json::json!(["c"]));
793 }
794
795 #[test]
796 fn deep_merge_nested_object_merge() {
797 let mut base = serde_json::json!({
798 "level1": {
799 "level2": {
800 "a": 1,
801 "b": 2
802 }
803 }
804 });
805 let overlay = serde_json::json!({
806 "level1": {
807 "level2": {
808 "b": 99,
809 "c": 3
810 }
811 }
812 });
813 deep_merge_json(&mut base, overlay);
814 assert_eq!(base["level1"]["level2"]["a"], 1);
815 assert_eq!(base["level1"]["level2"]["b"], 99);
816 assert_eq!(base["level1"]["level2"]["c"], 3);
817 }
818
819 #[test]
820 fn deep_merge_overlay_adds_new_fields() {
821 let mut base = serde_json::json!({"existing": true});
822 let overlay = serde_json::json!({"new_field": "added", "another": 42});
823 deep_merge_json(&mut base, overlay);
824 assert_eq!(base["existing"], true);
825 assert_eq!(base["new_field"], "added");
826 assert_eq!(base["another"], 42);
827 }
828
829 #[test]
830 fn deep_merge_null_overlay_replaces_object() {
831 let mut base = serde_json::json!({"key": "value"});
832 deep_merge_json(&mut base, serde_json::json!(null));
833 assert_eq!(base, serde_json::json!(null));
834 }
835
836 #[test]
837 fn deep_merge_empty_object_overlay_preserves_base() {
838 let mut base = serde_json::json!({"a": 1, "b": 2});
839 deep_merge_json(&mut base, serde_json::json!({}));
840 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
841 }
842
843 #[test]
846 fn rules_severity_error_warn_off_from_json() {
847 let json_str = r#"{
848 "rules": {
849 "unused-files": "error",
850 "unused-exports": "warn",
851 "unused-types": "off"
852 }
853 }"#;
854 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
855 assert_eq!(config.rules.unused_files, Severity::Error);
856 assert_eq!(config.rules.unused_exports, Severity::Warn);
857 assert_eq!(config.rules.unused_types, Severity::Off);
858 }
859
860 #[test]
861 fn rules_omitted_default_to_error() {
862 let json_str = r#"{
863 "rules": {
864 "unused-files": "warn"
865 }
866 }"#;
867 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
868 assert_eq!(config.rules.unused_files, Severity::Warn);
869 assert_eq!(config.rules.unused_exports, Severity::Error);
871 assert_eq!(config.rules.unused_types, Severity::Error);
872 assert_eq!(config.rules.unused_dependencies, Severity::Error);
873 assert_eq!(config.rules.unresolved_imports, Severity::Error);
874 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
875 assert_eq!(config.rules.duplicate_exports, Severity::Error);
876 assert_eq!(config.rules.circular_dependencies, Severity::Error);
877 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
879 }
880}