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 #[must_use]
184 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
185 let mut dir = start;
186 loop {
187 for name in CONFIG_NAMES {
188 let candidate = dir.join(name);
189 if candidate.exists() {
190 return Some(candidate);
191 }
192 }
193 if dir.join(".git").exists() || dir.join("package.json").exists() {
194 break;
195 }
196 dir = dir.parent()?;
197 }
198 None
199 }
200
201 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
207 let mut dir = start;
208 loop {
209 for name in CONFIG_NAMES {
210 let candidate = dir.join(name);
211 if candidate.exists() {
212 match Self::load(&candidate) {
213 Ok(config) => return Ok(Some((config, candidate))),
214 Err(e) => {
215 return Err(format!("Failed to parse {}: {e}", candidate.display()));
216 }
217 }
218 }
219 }
220 if dir.join(".git").exists() || dir.join("package.json").exists() {
222 break;
223 }
224 dir = match dir.parent() {
225 Some(parent) => parent,
226 None => break,
227 };
228 }
229 Ok(None)
230 }
231
232 #[must_use]
234 pub fn json_schema() -> serde_json::Value {
235 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use std::io::Read as _;
242
243 use super::*;
244 use crate::PackageJson;
245 use crate::config::duplicates_config::DuplicatesConfig;
246 use crate::config::format::OutputFormat;
247 use crate::config::health::HealthConfig;
248 use crate::config::rules::{RulesConfig, Severity};
249
250 fn test_dir(_name: &str) -> tempfile::TempDir {
252 tempfile::tempdir().expect("create temp dir")
253 }
254
255 #[test]
256 fn fallow_config_deserialize_minimal() {
257 let toml_str = r#"
258entry = ["src/main.ts"]
259"#;
260 let config: FallowConfig = toml::from_str(toml_str).unwrap();
261 assert_eq!(config.entry, vec!["src/main.ts"]);
262 assert!(config.ignore_patterns.is_empty());
263 }
264
265 #[test]
266 fn fallow_config_deserialize_ignore_exports() {
267 let toml_str = r#"
268[[ignoreExports]]
269file = "src/types/*.ts"
270exports = ["*"]
271
272[[ignoreExports]]
273file = "src/constants.ts"
274exports = ["FOO", "BAR"]
275"#;
276 let config: FallowConfig = toml::from_str(toml_str).unwrap();
277 assert_eq!(config.ignore_exports.len(), 2);
278 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
279 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
280 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
281 }
282
283 #[test]
284 fn fallow_config_deserialize_ignore_dependencies() {
285 let toml_str = r#"
286ignoreDependencies = ["autoprefixer", "postcss"]
287"#;
288 let config: FallowConfig = toml::from_str(toml_str).unwrap();
289 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
290 }
291
292 #[test]
293 fn fallow_config_resolve_default_ignores() {
294 let config = FallowConfig {
295 schema: None,
296 extends: vec![],
297 entry: vec![],
298 ignore_patterns: vec![],
299 framework: vec![],
300 workspaces: None,
301 ignore_dependencies: vec![],
302 ignore_exports: vec![],
303 duplicates: DuplicatesConfig::default(),
304 health: HealthConfig::default(),
305 rules: RulesConfig::default(),
306 production: false,
307 plugins: vec![],
308 overrides: vec![],
309 regression: None,
310 };
311 let resolved = config.resolve(
312 PathBuf::from("/tmp/test"),
313 OutputFormat::Human,
314 4,
315 true,
316 true,
317 );
318
319 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
321 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
322 assert!(resolved.ignore_patterns.is_match("build/output.js"));
323 assert!(resolved.ignore_patterns.is_match(".git/config"));
324 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
325 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
326 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
327 }
328
329 #[test]
330 fn fallow_config_resolve_custom_ignores() {
331 let config = FallowConfig {
332 schema: None,
333 extends: vec![],
334 entry: vec!["src/**/*.ts".to_string()],
335 ignore_patterns: vec!["**/*.generated.ts".to_string()],
336 framework: vec![],
337 workspaces: None,
338 ignore_dependencies: vec![],
339 ignore_exports: vec![],
340 duplicates: DuplicatesConfig::default(),
341 health: HealthConfig::default(),
342 rules: RulesConfig::default(),
343 production: false,
344 plugins: vec![],
345 overrides: vec![],
346 regression: None,
347 };
348 let resolved = config.resolve(
349 PathBuf::from("/tmp/test"),
350 OutputFormat::Json,
351 4,
352 false,
353 true,
354 );
355
356 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
357 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
358 assert!(matches!(resolved.output, OutputFormat::Json));
359 assert!(!resolved.no_cache);
360 }
361
362 #[test]
363 fn fallow_config_resolve_cache_dir() {
364 let config = FallowConfig {
365 schema: None,
366 extends: vec![],
367 entry: vec![],
368 ignore_patterns: vec![],
369 framework: vec![],
370 workspaces: None,
371 ignore_dependencies: vec![],
372 ignore_exports: vec![],
373 duplicates: DuplicatesConfig::default(),
374 health: HealthConfig::default(),
375 rules: RulesConfig::default(),
376 production: false,
377 plugins: vec![],
378 overrides: vec![],
379 regression: None,
380 };
381 let resolved = config.resolve(
382 PathBuf::from("/tmp/project"),
383 OutputFormat::Human,
384 4,
385 true,
386 true,
387 );
388 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
389 assert!(resolved.no_cache);
390 }
391
392 #[test]
393 fn package_json_entry_points_main() {
394 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
395 let entries = pkg.entry_points();
396 assert!(entries.contains(&"dist/index.js".to_string()));
397 }
398
399 #[test]
400 fn package_json_entry_points_module() {
401 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
402 let entries = pkg.entry_points();
403 assert!(entries.contains(&"dist/index.mjs".to_string()));
404 }
405
406 #[test]
407 fn package_json_entry_points_types() {
408 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
409 let entries = pkg.entry_points();
410 assert!(entries.contains(&"dist/index.d.ts".to_string()));
411 }
412
413 #[test]
414 fn package_json_entry_points_bin_string() {
415 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
416 let entries = pkg.entry_points();
417 assert!(entries.contains(&"bin/cli.js".to_string()));
418 }
419
420 #[test]
421 fn package_json_entry_points_bin_object() {
422 let pkg: PackageJson =
423 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
424 .unwrap();
425 let entries = pkg.entry_points();
426 assert!(entries.contains(&"bin/cli.js".to_string()));
427 assert!(entries.contains(&"bin/serve.js".to_string()));
428 }
429
430 #[test]
431 fn package_json_entry_points_exports_string() {
432 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
433 let entries = pkg.entry_points();
434 assert!(entries.contains(&"./dist/index.js".to_string()));
435 }
436
437 #[test]
438 fn package_json_entry_points_exports_object() {
439 let pkg: PackageJson = serde_json::from_str(
440 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
441 )
442 .unwrap();
443 let entries = pkg.entry_points();
444 assert!(entries.contains(&"./dist/index.mjs".to_string()));
445 assert!(entries.contains(&"./dist/index.cjs".to_string()));
446 }
447
448 #[test]
449 fn package_json_dependency_names() {
450 let pkg: PackageJson = serde_json::from_str(
451 r#"{
452 "dependencies": {"react": "^18", "lodash": "^4"},
453 "devDependencies": {"typescript": "^5"},
454 "peerDependencies": {"react-dom": "^18"}
455 }"#,
456 )
457 .unwrap();
458
459 let all = pkg.all_dependency_names();
460 assert!(all.contains(&"react".to_string()));
461 assert!(all.contains(&"lodash".to_string()));
462 assert!(all.contains(&"typescript".to_string()));
463 assert!(all.contains(&"react-dom".to_string()));
464
465 let prod = pkg.production_dependency_names();
466 assert!(prod.contains(&"react".to_string()));
467 assert!(!prod.contains(&"typescript".to_string()));
468
469 let dev = pkg.dev_dependency_names();
470 assert!(dev.contains(&"typescript".to_string()));
471 assert!(!dev.contains(&"react".to_string()));
472 }
473
474 #[test]
475 fn package_json_no_dependencies() {
476 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
477 assert!(pkg.all_dependency_names().is_empty());
478 assert!(pkg.production_dependency_names().is_empty());
479 assert!(pkg.dev_dependency_names().is_empty());
480 assert!(pkg.entry_points().is_empty());
481 }
482
483 #[test]
484 fn rules_deserialize_toml_kebab_case() {
485 let toml_str = r#"
486[rules]
487unused-files = "error"
488unused-exports = "warn"
489unused-types = "off"
490"#;
491 let config: FallowConfig = toml::from_str(toml_str).unwrap();
492 assert_eq!(config.rules.unused_files, Severity::Error);
493 assert_eq!(config.rules.unused_exports, Severity::Warn);
494 assert_eq!(config.rules.unused_types, Severity::Off);
495 assert_eq!(config.rules.unresolved_imports, Severity::Error);
497 }
498
499 #[test]
500 fn config_without_rules_defaults_to_error() {
501 let toml_str = r#"
502entry = ["src/main.ts"]
503"#;
504 let config: FallowConfig = toml::from_str(toml_str).unwrap();
505 assert_eq!(config.rules.unused_files, Severity::Error);
506 assert_eq!(config.rules.unused_exports, Severity::Error);
507 }
508
509 #[test]
510 fn fallow_config_denies_unknown_fields() {
511 let toml_str = r"
512unknown_field = true
513";
514 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
515 assert!(result.is_err());
516 }
517
518 #[test]
519 fn fallow_config_deserialize_json() {
520 let json_str = r#"{"entry": ["src/main.ts"]}"#;
521 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
522 assert_eq!(config.entry, vec!["src/main.ts"]);
523 }
524
525 #[test]
526 fn fallow_config_deserialize_jsonc() {
527 let jsonc_str = r#"{
528 // This is a comment
529 "entry": ["src/main.ts"],
530 "rules": {
531 "unused-files": "warn"
532 }
533 }"#;
534 let mut stripped = String::new();
535 json_comments::StripComments::new(jsonc_str.as_bytes())
536 .read_to_string(&mut stripped)
537 .unwrap();
538 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
539 assert_eq!(config.entry, vec!["src/main.ts"]);
540 assert_eq!(config.rules.unused_files, Severity::Warn);
541 }
542
543 #[test]
544 fn fallow_config_json_with_schema_field() {
545 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
546 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
547 assert_eq!(config.entry, vec!["src/main.ts"]);
548 }
549
550 #[test]
551 fn fallow_config_json_schema_generation() {
552 let schema = FallowConfig::json_schema();
553 assert!(schema.is_object());
554 let obj = schema.as_object().unwrap();
555 assert!(obj.contains_key("properties"));
556 }
557
558 #[test]
559 fn config_format_detection() {
560 assert!(matches!(
561 ConfigFormat::from_path(Path::new("fallow.toml")),
562 ConfigFormat::Toml
563 ));
564 assert!(matches!(
565 ConfigFormat::from_path(Path::new(".fallowrc.json")),
566 ConfigFormat::Json
567 ));
568 assert!(matches!(
569 ConfigFormat::from_path(Path::new(".fallow.toml")),
570 ConfigFormat::Toml
571 ));
572 }
573
574 #[test]
575 fn config_names_priority_order() {
576 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
577 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
578 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
579 }
580
581 #[test]
582 fn load_json_config_file() {
583 let dir = test_dir("json-config");
584 let config_path = dir.path().join(".fallowrc.json");
585 std::fs::write(
586 &config_path,
587 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
588 )
589 .unwrap();
590
591 let config = FallowConfig::load(&config_path).unwrap();
592 assert_eq!(config.entry, vec!["src/index.ts"]);
593 assert_eq!(config.rules.unused_exports, Severity::Warn);
594 }
595
596 #[test]
597 fn load_jsonc_config_file() {
598 let dir = test_dir("jsonc-config");
599 let config_path = dir.path().join(".fallowrc.json");
600 std::fs::write(
601 &config_path,
602 r#"{
603 // Entry points for analysis
604 "entry": ["src/index.ts"],
605 /* Block comment */
606 "rules": {
607 "unused-exports": "warn"
608 }
609 }"#,
610 )
611 .unwrap();
612
613 let config = FallowConfig::load(&config_path).unwrap();
614 assert_eq!(config.entry, vec!["src/index.ts"]);
615 assert_eq!(config.rules.unused_exports, Severity::Warn);
616 }
617
618 #[test]
619 fn json_config_ignore_dependencies_camel_case() {
620 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
621 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
622 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
623 }
624
625 #[test]
626 fn json_config_all_fields() {
627 let json_str = r#"{
628 "ignoreDependencies": ["lodash"],
629 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
630 "rules": {
631 "unused-files": "off",
632 "unused-exports": "warn",
633 "unused-dependencies": "error",
634 "unused-dev-dependencies": "off",
635 "unused-types": "warn",
636 "unused-enum-members": "error",
637 "unused-class-members": "off",
638 "unresolved-imports": "warn",
639 "unlisted-dependencies": "error",
640 "duplicate-exports": "off"
641 },
642 "duplicates": {
643 "minTokens": 100,
644 "minLines": 10,
645 "skipLocal": true
646 }
647 }"#;
648 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
649 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
650 assert_eq!(config.rules.unused_files, Severity::Off);
651 assert_eq!(config.rules.unused_exports, Severity::Warn);
652 assert_eq!(config.rules.unused_dependencies, Severity::Error);
653 assert_eq!(config.duplicates.min_tokens, 100);
654 assert_eq!(config.duplicates.min_lines, 10);
655 assert!(config.duplicates.skip_local);
656 }
657
658 #[test]
661 fn extends_single_base() {
662 let dir = test_dir("extends-single");
663
664 std::fs::write(
665 dir.path().join("base.json"),
666 r#"{"rules": {"unused-files": "warn"}}"#,
667 )
668 .unwrap();
669 std::fs::write(
670 dir.path().join(".fallowrc.json"),
671 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
672 )
673 .unwrap();
674
675 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
676 assert_eq!(config.rules.unused_files, Severity::Warn);
677 assert_eq!(config.entry, vec!["src/index.ts"]);
678 assert_eq!(config.rules.unused_exports, Severity::Error);
680 }
681
682 #[test]
683 fn extends_overlay_overrides_base() {
684 let dir = test_dir("extends-overlay");
685
686 std::fs::write(
687 dir.path().join("base.json"),
688 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
689 )
690 .unwrap();
691 std::fs::write(
692 dir.path().join(".fallowrc.json"),
693 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
694 )
695 .unwrap();
696
697 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
698 assert_eq!(config.rules.unused_files, Severity::Error);
700 assert_eq!(config.rules.unused_exports, Severity::Off);
702 }
703
704 #[test]
705 fn extends_chained() {
706 let dir = test_dir("extends-chained");
707
708 std::fs::write(
709 dir.path().join("grandparent.json"),
710 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
711 )
712 .unwrap();
713 std::fs::write(
714 dir.path().join("parent.json"),
715 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
716 )
717 .unwrap();
718 std::fs::write(
719 dir.path().join(".fallowrc.json"),
720 r#"{"extends": ["parent.json"]}"#,
721 )
722 .unwrap();
723
724 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
725 assert_eq!(config.rules.unused_files, Severity::Warn);
727 assert_eq!(config.rules.unused_exports, Severity::Warn);
729 }
730
731 #[test]
732 fn extends_circular_detected() {
733 let dir = test_dir("extends-circular");
734
735 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
736 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
737
738 let result = FallowConfig::load(&dir.path().join("a.json"));
739 assert!(result.is_err());
740 let err_msg = format!("{}", result.unwrap_err());
741 assert!(
742 err_msg.contains("Circular extends"),
743 "Expected circular error, got: {err_msg}"
744 );
745 }
746
747 #[test]
748 fn extends_missing_file_errors() {
749 let dir = test_dir("extends-missing");
750
751 std::fs::write(
752 dir.path().join(".fallowrc.json"),
753 r#"{"extends": ["nonexistent.json"]}"#,
754 )
755 .unwrap();
756
757 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
758 assert!(result.is_err());
759 let err_msg = format!("{}", result.unwrap_err());
760 assert!(
761 err_msg.contains("not found"),
762 "Expected not found error, got: {err_msg}"
763 );
764 }
765
766 #[test]
767 fn extends_string_sugar() {
768 let dir = test_dir("extends-string");
769
770 std::fs::write(
771 dir.path().join("base.json"),
772 r#"{"ignorePatterns": ["gen/**"]}"#,
773 )
774 .unwrap();
775 std::fs::write(
777 dir.path().join(".fallowrc.json"),
778 r#"{"extends": "base.json"}"#,
779 )
780 .unwrap();
781
782 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
783 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
784 }
785
786 #[test]
787 fn extends_deep_merge_preserves_arrays() {
788 let dir = test_dir("extends-array");
789
790 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
791 std::fs::write(
792 dir.path().join(".fallowrc.json"),
793 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
794 )
795 .unwrap();
796
797 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
798 assert_eq!(config.entry, vec!["src/b.ts"]);
800 }
801
802 #[test]
805 fn deep_merge_scalar_overlay_replaces_base() {
806 let mut base = serde_json::json!("hello");
807 deep_merge_json(&mut base, serde_json::json!("world"));
808 assert_eq!(base, serde_json::json!("world"));
809 }
810
811 #[test]
812 fn deep_merge_array_overlay_replaces_base() {
813 let mut base = serde_json::json!(["a", "b"]);
814 deep_merge_json(&mut base, serde_json::json!(["c"]));
815 assert_eq!(base, serde_json::json!(["c"]));
816 }
817
818 #[test]
819 fn deep_merge_nested_object_merge() {
820 let mut base = serde_json::json!({
821 "level1": {
822 "level2": {
823 "a": 1,
824 "b": 2
825 }
826 }
827 });
828 let overlay = serde_json::json!({
829 "level1": {
830 "level2": {
831 "b": 99,
832 "c": 3
833 }
834 }
835 });
836 deep_merge_json(&mut base, overlay);
837 assert_eq!(base["level1"]["level2"]["a"], 1);
838 assert_eq!(base["level1"]["level2"]["b"], 99);
839 assert_eq!(base["level1"]["level2"]["c"], 3);
840 }
841
842 #[test]
843 fn deep_merge_overlay_adds_new_fields() {
844 let mut base = serde_json::json!({"existing": true});
845 let overlay = serde_json::json!({"new_field": "added", "another": 42});
846 deep_merge_json(&mut base, overlay);
847 assert_eq!(base["existing"], true);
848 assert_eq!(base["new_field"], "added");
849 assert_eq!(base["another"], 42);
850 }
851
852 #[test]
853 fn deep_merge_null_overlay_replaces_object() {
854 let mut base = serde_json::json!({"key": "value"});
855 deep_merge_json(&mut base, serde_json::json!(null));
856 assert_eq!(base, serde_json::json!(null));
857 }
858
859 #[test]
860 fn deep_merge_empty_object_overlay_preserves_base() {
861 let mut base = serde_json::json!({"a": 1, "b": 2});
862 deep_merge_json(&mut base, serde_json::json!({}));
863 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
864 }
865
866 #[test]
869 fn rules_severity_error_warn_off_from_json() {
870 let json_str = r#"{
871 "rules": {
872 "unused-files": "error",
873 "unused-exports": "warn",
874 "unused-types": "off"
875 }
876 }"#;
877 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
878 assert_eq!(config.rules.unused_files, Severity::Error);
879 assert_eq!(config.rules.unused_exports, Severity::Warn);
880 assert_eq!(config.rules.unused_types, Severity::Off);
881 }
882
883 #[test]
884 fn rules_omitted_default_to_error() {
885 let json_str = r#"{
886 "rules": {
887 "unused-files": "warn"
888 }
889 }"#;
890 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
891 assert_eq!(config.rules.unused_files, Severity::Warn);
892 assert_eq!(config.rules.unused_exports, Severity::Error);
894 assert_eq!(config.rules.unused_types, Severity::Error);
895 assert_eq!(config.rules.unused_dependencies, Severity::Error);
896 assert_eq!(config.rules.unresolved_imports, Severity::Error);
897 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
898 assert_eq!(config.rules.duplicate_exports, Severity::Error);
899 assert_eq!(config.rules.circular_dependencies, Severity::Error);
900 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
902 }
903
904 #[test]
907 fn find_and_load_returns_none_when_no_config() {
908 let dir = test_dir("find-none");
909 std::fs::create_dir(dir.path().join(".git")).unwrap();
911
912 let result = FallowConfig::find_and_load(dir.path()).unwrap();
913 assert!(result.is_none());
914 }
915
916 #[test]
917 fn find_and_load_finds_fallowrc_json() {
918 let dir = test_dir("find-json");
919 std::fs::create_dir(dir.path().join(".git")).unwrap();
920 std::fs::write(
921 dir.path().join(".fallowrc.json"),
922 r#"{"entry": ["src/main.ts"]}"#,
923 )
924 .unwrap();
925
926 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
927 assert_eq!(config.entry, vec!["src/main.ts"]);
928 assert!(path.ends_with(".fallowrc.json"));
929 }
930
931 #[test]
932 fn find_and_load_prefers_fallowrc_json_over_toml() {
933 let dir = test_dir("find-priority");
934 std::fs::create_dir(dir.path().join(".git")).unwrap();
935 std::fs::write(
936 dir.path().join(".fallowrc.json"),
937 r#"{"entry": ["from-json.ts"]}"#,
938 )
939 .unwrap();
940 std::fs::write(
941 dir.path().join("fallow.toml"),
942 "entry = [\"from-toml.ts\"]\n",
943 )
944 .unwrap();
945
946 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
947 assert_eq!(config.entry, vec!["from-json.ts"]);
948 assert!(path.ends_with(".fallowrc.json"));
949 }
950
951 #[test]
952 fn find_and_load_finds_fallow_toml() {
953 let dir = test_dir("find-toml");
954 std::fs::create_dir(dir.path().join(".git")).unwrap();
955 std::fs::write(
956 dir.path().join("fallow.toml"),
957 "entry = [\"src/index.ts\"]\n",
958 )
959 .unwrap();
960
961 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
962 assert_eq!(config.entry, vec!["src/index.ts"]);
963 }
964
965 #[test]
966 fn find_and_load_stops_at_git_dir() {
967 let dir = test_dir("find-git-stop");
968 let sub = dir.path().join("sub");
969 std::fs::create_dir(&sub).unwrap();
970 std::fs::create_dir(dir.path().join(".git")).unwrap();
972 let result = FallowConfig::find_and_load(&sub).unwrap();
976 assert!(result.is_none());
977 }
978
979 #[test]
980 fn find_and_load_stops_at_package_json() {
981 let dir = test_dir("find-pkg-stop");
982 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
983
984 let result = FallowConfig::find_and_load(dir.path()).unwrap();
985 assert!(result.is_none());
986 }
987
988 #[test]
989 fn find_and_load_returns_error_for_invalid_config() {
990 let dir = test_dir("find-invalid");
991 std::fs::create_dir(dir.path().join(".git")).unwrap();
992 std::fs::write(
993 dir.path().join(".fallowrc.json"),
994 r"{ this is not valid json }",
995 )
996 .unwrap();
997
998 let result = FallowConfig::find_and_load(dir.path());
999 assert!(result.is_err());
1000 }
1001
1002 #[test]
1005 fn load_toml_config_file() {
1006 let dir = test_dir("toml-config");
1007 let config_path = dir.path().join("fallow.toml");
1008 std::fs::write(
1009 &config_path,
1010 r#"
1011entry = ["src/index.ts"]
1012ignorePatterns = ["dist/**"]
1013
1014[rules]
1015unused-files = "warn"
1016
1017[duplicates]
1018minTokens = 100
1019"#,
1020 )
1021 .unwrap();
1022
1023 let config = FallowConfig::load(&config_path).unwrap();
1024 assert_eq!(config.entry, vec!["src/index.ts"]);
1025 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1026 assert_eq!(config.rules.unused_files, Severity::Warn);
1027 assert_eq!(config.duplicates.min_tokens, 100);
1028 }
1029
1030 #[test]
1033 fn extends_absolute_path_rejected() {
1034 let dir = test_dir("extends-absolute");
1035
1036 #[cfg(unix)]
1038 let abs_path = "/absolute/path/config.json";
1039 #[cfg(windows)]
1040 let abs_path = "C:\\absolute\\path\\config.json";
1041
1042 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1043 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1044
1045 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1046 assert!(result.is_err());
1047 let err_msg = format!("{}", result.unwrap_err());
1048 assert!(
1049 err_msg.contains("must be relative"),
1050 "Expected 'must be relative' error, got: {err_msg}"
1051 );
1052 }
1053
1054 #[test]
1057 fn resolve_production_mode_disables_dev_deps() {
1058 let config = FallowConfig {
1059 schema: None,
1060 extends: vec![],
1061 entry: vec![],
1062 ignore_patterns: vec![],
1063 framework: vec![],
1064 workspaces: None,
1065 ignore_dependencies: vec![],
1066 ignore_exports: vec![],
1067 duplicates: DuplicatesConfig::default(),
1068 health: HealthConfig::default(),
1069 rules: RulesConfig::default(),
1070 production: true,
1071 plugins: vec![],
1072 overrides: vec![],
1073 regression: None,
1074 };
1075 let resolved = config.resolve(
1076 PathBuf::from("/tmp/test"),
1077 OutputFormat::Human,
1078 4,
1079 false,
1080 true,
1081 );
1082 assert!(resolved.production);
1083 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1084 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1085 assert_eq!(resolved.rules.unused_files, Severity::Error);
1087 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1088 }
1089
1090 #[test]
1093 fn config_format_defaults_to_toml_for_unknown() {
1094 assert!(matches!(
1095 ConfigFormat::from_path(Path::new("config.yaml")),
1096 ConfigFormat::Toml
1097 ));
1098 assert!(matches!(
1099 ConfigFormat::from_path(Path::new("config")),
1100 ConfigFormat::Toml
1101 ));
1102 }
1103
1104 #[test]
1107 fn deep_merge_object_over_scalar_replaces() {
1108 let mut base = serde_json::json!("just a string");
1109 let overlay = serde_json::json!({"key": "value"});
1110 deep_merge_json(&mut base, overlay);
1111 assert_eq!(base, serde_json::json!({"key": "value"}));
1112 }
1113
1114 #[test]
1115 fn deep_merge_scalar_over_object_replaces() {
1116 let mut base = serde_json::json!({"key": "value"});
1117 let overlay = serde_json::json!(42);
1118 deep_merge_json(&mut base, overlay);
1119 assert_eq!(base, serde_json::json!(42));
1120 }
1121
1122 #[test]
1125 fn extends_non_string_non_array_ignored() {
1126 let dir = test_dir("extends-numeric");
1127 std::fs::write(
1128 dir.path().join(".fallowrc.json"),
1129 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1130 )
1131 .unwrap();
1132
1133 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1135 assert_eq!(config.entry, vec!["src/index.ts"]);
1136 }
1137
1138 #[test]
1141 fn extends_multiple_bases_later_wins() {
1142 let dir = test_dir("extends-multi-base");
1143
1144 std::fs::write(
1145 dir.path().join("base-a.json"),
1146 r#"{"rules": {"unused-files": "warn"}}"#,
1147 )
1148 .unwrap();
1149 std::fs::write(
1150 dir.path().join("base-b.json"),
1151 r#"{"rules": {"unused-files": "off"}}"#,
1152 )
1153 .unwrap();
1154 std::fs::write(
1155 dir.path().join(".fallowrc.json"),
1156 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1157 )
1158 .unwrap();
1159
1160 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1161 assert_eq!(config.rules.unused_files, Severity::Off);
1163 }
1164
1165 #[test]
1168 fn fallow_config_deserialize_production() {
1169 let json_str = r#"{"production": true}"#;
1170 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1171 assert!(config.production);
1172 }
1173
1174 #[test]
1175 fn fallow_config_production_defaults_false() {
1176 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1177 assert!(!config.production);
1178 }
1179
1180 #[test]
1183 fn package_json_optional_dependency_names() {
1184 let pkg: PackageJson = serde_json::from_str(
1185 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1186 )
1187 .unwrap();
1188 let opt = pkg.optional_dependency_names();
1189 assert_eq!(opt.len(), 2);
1190 assert!(opt.contains(&"fsevents".to_string()));
1191 assert!(opt.contains(&"chokidar".to_string()));
1192 }
1193
1194 #[test]
1195 fn package_json_optional_deps_empty_when_missing() {
1196 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1197 assert!(pkg.optional_dependency_names().is_empty());
1198 }
1199}