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