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::boundaries::BoundaryConfig;
246 use crate::config::duplicates_config::DuplicatesConfig;
247 use crate::config::format::OutputFormat;
248 use crate::config::health::HealthConfig;
249 use crate::config::rules::{RulesConfig, Severity};
250
251 fn test_dir(_name: &str) -> tempfile::TempDir {
253 tempfile::tempdir().expect("create temp dir")
254 }
255
256 #[test]
257 fn fallow_config_deserialize_minimal() {
258 let toml_str = r#"
259entry = ["src/main.ts"]
260"#;
261 let config: FallowConfig = toml::from_str(toml_str).unwrap();
262 assert_eq!(config.entry, vec!["src/main.ts"]);
263 assert!(config.ignore_patterns.is_empty());
264 }
265
266 #[test]
267 fn fallow_config_deserialize_ignore_exports() {
268 let toml_str = r#"
269[[ignoreExports]]
270file = "src/types/*.ts"
271exports = ["*"]
272
273[[ignoreExports]]
274file = "src/constants.ts"
275exports = ["FOO", "BAR"]
276"#;
277 let config: FallowConfig = toml::from_str(toml_str).unwrap();
278 assert_eq!(config.ignore_exports.len(), 2);
279 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
280 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
281 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
282 }
283
284 #[test]
285 fn fallow_config_deserialize_ignore_dependencies() {
286 let toml_str = r#"
287ignoreDependencies = ["autoprefixer", "postcss"]
288"#;
289 let config: FallowConfig = toml::from_str(toml_str).unwrap();
290 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
291 }
292
293 #[test]
294 fn fallow_config_resolve_default_ignores() {
295 let config = FallowConfig {
296 schema: None,
297 extends: vec![],
298 entry: vec![],
299 ignore_patterns: vec![],
300 framework: vec![],
301 workspaces: None,
302 ignore_dependencies: vec![],
303 ignore_exports: vec![],
304 duplicates: DuplicatesConfig::default(),
305 health: HealthConfig::default(),
306 rules: RulesConfig::default(),
307 boundaries: BoundaryConfig::default(),
308 production: false,
309 plugins: vec![],
310 overrides: vec![],
311 regression: None,
312 };
313 let resolved = config.resolve(
314 PathBuf::from("/tmp/test"),
315 OutputFormat::Human,
316 4,
317 true,
318 true,
319 );
320
321 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
323 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
324 assert!(resolved.ignore_patterns.is_match("build/output.js"));
325 assert!(resolved.ignore_patterns.is_match(".git/config"));
326 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
327 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
328 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
329 }
330
331 #[test]
332 fn fallow_config_resolve_custom_ignores() {
333 let config = FallowConfig {
334 schema: None,
335 extends: vec![],
336 entry: vec!["src/**/*.ts".to_string()],
337 ignore_patterns: vec!["**/*.generated.ts".to_string()],
338 framework: vec![],
339 workspaces: None,
340 ignore_dependencies: vec![],
341 ignore_exports: vec![],
342 duplicates: DuplicatesConfig::default(),
343 health: HealthConfig::default(),
344 rules: RulesConfig::default(),
345 boundaries: BoundaryConfig::default(),
346 production: false,
347 plugins: vec![],
348 overrides: vec![],
349 regression: None,
350 };
351 let resolved = config.resolve(
352 PathBuf::from("/tmp/test"),
353 OutputFormat::Json,
354 4,
355 false,
356 true,
357 );
358
359 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
360 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
361 assert!(matches!(resolved.output, OutputFormat::Json));
362 assert!(!resolved.no_cache);
363 }
364
365 #[test]
366 fn fallow_config_resolve_cache_dir() {
367 let config = FallowConfig {
368 schema: None,
369 extends: vec![],
370 entry: vec![],
371 ignore_patterns: vec![],
372 framework: vec![],
373 workspaces: None,
374 ignore_dependencies: vec![],
375 ignore_exports: vec![],
376 duplicates: DuplicatesConfig::default(),
377 health: HealthConfig::default(),
378 rules: RulesConfig::default(),
379 boundaries: BoundaryConfig::default(),
380 production: false,
381 plugins: vec![],
382 overrides: vec![],
383 regression: None,
384 };
385 let resolved = config.resolve(
386 PathBuf::from("/tmp/project"),
387 OutputFormat::Human,
388 4,
389 true,
390 true,
391 );
392 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
393 assert!(resolved.no_cache);
394 }
395
396 #[test]
397 fn package_json_entry_points_main() {
398 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
399 let entries = pkg.entry_points();
400 assert!(entries.contains(&"dist/index.js".to_string()));
401 }
402
403 #[test]
404 fn package_json_entry_points_module() {
405 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
406 let entries = pkg.entry_points();
407 assert!(entries.contains(&"dist/index.mjs".to_string()));
408 }
409
410 #[test]
411 fn package_json_entry_points_types() {
412 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
413 let entries = pkg.entry_points();
414 assert!(entries.contains(&"dist/index.d.ts".to_string()));
415 }
416
417 #[test]
418 fn package_json_entry_points_bin_string() {
419 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
420 let entries = pkg.entry_points();
421 assert!(entries.contains(&"bin/cli.js".to_string()));
422 }
423
424 #[test]
425 fn package_json_entry_points_bin_object() {
426 let pkg: PackageJson =
427 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
428 .unwrap();
429 let entries = pkg.entry_points();
430 assert!(entries.contains(&"bin/cli.js".to_string()));
431 assert!(entries.contains(&"bin/serve.js".to_string()));
432 }
433
434 #[test]
435 fn package_json_entry_points_exports_string() {
436 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
437 let entries = pkg.entry_points();
438 assert!(entries.contains(&"./dist/index.js".to_string()));
439 }
440
441 #[test]
442 fn package_json_entry_points_exports_object() {
443 let pkg: PackageJson = serde_json::from_str(
444 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
445 )
446 .unwrap();
447 let entries = pkg.entry_points();
448 assert!(entries.contains(&"./dist/index.mjs".to_string()));
449 assert!(entries.contains(&"./dist/index.cjs".to_string()));
450 }
451
452 #[test]
453 fn package_json_dependency_names() {
454 let pkg: PackageJson = serde_json::from_str(
455 r#"{
456 "dependencies": {"react": "^18", "lodash": "^4"},
457 "devDependencies": {"typescript": "^5"},
458 "peerDependencies": {"react-dom": "^18"}
459 }"#,
460 )
461 .unwrap();
462
463 let all = pkg.all_dependency_names();
464 assert!(all.contains(&"react".to_string()));
465 assert!(all.contains(&"lodash".to_string()));
466 assert!(all.contains(&"typescript".to_string()));
467 assert!(all.contains(&"react-dom".to_string()));
468
469 let prod = pkg.production_dependency_names();
470 assert!(prod.contains(&"react".to_string()));
471 assert!(!prod.contains(&"typescript".to_string()));
472
473 let dev = pkg.dev_dependency_names();
474 assert!(dev.contains(&"typescript".to_string()));
475 assert!(!dev.contains(&"react".to_string()));
476 }
477
478 #[test]
479 fn package_json_no_dependencies() {
480 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
481 assert!(pkg.all_dependency_names().is_empty());
482 assert!(pkg.production_dependency_names().is_empty());
483 assert!(pkg.dev_dependency_names().is_empty());
484 assert!(pkg.entry_points().is_empty());
485 }
486
487 #[test]
488 fn rules_deserialize_toml_kebab_case() {
489 let toml_str = r#"
490[rules]
491unused-files = "error"
492unused-exports = "warn"
493unused-types = "off"
494"#;
495 let config: FallowConfig = toml::from_str(toml_str).unwrap();
496 assert_eq!(config.rules.unused_files, Severity::Error);
497 assert_eq!(config.rules.unused_exports, Severity::Warn);
498 assert_eq!(config.rules.unused_types, Severity::Off);
499 assert_eq!(config.rules.unresolved_imports, Severity::Error);
501 }
502
503 #[test]
504 fn config_without_rules_defaults_to_error() {
505 let toml_str = r#"
506entry = ["src/main.ts"]
507"#;
508 let config: FallowConfig = toml::from_str(toml_str).unwrap();
509 assert_eq!(config.rules.unused_files, Severity::Error);
510 assert_eq!(config.rules.unused_exports, Severity::Error);
511 }
512
513 #[test]
514 fn fallow_config_denies_unknown_fields() {
515 let toml_str = r"
516unknown_field = true
517";
518 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
519 assert!(result.is_err());
520 }
521
522 #[test]
523 fn fallow_config_deserialize_json() {
524 let json_str = r#"{"entry": ["src/main.ts"]}"#;
525 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
526 assert_eq!(config.entry, vec!["src/main.ts"]);
527 }
528
529 #[test]
530 fn fallow_config_deserialize_jsonc() {
531 let jsonc_str = r#"{
532 // This is a comment
533 "entry": ["src/main.ts"],
534 "rules": {
535 "unused-files": "warn"
536 }
537 }"#;
538 let mut stripped = String::new();
539 json_comments::StripComments::new(jsonc_str.as_bytes())
540 .read_to_string(&mut stripped)
541 .unwrap();
542 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
543 assert_eq!(config.entry, vec!["src/main.ts"]);
544 assert_eq!(config.rules.unused_files, Severity::Warn);
545 }
546
547 #[test]
548 fn fallow_config_json_with_schema_field() {
549 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
550 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
551 assert_eq!(config.entry, vec!["src/main.ts"]);
552 }
553
554 #[test]
555 fn fallow_config_json_schema_generation() {
556 let schema = FallowConfig::json_schema();
557 assert!(schema.is_object());
558 let obj = schema.as_object().unwrap();
559 assert!(obj.contains_key("properties"));
560 }
561
562 #[test]
563 fn config_format_detection() {
564 assert!(matches!(
565 ConfigFormat::from_path(Path::new("fallow.toml")),
566 ConfigFormat::Toml
567 ));
568 assert!(matches!(
569 ConfigFormat::from_path(Path::new(".fallowrc.json")),
570 ConfigFormat::Json
571 ));
572 assert!(matches!(
573 ConfigFormat::from_path(Path::new(".fallow.toml")),
574 ConfigFormat::Toml
575 ));
576 }
577
578 #[test]
579 fn config_names_priority_order() {
580 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
581 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
582 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
583 }
584
585 #[test]
586 fn load_json_config_file() {
587 let dir = test_dir("json-config");
588 let config_path = dir.path().join(".fallowrc.json");
589 std::fs::write(
590 &config_path,
591 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
592 )
593 .unwrap();
594
595 let config = FallowConfig::load(&config_path).unwrap();
596 assert_eq!(config.entry, vec!["src/index.ts"]);
597 assert_eq!(config.rules.unused_exports, Severity::Warn);
598 }
599
600 #[test]
601 fn load_jsonc_config_file() {
602 let dir = test_dir("jsonc-config");
603 let config_path = dir.path().join(".fallowrc.json");
604 std::fs::write(
605 &config_path,
606 r#"{
607 // Entry points for analysis
608 "entry": ["src/index.ts"],
609 /* Block comment */
610 "rules": {
611 "unused-exports": "warn"
612 }
613 }"#,
614 )
615 .unwrap();
616
617 let config = FallowConfig::load(&config_path).unwrap();
618 assert_eq!(config.entry, vec!["src/index.ts"]);
619 assert_eq!(config.rules.unused_exports, Severity::Warn);
620 }
621
622 #[test]
623 fn json_config_ignore_dependencies_camel_case() {
624 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
625 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
626 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
627 }
628
629 #[test]
630 fn json_config_all_fields() {
631 let json_str = r#"{
632 "ignoreDependencies": ["lodash"],
633 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
634 "rules": {
635 "unused-files": "off",
636 "unused-exports": "warn",
637 "unused-dependencies": "error",
638 "unused-dev-dependencies": "off",
639 "unused-types": "warn",
640 "unused-enum-members": "error",
641 "unused-class-members": "off",
642 "unresolved-imports": "warn",
643 "unlisted-dependencies": "error",
644 "duplicate-exports": "off"
645 },
646 "duplicates": {
647 "minTokens": 100,
648 "minLines": 10,
649 "skipLocal": true
650 }
651 }"#;
652 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
653 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
654 assert_eq!(config.rules.unused_files, Severity::Off);
655 assert_eq!(config.rules.unused_exports, Severity::Warn);
656 assert_eq!(config.rules.unused_dependencies, Severity::Error);
657 assert_eq!(config.duplicates.min_tokens, 100);
658 assert_eq!(config.duplicates.min_lines, 10);
659 assert!(config.duplicates.skip_local);
660 }
661
662 #[test]
665 fn extends_single_base() {
666 let dir = test_dir("extends-single");
667
668 std::fs::write(
669 dir.path().join("base.json"),
670 r#"{"rules": {"unused-files": "warn"}}"#,
671 )
672 .unwrap();
673 std::fs::write(
674 dir.path().join(".fallowrc.json"),
675 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
676 )
677 .unwrap();
678
679 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
680 assert_eq!(config.rules.unused_files, Severity::Warn);
681 assert_eq!(config.entry, vec!["src/index.ts"]);
682 assert_eq!(config.rules.unused_exports, Severity::Error);
684 }
685
686 #[test]
687 fn extends_overlay_overrides_base() {
688 let dir = test_dir("extends-overlay");
689
690 std::fs::write(
691 dir.path().join("base.json"),
692 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
693 )
694 .unwrap();
695 std::fs::write(
696 dir.path().join(".fallowrc.json"),
697 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
698 )
699 .unwrap();
700
701 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
702 assert_eq!(config.rules.unused_files, Severity::Error);
704 assert_eq!(config.rules.unused_exports, Severity::Off);
706 }
707
708 #[test]
709 fn extends_chained() {
710 let dir = test_dir("extends-chained");
711
712 std::fs::write(
713 dir.path().join("grandparent.json"),
714 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
715 )
716 .unwrap();
717 std::fs::write(
718 dir.path().join("parent.json"),
719 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
720 )
721 .unwrap();
722 std::fs::write(
723 dir.path().join(".fallowrc.json"),
724 r#"{"extends": ["parent.json"]}"#,
725 )
726 .unwrap();
727
728 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
729 assert_eq!(config.rules.unused_files, Severity::Warn);
731 assert_eq!(config.rules.unused_exports, Severity::Warn);
733 }
734
735 #[test]
736 fn extends_circular_detected() {
737 let dir = test_dir("extends-circular");
738
739 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
740 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
741
742 let result = FallowConfig::load(&dir.path().join("a.json"));
743 assert!(result.is_err());
744 let err_msg = format!("{}", result.unwrap_err());
745 assert!(
746 err_msg.contains("Circular extends"),
747 "Expected circular error, got: {err_msg}"
748 );
749 }
750
751 #[test]
752 fn extends_missing_file_errors() {
753 let dir = test_dir("extends-missing");
754
755 std::fs::write(
756 dir.path().join(".fallowrc.json"),
757 r#"{"extends": ["nonexistent.json"]}"#,
758 )
759 .unwrap();
760
761 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
762 assert!(result.is_err());
763 let err_msg = format!("{}", result.unwrap_err());
764 assert!(
765 err_msg.contains("not found"),
766 "Expected not found error, got: {err_msg}"
767 );
768 }
769
770 #[test]
771 fn extends_string_sugar() {
772 let dir = test_dir("extends-string");
773
774 std::fs::write(
775 dir.path().join("base.json"),
776 r#"{"ignorePatterns": ["gen/**"]}"#,
777 )
778 .unwrap();
779 std::fs::write(
781 dir.path().join(".fallowrc.json"),
782 r#"{"extends": "base.json"}"#,
783 )
784 .unwrap();
785
786 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
787 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
788 }
789
790 #[test]
791 fn extends_deep_merge_preserves_arrays() {
792 let dir = test_dir("extends-array");
793
794 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
795 std::fs::write(
796 dir.path().join(".fallowrc.json"),
797 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
798 )
799 .unwrap();
800
801 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
802 assert_eq!(config.entry, vec!["src/b.ts"]);
804 }
805
806 #[test]
809 fn deep_merge_scalar_overlay_replaces_base() {
810 let mut base = serde_json::json!("hello");
811 deep_merge_json(&mut base, serde_json::json!("world"));
812 assert_eq!(base, serde_json::json!("world"));
813 }
814
815 #[test]
816 fn deep_merge_array_overlay_replaces_base() {
817 let mut base = serde_json::json!(["a", "b"]);
818 deep_merge_json(&mut base, serde_json::json!(["c"]));
819 assert_eq!(base, serde_json::json!(["c"]));
820 }
821
822 #[test]
823 fn deep_merge_nested_object_merge() {
824 let mut base = serde_json::json!({
825 "level1": {
826 "level2": {
827 "a": 1,
828 "b": 2
829 }
830 }
831 });
832 let overlay = serde_json::json!({
833 "level1": {
834 "level2": {
835 "b": 99,
836 "c": 3
837 }
838 }
839 });
840 deep_merge_json(&mut base, overlay);
841 assert_eq!(base["level1"]["level2"]["a"], 1);
842 assert_eq!(base["level1"]["level2"]["b"], 99);
843 assert_eq!(base["level1"]["level2"]["c"], 3);
844 }
845
846 #[test]
847 fn deep_merge_overlay_adds_new_fields() {
848 let mut base = serde_json::json!({"existing": true});
849 let overlay = serde_json::json!({"new_field": "added", "another": 42});
850 deep_merge_json(&mut base, overlay);
851 assert_eq!(base["existing"], true);
852 assert_eq!(base["new_field"], "added");
853 assert_eq!(base["another"], 42);
854 }
855
856 #[test]
857 fn deep_merge_null_overlay_replaces_object() {
858 let mut base = serde_json::json!({"key": "value"});
859 deep_merge_json(&mut base, serde_json::json!(null));
860 assert_eq!(base, serde_json::json!(null));
861 }
862
863 #[test]
864 fn deep_merge_empty_object_overlay_preserves_base() {
865 let mut base = serde_json::json!({"a": 1, "b": 2});
866 deep_merge_json(&mut base, serde_json::json!({}));
867 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
868 }
869
870 #[test]
873 fn rules_severity_error_warn_off_from_json() {
874 let json_str = r#"{
875 "rules": {
876 "unused-files": "error",
877 "unused-exports": "warn",
878 "unused-types": "off"
879 }
880 }"#;
881 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
882 assert_eq!(config.rules.unused_files, Severity::Error);
883 assert_eq!(config.rules.unused_exports, Severity::Warn);
884 assert_eq!(config.rules.unused_types, Severity::Off);
885 }
886
887 #[test]
888 fn rules_omitted_default_to_error() {
889 let json_str = r#"{
890 "rules": {
891 "unused-files": "warn"
892 }
893 }"#;
894 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
895 assert_eq!(config.rules.unused_files, Severity::Warn);
896 assert_eq!(config.rules.unused_exports, Severity::Error);
898 assert_eq!(config.rules.unused_types, Severity::Error);
899 assert_eq!(config.rules.unused_dependencies, Severity::Error);
900 assert_eq!(config.rules.unresolved_imports, Severity::Error);
901 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
902 assert_eq!(config.rules.duplicate_exports, Severity::Error);
903 assert_eq!(config.rules.circular_dependencies, Severity::Error);
904 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
906 }
907
908 #[test]
911 fn find_and_load_returns_none_when_no_config() {
912 let dir = test_dir("find-none");
913 std::fs::create_dir(dir.path().join(".git")).unwrap();
915
916 let result = FallowConfig::find_and_load(dir.path()).unwrap();
917 assert!(result.is_none());
918 }
919
920 #[test]
921 fn find_and_load_finds_fallowrc_json() {
922 let dir = test_dir("find-json");
923 std::fs::create_dir(dir.path().join(".git")).unwrap();
924 std::fs::write(
925 dir.path().join(".fallowrc.json"),
926 r#"{"entry": ["src/main.ts"]}"#,
927 )
928 .unwrap();
929
930 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
931 assert_eq!(config.entry, vec!["src/main.ts"]);
932 assert!(path.ends_with(".fallowrc.json"));
933 }
934
935 #[test]
936 fn find_and_load_prefers_fallowrc_json_over_toml() {
937 let dir = test_dir("find-priority");
938 std::fs::create_dir(dir.path().join(".git")).unwrap();
939 std::fs::write(
940 dir.path().join(".fallowrc.json"),
941 r#"{"entry": ["from-json.ts"]}"#,
942 )
943 .unwrap();
944 std::fs::write(
945 dir.path().join("fallow.toml"),
946 "entry = [\"from-toml.ts\"]\n",
947 )
948 .unwrap();
949
950 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
951 assert_eq!(config.entry, vec!["from-json.ts"]);
952 assert!(path.ends_with(".fallowrc.json"));
953 }
954
955 #[test]
956 fn find_and_load_finds_fallow_toml() {
957 let dir = test_dir("find-toml");
958 std::fs::create_dir(dir.path().join(".git")).unwrap();
959 std::fs::write(
960 dir.path().join("fallow.toml"),
961 "entry = [\"src/index.ts\"]\n",
962 )
963 .unwrap();
964
965 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
966 assert_eq!(config.entry, vec!["src/index.ts"]);
967 }
968
969 #[test]
970 fn find_and_load_stops_at_git_dir() {
971 let dir = test_dir("find-git-stop");
972 let sub = dir.path().join("sub");
973 std::fs::create_dir(&sub).unwrap();
974 std::fs::create_dir(dir.path().join(".git")).unwrap();
976 let result = FallowConfig::find_and_load(&sub).unwrap();
980 assert!(result.is_none());
981 }
982
983 #[test]
984 fn find_and_load_stops_at_package_json() {
985 let dir = test_dir("find-pkg-stop");
986 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
987
988 let result = FallowConfig::find_and_load(dir.path()).unwrap();
989 assert!(result.is_none());
990 }
991
992 #[test]
993 fn find_and_load_returns_error_for_invalid_config() {
994 let dir = test_dir("find-invalid");
995 std::fs::create_dir(dir.path().join(".git")).unwrap();
996 std::fs::write(
997 dir.path().join(".fallowrc.json"),
998 r"{ this is not valid json }",
999 )
1000 .unwrap();
1001
1002 let result = FallowConfig::find_and_load(dir.path());
1003 assert!(result.is_err());
1004 }
1005
1006 #[test]
1009 fn load_toml_config_file() {
1010 let dir = test_dir("toml-config");
1011 let config_path = dir.path().join("fallow.toml");
1012 std::fs::write(
1013 &config_path,
1014 r#"
1015entry = ["src/index.ts"]
1016ignorePatterns = ["dist/**"]
1017
1018[rules]
1019unused-files = "warn"
1020
1021[duplicates]
1022minTokens = 100
1023"#,
1024 )
1025 .unwrap();
1026
1027 let config = FallowConfig::load(&config_path).unwrap();
1028 assert_eq!(config.entry, vec!["src/index.ts"]);
1029 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1030 assert_eq!(config.rules.unused_files, Severity::Warn);
1031 assert_eq!(config.duplicates.min_tokens, 100);
1032 }
1033
1034 #[test]
1037 fn extends_absolute_path_rejected() {
1038 let dir = test_dir("extends-absolute");
1039
1040 #[cfg(unix)]
1042 let abs_path = "/absolute/path/config.json";
1043 #[cfg(windows)]
1044 let abs_path = "C:\\absolute\\path\\config.json";
1045
1046 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1047 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1048
1049 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1050 assert!(result.is_err());
1051 let err_msg = format!("{}", result.unwrap_err());
1052 assert!(
1053 err_msg.contains("must be relative"),
1054 "Expected 'must be relative' error, got: {err_msg}"
1055 );
1056 }
1057
1058 #[test]
1061 fn resolve_production_mode_disables_dev_deps() {
1062 let config = FallowConfig {
1063 schema: None,
1064 extends: vec![],
1065 entry: vec![],
1066 ignore_patterns: vec![],
1067 framework: vec![],
1068 workspaces: None,
1069 ignore_dependencies: vec![],
1070 ignore_exports: vec![],
1071 duplicates: DuplicatesConfig::default(),
1072 health: HealthConfig::default(),
1073 rules: RulesConfig::default(),
1074 boundaries: BoundaryConfig::default(),
1075 production: true,
1076 plugins: vec![],
1077 overrides: vec![],
1078 regression: None,
1079 };
1080 let resolved = config.resolve(
1081 PathBuf::from("/tmp/test"),
1082 OutputFormat::Human,
1083 4,
1084 false,
1085 true,
1086 );
1087 assert!(resolved.production);
1088 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1089 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1090 assert_eq!(resolved.rules.unused_files, Severity::Error);
1092 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1093 }
1094
1095 #[test]
1098 fn config_format_defaults_to_toml_for_unknown() {
1099 assert!(matches!(
1100 ConfigFormat::from_path(Path::new("config.yaml")),
1101 ConfigFormat::Toml
1102 ));
1103 assert!(matches!(
1104 ConfigFormat::from_path(Path::new("config")),
1105 ConfigFormat::Toml
1106 ));
1107 }
1108
1109 #[test]
1112 fn deep_merge_object_over_scalar_replaces() {
1113 let mut base = serde_json::json!("just a string");
1114 let overlay = serde_json::json!({"key": "value"});
1115 deep_merge_json(&mut base, overlay);
1116 assert_eq!(base, serde_json::json!({"key": "value"}));
1117 }
1118
1119 #[test]
1120 fn deep_merge_scalar_over_object_replaces() {
1121 let mut base = serde_json::json!({"key": "value"});
1122 let overlay = serde_json::json!(42);
1123 deep_merge_json(&mut base, overlay);
1124 assert_eq!(base, serde_json::json!(42));
1125 }
1126
1127 #[test]
1130 fn extends_non_string_non_array_ignored() {
1131 let dir = test_dir("extends-numeric");
1132 std::fs::write(
1133 dir.path().join(".fallowrc.json"),
1134 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1135 )
1136 .unwrap();
1137
1138 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1140 assert_eq!(config.entry, vec!["src/index.ts"]);
1141 }
1142
1143 #[test]
1146 fn extends_multiple_bases_later_wins() {
1147 let dir = test_dir("extends-multi-base");
1148
1149 std::fs::write(
1150 dir.path().join("base-a.json"),
1151 r#"{"rules": {"unused-files": "warn"}}"#,
1152 )
1153 .unwrap();
1154 std::fs::write(
1155 dir.path().join("base-b.json"),
1156 r#"{"rules": {"unused-files": "off"}}"#,
1157 )
1158 .unwrap();
1159 std::fs::write(
1160 dir.path().join(".fallowrc.json"),
1161 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1162 )
1163 .unwrap();
1164
1165 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1166 assert_eq!(config.rules.unused_files, Severity::Off);
1168 }
1169
1170 #[test]
1173 fn fallow_config_deserialize_production() {
1174 let json_str = r#"{"production": true}"#;
1175 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1176 assert!(config.production);
1177 }
1178
1179 #[test]
1180 fn fallow_config_production_defaults_false() {
1181 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1182 assert!(!config.production);
1183 }
1184
1185 #[test]
1188 fn package_json_optional_dependency_names() {
1189 let pkg: PackageJson = serde_json::from_str(
1190 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1191 )
1192 .unwrap();
1193 let opt = pkg.optional_dependency_names();
1194 assert_eq!(opt.len(), 2);
1195 assert!(opt.contains(&"fsevents".to_string()));
1196 assert!(opt.contains(&"chokidar".to_string()));
1197 }
1198
1199 #[test]
1200 fn package_json_optional_deps_empty_when_missing() {
1201 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1202 assert!(pkg.optional_dependency_names().is_empty());
1203 }
1204
1205 #[test]
1208 fn find_config_path_returns_fallowrc_json() {
1209 let dir = test_dir("find-path-json");
1210 std::fs::create_dir(dir.path().join(".git")).unwrap();
1211 std::fs::write(
1212 dir.path().join(".fallowrc.json"),
1213 r#"{"entry": ["src/main.ts"]}"#,
1214 )
1215 .unwrap();
1216
1217 let path = FallowConfig::find_config_path(dir.path());
1218 assert!(path.is_some());
1219 assert!(path.unwrap().ends_with(".fallowrc.json"));
1220 }
1221
1222 #[test]
1223 fn find_config_path_returns_fallow_toml() {
1224 let dir = test_dir("find-path-toml");
1225 std::fs::create_dir(dir.path().join(".git")).unwrap();
1226 std::fs::write(
1227 dir.path().join("fallow.toml"),
1228 "entry = [\"src/main.ts\"]\n",
1229 )
1230 .unwrap();
1231
1232 let path = FallowConfig::find_config_path(dir.path());
1233 assert!(path.is_some());
1234 assert!(path.unwrap().ends_with("fallow.toml"));
1235 }
1236
1237 #[test]
1238 fn find_config_path_returns_dot_fallow_toml() {
1239 let dir = test_dir("find-path-dot-toml");
1240 std::fs::create_dir(dir.path().join(".git")).unwrap();
1241 std::fs::write(
1242 dir.path().join(".fallow.toml"),
1243 "entry = [\"src/main.ts\"]\n",
1244 )
1245 .unwrap();
1246
1247 let path = FallowConfig::find_config_path(dir.path());
1248 assert!(path.is_some());
1249 assert!(path.unwrap().ends_with(".fallow.toml"));
1250 }
1251
1252 #[test]
1253 fn find_config_path_prefers_json_over_toml() {
1254 let dir = test_dir("find-path-priority");
1255 std::fs::create_dir(dir.path().join(".git")).unwrap();
1256 std::fs::write(
1257 dir.path().join(".fallowrc.json"),
1258 r#"{"entry": ["json.ts"]}"#,
1259 )
1260 .unwrap();
1261 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
1262
1263 let path = FallowConfig::find_config_path(dir.path());
1264 assert!(path.unwrap().ends_with(".fallowrc.json"));
1265 }
1266
1267 #[test]
1268 fn find_config_path_none_when_no_config() {
1269 let dir = test_dir("find-path-none");
1270 std::fs::create_dir(dir.path().join(".git")).unwrap();
1271
1272 let path = FallowConfig::find_config_path(dir.path());
1273 assert!(path.is_none());
1274 }
1275
1276 #[test]
1277 fn find_config_path_stops_at_package_json() {
1278 let dir = test_dir("find-path-pkg-stop");
1279 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
1280
1281 let path = FallowConfig::find_config_path(dir.path());
1282 assert!(path.is_none());
1283 }
1284
1285 #[test]
1288 fn extends_toml_base() {
1289 let dir = test_dir("extends-toml");
1290
1291 std::fs::write(
1292 dir.path().join("base.json"),
1293 r#"{"rules": {"unused-files": "warn"}}"#,
1294 )
1295 .unwrap();
1296 std::fs::write(
1297 dir.path().join("fallow.toml"),
1298 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
1299 )
1300 .unwrap();
1301
1302 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
1303 assert_eq!(config.rules.unused_files, Severity::Warn);
1304 assert_eq!(config.entry, vec!["src/index.ts"]);
1305 }
1306
1307 #[test]
1310 fn deep_merge_boolean_overlay() {
1311 let mut base = serde_json::json!(true);
1312 deep_merge_json(&mut base, serde_json::json!(false));
1313 assert_eq!(base, serde_json::json!(false));
1314 }
1315
1316 #[test]
1317 fn deep_merge_number_overlay() {
1318 let mut base = serde_json::json!(42);
1319 deep_merge_json(&mut base, serde_json::json!(99));
1320 assert_eq!(base, serde_json::json!(99));
1321 }
1322
1323 #[test]
1324 fn deep_merge_disjoint_objects() {
1325 let mut base = serde_json::json!({"a": 1});
1326 let overlay = serde_json::json!({"b": 2});
1327 deep_merge_json(&mut base, overlay);
1328 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1329 }
1330
1331 #[test]
1334 fn max_extends_depth_is_reasonable() {
1335 assert_eq!(MAX_EXTENDS_DEPTH, 10);
1336 }
1337
1338 #[test]
1341 fn config_names_has_three_entries() {
1342 assert_eq!(CONFIG_NAMES.len(), 3);
1343 for name in CONFIG_NAMES {
1345 assert!(
1346 name.starts_with('.') || name.starts_with("fallow"),
1347 "unexpected config name: {name}"
1348 );
1349 }
1350 }
1351
1352 #[test]
1355 fn package_json_peer_dependency_names() {
1356 let pkg: PackageJson = serde_json::from_str(
1357 r#"{
1358 "dependencies": {"react": "^18"},
1359 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
1360 }"#,
1361 )
1362 .unwrap();
1363 let all = pkg.all_dependency_names();
1364 assert!(all.contains(&"react".to_string()));
1365 assert!(all.contains(&"react-dom".to_string()));
1366 assert!(all.contains(&"react-native".to_string()));
1367 }
1368
1369 #[test]
1372 fn package_json_scripts_field() {
1373 let pkg: PackageJson = serde_json::from_str(
1374 r#"{
1375 "scripts": {
1376 "build": "tsc",
1377 "test": "vitest",
1378 "lint": "fallow check"
1379 }
1380 }"#,
1381 )
1382 .unwrap();
1383 let scripts = pkg.scripts.unwrap();
1384 assert_eq!(scripts.len(), 3);
1385 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
1386 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
1387 }
1388
1389 #[test]
1392 fn extends_toml_chain() {
1393 let dir = test_dir("extends-toml-chain");
1394
1395 std::fs::write(
1396 dir.path().join("base.json"),
1397 r#"{"entry": ["src/base.ts"]}"#,
1398 )
1399 .unwrap();
1400 std::fs::write(
1401 dir.path().join("middle.json"),
1402 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
1403 )
1404 .unwrap();
1405 std::fs::write(
1406 dir.path().join("fallow.toml"),
1407 "extends = [\"middle.json\"]\n",
1408 )
1409 .unwrap();
1410
1411 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
1412 assert_eq!(config.entry, vec!["src/base.ts"]);
1413 assert_eq!(config.rules.unused_files, Severity::Off);
1414 }
1415
1416 #[test]
1419 fn find_and_load_walks_up_directories() {
1420 let dir = test_dir("find-walk-up");
1421 let sub = dir.path().join("src").join("deep");
1422 std::fs::create_dir_all(&sub).unwrap();
1423 std::fs::write(
1424 dir.path().join(".fallowrc.json"),
1425 r#"{"entry": ["src/main.ts"]}"#,
1426 )
1427 .unwrap();
1428 std::fs::create_dir(dir.path().join(".git")).unwrap();
1430
1431 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
1432 assert_eq!(config.entry, vec!["src/main.ts"]);
1433 assert!(path.ends_with(".fallowrc.json"));
1434 }
1435
1436 #[test]
1439 fn json_schema_contains_entry_field() {
1440 let schema = FallowConfig::json_schema();
1441 let obj = schema.as_object().unwrap();
1442 let props = obj.get("properties").and_then(|v| v.as_object());
1443 assert!(props.is_some(), "schema should have properties");
1444 assert!(
1445 props.unwrap().contains_key("entry"),
1446 "schema should contain entry property"
1447 );
1448 }
1449
1450 #[test]
1453 fn fallow_config_json_duplicates_all_fields() {
1454 let json = r#"{
1455 "duplicates": {
1456 "enabled": true,
1457 "mode": "semantic",
1458 "minTokens": 200,
1459 "minLines": 20,
1460 "threshold": 10.5,
1461 "ignore": ["**/*.test.ts"],
1462 "skipLocal": true,
1463 "crossLanguage": true,
1464 "normalization": {
1465 "ignoreIdentifiers": true,
1466 "ignoreStringValues": false
1467 }
1468 }
1469 }"#;
1470 let config: FallowConfig = serde_json::from_str(json).unwrap();
1471 assert!(config.duplicates.enabled);
1472 assert_eq!(
1473 config.duplicates.mode,
1474 crate::config::DetectionMode::Semantic
1475 );
1476 assert_eq!(config.duplicates.min_tokens, 200);
1477 assert_eq!(config.duplicates.min_lines, 20);
1478 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
1479 assert!(config.duplicates.skip_local);
1480 assert!(config.duplicates.cross_language);
1481 assert_eq!(
1482 config.duplicates.normalization.ignore_identifiers,
1483 Some(true)
1484 );
1485 assert_eq!(
1486 config.duplicates.normalization.ignore_string_values,
1487 Some(false)
1488 );
1489 }
1490}