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) -> PathBuf {
232 use std::sync::atomic::{AtomicU64, Ordering};
233 static COUNTER: AtomicU64 = AtomicU64::new(0);
234 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
235 let dir = std::env::temp_dir().join(format!("fallow-{name}-{id}"));
236 let _ = std::fs::remove_dir_all(&dir);
237 std::fs::create_dir_all(&dir).unwrap();
238 dir
239 }
240
241 #[test]
242 fn fallow_config_deserialize_minimal() {
243 let toml_str = r#"
244entry = ["src/main.ts"]
245"#;
246 let config: FallowConfig = toml::from_str(toml_str).unwrap();
247 assert_eq!(config.entry, vec!["src/main.ts"]);
248 assert!(config.ignore_patterns.is_empty());
249 }
250
251 #[test]
252 fn fallow_config_deserialize_ignore_exports() {
253 let toml_str = r#"
254[[ignoreExports]]
255file = "src/types/*.ts"
256exports = ["*"]
257
258[[ignoreExports]]
259file = "src/constants.ts"
260exports = ["FOO", "BAR"]
261"#;
262 let config: FallowConfig = toml::from_str(toml_str).unwrap();
263 assert_eq!(config.ignore_exports.len(), 2);
264 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
265 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
266 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
267 }
268
269 #[test]
270 fn fallow_config_deserialize_ignore_dependencies() {
271 let toml_str = r#"
272ignoreDependencies = ["autoprefixer", "postcss"]
273"#;
274 let config: FallowConfig = toml::from_str(toml_str).unwrap();
275 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
276 }
277
278 #[test]
279 fn fallow_config_resolve_default_ignores() {
280 let config = FallowConfig {
281 schema: None,
282 extends: vec![],
283 entry: vec![],
284 ignore_patterns: vec![],
285 framework: vec![],
286 workspaces: None,
287 ignore_dependencies: vec![],
288 ignore_exports: vec![],
289 duplicates: DuplicatesConfig::default(),
290 health: HealthConfig::default(),
291 rules: RulesConfig::default(),
292 production: false,
293 plugins: vec![],
294 overrides: vec![],
295 };
296 let resolved = config.resolve(
297 PathBuf::from("/tmp/test"),
298 OutputFormat::Human,
299 4,
300 true,
301 true,
302 );
303
304 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
306 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
307 assert!(resolved.ignore_patterns.is_match("build/output.js"));
308 assert!(resolved.ignore_patterns.is_match(".git/config"));
309 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
310 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
311 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
312 }
313
314 #[test]
315 fn fallow_config_resolve_custom_ignores() {
316 let config = FallowConfig {
317 schema: None,
318 extends: vec![],
319 entry: vec!["src/**/*.ts".to_string()],
320 ignore_patterns: vec!["**/*.generated.ts".to_string()],
321 framework: vec![],
322 workspaces: None,
323 ignore_dependencies: vec![],
324 ignore_exports: vec![],
325 duplicates: DuplicatesConfig::default(),
326 health: HealthConfig::default(),
327 rules: RulesConfig::default(),
328 production: false,
329 plugins: vec![],
330 overrides: vec![],
331 };
332 let resolved = config.resolve(
333 PathBuf::from("/tmp/test"),
334 OutputFormat::Json,
335 4,
336 false,
337 true,
338 );
339
340 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
341 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
342 assert!(matches!(resolved.output, OutputFormat::Json));
343 assert!(!resolved.no_cache);
344 }
345
346 #[test]
347 fn fallow_config_resolve_cache_dir() {
348 let config = FallowConfig {
349 schema: None,
350 extends: vec![],
351 entry: vec![],
352 ignore_patterns: vec![],
353 framework: vec![],
354 workspaces: None,
355 ignore_dependencies: vec![],
356 ignore_exports: vec![],
357 duplicates: DuplicatesConfig::default(),
358 health: HealthConfig::default(),
359 rules: RulesConfig::default(),
360 production: false,
361 plugins: vec![],
362 overrides: vec![],
363 };
364 let resolved = config.resolve(
365 PathBuf::from("/tmp/project"),
366 OutputFormat::Human,
367 4,
368 true,
369 true,
370 );
371 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
372 assert!(resolved.no_cache);
373 }
374
375 #[test]
376 fn package_json_entry_points_main() {
377 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
378 let entries = pkg.entry_points();
379 assert!(entries.contains(&"dist/index.js".to_string()));
380 }
381
382 #[test]
383 fn package_json_entry_points_module() {
384 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
385 let entries = pkg.entry_points();
386 assert!(entries.contains(&"dist/index.mjs".to_string()));
387 }
388
389 #[test]
390 fn package_json_entry_points_types() {
391 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
392 let entries = pkg.entry_points();
393 assert!(entries.contains(&"dist/index.d.ts".to_string()));
394 }
395
396 #[test]
397 fn package_json_entry_points_bin_string() {
398 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
399 let entries = pkg.entry_points();
400 assert!(entries.contains(&"bin/cli.js".to_string()));
401 }
402
403 #[test]
404 fn package_json_entry_points_bin_object() {
405 let pkg: PackageJson =
406 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
407 .unwrap();
408 let entries = pkg.entry_points();
409 assert!(entries.contains(&"bin/cli.js".to_string()));
410 assert!(entries.contains(&"bin/serve.js".to_string()));
411 }
412
413 #[test]
414 fn package_json_entry_points_exports_string() {
415 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
416 let entries = pkg.entry_points();
417 assert!(entries.contains(&"./dist/index.js".to_string()));
418 }
419
420 #[test]
421 fn package_json_entry_points_exports_object() {
422 let pkg: PackageJson = serde_json::from_str(
423 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
424 )
425 .unwrap();
426 let entries = pkg.entry_points();
427 assert!(entries.contains(&"./dist/index.mjs".to_string()));
428 assert!(entries.contains(&"./dist/index.cjs".to_string()));
429 }
430
431 #[test]
432 fn package_json_dependency_names() {
433 let pkg: PackageJson = serde_json::from_str(
434 r#"{
435 "dependencies": {"react": "^18", "lodash": "^4"},
436 "devDependencies": {"typescript": "^5"},
437 "peerDependencies": {"react-dom": "^18"}
438 }"#,
439 )
440 .unwrap();
441
442 let all = pkg.all_dependency_names();
443 assert!(all.contains(&"react".to_string()));
444 assert!(all.contains(&"lodash".to_string()));
445 assert!(all.contains(&"typescript".to_string()));
446 assert!(all.contains(&"react-dom".to_string()));
447
448 let prod = pkg.production_dependency_names();
449 assert!(prod.contains(&"react".to_string()));
450 assert!(!prod.contains(&"typescript".to_string()));
451
452 let dev = pkg.dev_dependency_names();
453 assert!(dev.contains(&"typescript".to_string()));
454 assert!(!dev.contains(&"react".to_string()));
455 }
456
457 #[test]
458 fn package_json_no_dependencies() {
459 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
460 assert!(pkg.all_dependency_names().is_empty());
461 assert!(pkg.production_dependency_names().is_empty());
462 assert!(pkg.dev_dependency_names().is_empty());
463 assert!(pkg.entry_points().is_empty());
464 }
465
466 #[test]
467 fn rules_deserialize_toml_kebab_case() {
468 let toml_str = r#"
469[rules]
470unused-files = "error"
471unused-exports = "warn"
472unused-types = "off"
473"#;
474 let config: FallowConfig = toml::from_str(toml_str).unwrap();
475 assert_eq!(config.rules.unused_files, Severity::Error);
476 assert_eq!(config.rules.unused_exports, Severity::Warn);
477 assert_eq!(config.rules.unused_types, Severity::Off);
478 assert_eq!(config.rules.unresolved_imports, Severity::Error);
480 }
481
482 #[test]
483 fn config_without_rules_defaults_to_error() {
484 let toml_str = r#"
485entry = ["src/main.ts"]
486"#;
487 let config: FallowConfig = toml::from_str(toml_str).unwrap();
488 assert_eq!(config.rules.unused_files, Severity::Error);
489 assert_eq!(config.rules.unused_exports, Severity::Error);
490 }
491
492 #[test]
493 fn fallow_config_denies_unknown_fields() {
494 let toml_str = r#"
495unknown_field = true
496"#;
497 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
498 assert!(result.is_err());
499 }
500
501 #[test]
502 fn fallow_config_deserialize_json() {
503 let json_str = r#"{"entry": ["src/main.ts"]}"#;
504 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
505 assert_eq!(config.entry, vec!["src/main.ts"]);
506 }
507
508 #[test]
509 fn fallow_config_deserialize_jsonc() {
510 let jsonc_str = r#"{
511 // This is a comment
512 "entry": ["src/main.ts"],
513 "rules": {
514 "unused-files": "warn"
515 }
516 }"#;
517 let mut stripped = String::new();
518 json_comments::StripComments::new(jsonc_str.as_bytes())
519 .read_to_string(&mut stripped)
520 .unwrap();
521 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
522 assert_eq!(config.entry, vec!["src/main.ts"]);
523 assert_eq!(config.rules.unused_files, Severity::Warn);
524 }
525
526 #[test]
527 fn fallow_config_json_with_schema_field() {
528 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "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_json_schema_generation() {
535 let schema = FallowConfig::json_schema();
536 assert!(schema.is_object());
537 let obj = schema.as_object().unwrap();
538 assert!(obj.contains_key("properties"));
539 }
540
541 #[test]
542 fn config_format_detection() {
543 assert!(matches!(
544 ConfigFormat::from_path(Path::new("fallow.toml")),
545 ConfigFormat::Toml
546 ));
547 assert!(matches!(
548 ConfigFormat::from_path(Path::new(".fallowrc.json")),
549 ConfigFormat::Json
550 ));
551 assert!(matches!(
552 ConfigFormat::from_path(Path::new(".fallow.toml")),
553 ConfigFormat::Toml
554 ));
555 }
556
557 #[test]
558 fn config_names_priority_order() {
559 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
560 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
561 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
562 }
563
564 #[test]
565 fn load_json_config_file() {
566 let dir = test_dir("json-config");
567 let config_path = dir.join(".fallowrc.json");
568 std::fs::write(
569 &config_path,
570 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
571 )
572 .unwrap();
573
574 let config = FallowConfig::load(&config_path).unwrap();
575 assert_eq!(config.entry, vec!["src/index.ts"]);
576 assert_eq!(config.rules.unused_exports, Severity::Warn);
577
578 let _ = std::fs::remove_dir_all(&dir);
579 }
580
581 #[test]
582 fn load_jsonc_config_file() {
583 let dir = test_dir("jsonc-config");
584 let config_path = dir.join(".fallowrc.json");
585 std::fs::write(
586 &config_path,
587 r#"{
588 // Entry points for analysis
589 "entry": ["src/index.ts"],
590 /* Block comment */
591 "rules": {
592 "unused-exports": "warn"
593 }
594 }"#,
595 )
596 .unwrap();
597
598 let config = FallowConfig::load(&config_path).unwrap();
599 assert_eq!(config.entry, vec!["src/index.ts"]);
600 assert_eq!(config.rules.unused_exports, Severity::Warn);
601
602 let _ = std::fs::remove_dir_all(&dir);
603 }
604
605 #[test]
606 fn json_config_ignore_dependencies_camel_case() {
607 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
608 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
609 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
610 }
611
612 #[test]
613 fn json_config_all_fields() {
614 let json_str = r#"{
615 "ignoreDependencies": ["lodash"],
616 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
617 "rules": {
618 "unused-files": "off",
619 "unused-exports": "warn",
620 "unused-dependencies": "error",
621 "unused-dev-dependencies": "off",
622 "unused-types": "warn",
623 "unused-enum-members": "error",
624 "unused-class-members": "off",
625 "unresolved-imports": "warn",
626 "unlisted-dependencies": "error",
627 "duplicate-exports": "off"
628 },
629 "duplicates": {
630 "minTokens": 100,
631 "minLines": 10,
632 "skipLocal": true
633 }
634 }"#;
635 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
636 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
637 assert_eq!(config.rules.unused_files, Severity::Off);
638 assert_eq!(config.rules.unused_exports, Severity::Warn);
639 assert_eq!(config.rules.unused_dependencies, Severity::Error);
640 assert_eq!(config.duplicates.min_tokens, 100);
641 assert_eq!(config.duplicates.min_lines, 10);
642 assert!(config.duplicates.skip_local);
643 }
644
645 #[test]
648 fn extends_single_base() {
649 let dir = test_dir("extends-single");
650
651 std::fs::write(
652 dir.join("base.json"),
653 r#"{"rules": {"unused-files": "warn"}}"#,
654 )
655 .unwrap();
656 std::fs::write(
657 dir.join(".fallowrc.json"),
658 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
659 )
660 .unwrap();
661
662 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
663 assert_eq!(config.rules.unused_files, Severity::Warn);
664 assert_eq!(config.entry, vec!["src/index.ts"]);
665 assert_eq!(config.rules.unused_exports, Severity::Error);
667
668 let _ = std::fs::remove_dir_all(&dir);
669 }
670
671 #[test]
672 fn extends_overlay_overrides_base() {
673 let dir = test_dir("extends-overlay");
674
675 std::fs::write(
676 dir.join("base.json"),
677 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
678 )
679 .unwrap();
680 std::fs::write(
681 dir.join(".fallowrc.json"),
682 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
683 )
684 .unwrap();
685
686 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
687 assert_eq!(config.rules.unused_files, Severity::Error);
689 assert_eq!(config.rules.unused_exports, Severity::Off);
691
692 let _ = std::fs::remove_dir_all(&dir);
693 }
694
695 #[test]
696 fn extends_chained() {
697 let dir = test_dir("extends-chained");
698
699 std::fs::write(
700 dir.join("grandparent.json"),
701 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
702 )
703 .unwrap();
704 std::fs::write(
705 dir.join("parent.json"),
706 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
707 )
708 .unwrap();
709 std::fs::write(
710 dir.join(".fallowrc.json"),
711 r#"{"extends": ["parent.json"]}"#,
712 )
713 .unwrap();
714
715 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
716 assert_eq!(config.rules.unused_files, Severity::Warn);
718 assert_eq!(config.rules.unused_exports, Severity::Warn);
720
721 let _ = std::fs::remove_dir_all(&dir);
722 }
723
724 #[test]
725 fn extends_circular_detected() {
726 let dir = test_dir("extends-circular");
727
728 std::fs::write(dir.join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
729 std::fs::write(dir.join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
730
731 let result = FallowConfig::load(&dir.join("a.json"));
732 assert!(result.is_err());
733 let err_msg = format!("{}", result.unwrap_err());
734 assert!(
735 err_msg.contains("Circular extends"),
736 "Expected circular error, got: {err_msg}"
737 );
738
739 let _ = std::fs::remove_dir_all(&dir);
740 }
741
742 #[test]
743 fn extends_missing_file_errors() {
744 let dir = test_dir("extends-missing");
745
746 std::fs::write(
747 dir.join(".fallowrc.json"),
748 r#"{"extends": ["nonexistent.json"]}"#,
749 )
750 .unwrap();
751
752 let result = FallowConfig::load(&dir.join(".fallowrc.json"));
753 assert!(result.is_err());
754 let err_msg = format!("{}", result.unwrap_err());
755 assert!(
756 err_msg.contains("not found"),
757 "Expected not found error, got: {err_msg}"
758 );
759
760 let _ = std::fs::remove_dir_all(&dir);
761 }
762
763 #[test]
764 fn extends_string_sugar() {
765 let dir = test_dir("extends-string");
766
767 std::fs::write(dir.join("base.json"), r#"{"ignorePatterns": ["gen/**"]}"#).unwrap();
768 std::fs::write(dir.join(".fallowrc.json"), r#"{"extends": "base.json"}"#).unwrap();
770
771 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
772 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
773
774 let _ = std::fs::remove_dir_all(&dir);
775 }
776
777 #[test]
778 fn extends_deep_merge_preserves_arrays() {
779 let dir = test_dir("extends-array");
780
781 std::fs::write(dir.join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
782 std::fs::write(
783 dir.join(".fallowrc.json"),
784 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
785 )
786 .unwrap();
787
788 let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
789 assert_eq!(config.entry, vec!["src/b.ts"]);
791
792 let _ = std::fs::remove_dir_all(&dir);
793 }
794}