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
16const NPM_PREFIX: &str = "npm:";
18
19pub(super) enum ConfigFormat {
21 Toml,
22 Json,
23}
24
25impl ConfigFormat {
26 pub(super) fn from_path(path: &Path) -> Self {
27 match path.extension().and_then(|e| e.to_str()) {
28 Some("json") => Self::Json,
29 _ => Self::Toml,
30 }
31 }
32}
33
34pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
37 match (base, overlay) {
38 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
39 for (key, value) in overlay_map {
40 if let Some(base_value) = base_map.get_mut(&key) {
41 deep_merge_json(base_value, value);
42 } else {
43 base_map.insert(key, value);
44 }
45 }
46 }
47 (base, overlay) => {
48 *base = overlay;
49 }
50 }
51}
52
53pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
54 let content = std::fs::read_to_string(path)
55 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
56
57 match ConfigFormat::from_path(path) {
58 ConfigFormat::Toml => {
59 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
60 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
61 })?;
62 serde_json::to_value(toml_value).map_err(|e| {
63 miette::miette!(
64 "Failed to convert TOML to JSON for {}: {}",
65 path.display(),
66 e
67 )
68 })
69 }
70 ConfigFormat::Json => {
71 let mut stripped = String::new();
72 json_comments::StripComments::new(content.as_bytes())
73 .read_to_string(&mut stripped)
74 .map_err(|e| {
75 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
76 })?;
77 serde_json::from_str(&stripped).map_err(|e| {
78 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
79 })
80 }
81 }
82}
83
84fn resolve_confined(
89 base_dir: &Path,
90 resolved: &Path,
91 context: &str,
92 source_config: &Path,
93) -> Result<PathBuf, miette::Report> {
94 let canonical_base = base_dir
95 .canonicalize()
96 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
97 let canonical_file = resolved.canonicalize().map_err(|e| {
98 miette::miette!(
99 "Config file not found: {} ({}, referenced from {}): {}",
100 resolved.display(),
101 context,
102 source_config.display(),
103 e
104 )
105 })?;
106 if !canonical_file.starts_with(&canonical_base) {
107 return Err(miette::miette!(
108 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
109 resolved.display(),
110 base_dir.display(),
111 context,
112 source_config.display()
113 ));
114 }
115 Ok(canonical_file)
116}
117
118fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
120 if name.starts_with('@') && !name.contains('/') {
121 return Err(miette::miette!(
122 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
123 name,
124 source_config.display()
125 ));
126 }
127 if name.split('/').any(|c| c == ".." || c == ".") {
128 return Err(miette::miette!(
129 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
130 name,
131 source_config.display()
132 ));
133 }
134 Ok(())
135}
136
137fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
144 if specifier.starts_with('@') {
145 let mut slashes = 0;
148 for (i, ch) in specifier.char_indices() {
149 if ch == '/' {
150 slashes += 1;
151 if slashes == 2 {
152 return (&specifier[..i], Some(&specifier[i + 1..]));
153 }
154 }
155 }
156 (specifier, None)
158 } else if let Some(slash) = specifier.find('/') {
159 (&specifier[..slash], Some(&specifier[slash + 1..]))
160 } else {
161 (specifier, None)
162 }
163}
164
165fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
172 let exports = pkg.get("exports")?;
173 match exports {
174 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
175 serde_json::Value::Object(map) => {
176 let dot_export = map.get(".")?;
177 match dot_export {
178 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
179 serde_json::Value::Object(conditions) => {
180 for key in ["default", "node", "import", "require"] {
181 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
182 return Some(package_dir.join(s.as_str()));
183 }
184 }
185 None
186 }
187 _ => None,
188 }
189 }
190 _ => None,
193 }
194}
195
196fn find_config_in_npm_package(
206 package_dir: &Path,
207 source_config: &Path,
208) -> Result<PathBuf, miette::Report> {
209 let pkg_json_path = package_dir.join("package.json");
210 if pkg_json_path.exists() {
211 let content = std::fs::read_to_string(&pkg_json_path)
212 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
213 let pkg: serde_json::Value = serde_json::from_str(&content)
214 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
215 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
216 && config_path.exists()
217 {
218 return resolve_confined(
219 package_dir,
220 &config_path,
221 "package.json exports",
222 source_config,
223 );
224 }
225 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
226 let main_path = package_dir.join(main);
227 if main_path.exists() {
228 return resolve_confined(
229 package_dir,
230 &main_path,
231 "package.json main",
232 source_config,
233 );
234 }
235 }
236 }
237
238 for config_name in CONFIG_NAMES {
239 let config_path = package_dir.join(config_name);
240 if config_path.exists() {
241 return resolve_confined(
242 package_dir,
243 &config_path,
244 "config name fallback",
245 source_config,
246 );
247 }
248 }
249
250 Err(miette::miette!(
251 "No fallow config found in npm package at {}. \
252 Expected package.json with main/exports pointing to a config file, \
253 or one of: {}",
254 package_dir.display(),
255 CONFIG_NAMES.join(", ")
256 ))
257}
258
259fn resolve_npm_package(
265 config_dir: &Path,
266 specifier: &str,
267 source_config: &Path,
268) -> Result<PathBuf, miette::Report> {
269 let specifier = specifier.trim();
270 if specifier.is_empty() {
271 return Err(miette::miette!(
272 "Empty npm specifier in extends (in {})",
273 source_config.display()
274 ));
275 }
276
277 let (package_name, subpath) = parse_npm_specifier(specifier);
278 validate_npm_package_name(package_name, source_config)?;
279
280 let mut dir = Some(config_dir);
281 while let Some(d) = dir {
282 let candidate = d.join("node_modules").join(package_name);
283 if candidate.is_dir() {
284 return if let Some(sub) = subpath {
285 let file = candidate.join(sub);
286 if file.exists() {
287 resolve_confined(
288 &candidate,
289 &file,
290 &format!("subpath '{sub}'"),
291 source_config,
292 )
293 } else {
294 Err(miette::miette!(
295 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
296 file.display(),
297 sub,
298 candidate.display(),
299 source_config.display()
300 ))
301 }
302 } else {
303 find_config_in_npm_package(&candidate, source_config)
304 };
305 }
306 dir = d.parent();
307 }
308
309 Err(miette::miette!(
310 "npm package '{}' not found. \
311 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
312 If this package should be available, install it and ensure it is listed in your project's dependencies",
313 package_name,
314 package_name,
315 config_dir.display(),
316 source_config.display()
317 ))
318}
319
320pub(super) fn resolve_extends(
321 path: &Path,
322 visited: &mut FxHashSet<PathBuf>,
323 depth: usize,
324) -> Result<serde_json::Value, miette::Report> {
325 if depth >= MAX_EXTENDS_DEPTH {
326 return Err(miette::miette!(
327 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
328 path.display()
329 ));
330 }
331
332 let canonical = path.canonicalize().map_err(|e| {
333 miette::miette!(
334 "Config file not found or unresolvable: {}: {}",
335 path.display(),
336 e
337 )
338 })?;
339
340 if !visited.insert(canonical) {
341 return Err(miette::miette!(
342 "Circular extends detected: {} was already visited in the extends chain",
343 path.display()
344 ));
345 }
346
347 let mut value = parse_config_to_value(path)?;
348
349 let extends = value
350 .as_object_mut()
351 .and_then(|obj| obj.remove("extends"))
352 .and_then(|v| match v {
353 serde_json::Value::Array(arr) => Some(
354 arr.into_iter()
355 .filter_map(|v| v.as_str().map(String::from))
356 .collect::<Vec<_>>(),
357 ),
358 serde_json::Value::String(s) => Some(vec![s]),
359 _ => None,
360 })
361 .unwrap_or_default();
362
363 if extends.is_empty() {
364 return Ok(value);
365 }
366
367 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
368 let mut merged = serde_json::Value::Object(serde_json::Map::new());
369
370 for extend_path_str in &extends {
371 let extend_path = if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
372 resolve_npm_package(config_dir, npm_specifier, path)?
373 } else {
374 if Path::new(extend_path_str).is_absolute() {
375 return Err(miette::miette!(
376 "extends paths must be relative, got absolute path: {} (in {})",
377 extend_path_str,
378 path.display()
379 ));
380 }
381 let p = config_dir.join(extend_path_str);
382 if !p.exists() {
383 return Err(miette::miette!(
384 "Extended config file not found: {} (referenced from {})",
385 p.display(),
386 path.display()
387 ));
388 }
389 p
390 };
391 let base = resolve_extends(&extend_path, visited, depth + 1)?;
392 deep_merge_json(&mut merged, base);
393 }
394
395 deep_merge_json(&mut merged, value);
396 Ok(merged)
397}
398
399impl FallowConfig {
400 pub fn load(path: &Path) -> Result<Self, miette::Report> {
413 let mut visited = FxHashSet::default();
414 let merged = resolve_extends(path, &mut visited, 0)?;
415
416 serde_json::from_value(merged).map_err(|e| {
417 miette::miette!(
418 "Failed to deserialize config from {}: {}",
419 path.display(),
420 e
421 )
422 })
423 }
424
425 #[must_use]
428 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
429 let mut dir = start;
430 loop {
431 for name in CONFIG_NAMES {
432 let candidate = dir.join(name);
433 if candidate.exists() {
434 return Some(candidate);
435 }
436 }
437 if dir.join(".git").exists() || dir.join("package.json").exists() {
438 break;
439 }
440 dir = dir.parent()?;
441 }
442 None
443 }
444
445 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
451 let mut dir = start;
452 loop {
453 for name in CONFIG_NAMES {
454 let candidate = dir.join(name);
455 if candidate.exists() {
456 match Self::load(&candidate) {
457 Ok(config) => return Ok(Some((config, candidate))),
458 Err(e) => {
459 return Err(format!("Failed to parse {}: {e}", candidate.display()));
460 }
461 }
462 }
463 }
464 if dir.join(".git").exists() || dir.join("package.json").exists() {
466 break;
467 }
468 dir = match dir.parent() {
469 Some(parent) => parent,
470 None => break,
471 };
472 }
473 Ok(None)
474 }
475
476 #[must_use]
478 pub fn json_schema() -> serde_json::Value {
479 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use std::io::Read as _;
486
487 use super::*;
488 use crate::PackageJson;
489 use crate::config::boundaries::BoundaryConfig;
490 use crate::config::duplicates_config::DuplicatesConfig;
491 use crate::config::format::OutputFormat;
492 use crate::config::health::HealthConfig;
493 use crate::config::rules::{RulesConfig, Severity};
494
495 fn test_dir(_name: &str) -> tempfile::TempDir {
497 tempfile::tempdir().expect("create temp dir")
498 }
499
500 #[test]
501 fn fallow_config_deserialize_minimal() {
502 let toml_str = r#"
503entry = ["src/main.ts"]
504"#;
505 let config: FallowConfig = toml::from_str(toml_str).unwrap();
506 assert_eq!(config.entry, vec!["src/main.ts"]);
507 assert!(config.ignore_patterns.is_empty());
508 }
509
510 #[test]
511 fn fallow_config_deserialize_ignore_exports() {
512 let toml_str = r#"
513[[ignoreExports]]
514file = "src/types/*.ts"
515exports = ["*"]
516
517[[ignoreExports]]
518file = "src/constants.ts"
519exports = ["FOO", "BAR"]
520"#;
521 let config: FallowConfig = toml::from_str(toml_str).unwrap();
522 assert_eq!(config.ignore_exports.len(), 2);
523 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
524 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
525 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
526 }
527
528 #[test]
529 fn fallow_config_deserialize_ignore_dependencies() {
530 let toml_str = r#"
531ignoreDependencies = ["autoprefixer", "postcss"]
532"#;
533 let config: FallowConfig = toml::from_str(toml_str).unwrap();
534 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
535 }
536
537 #[test]
538 fn fallow_config_resolve_default_ignores() {
539 let config = FallowConfig {
540 schema: None,
541 extends: vec![],
542 entry: vec![],
543 ignore_patterns: vec![],
544 framework: vec![],
545 workspaces: None,
546 ignore_dependencies: vec![],
547 ignore_exports: vec![],
548 duplicates: DuplicatesConfig::default(),
549 health: HealthConfig::default(),
550 rules: RulesConfig::default(),
551 boundaries: BoundaryConfig::default(),
552 production: false,
553 plugins: vec![],
554 overrides: vec![],
555 regression: None,
556 };
557 let resolved = config.resolve(
558 PathBuf::from("/tmp/test"),
559 OutputFormat::Human,
560 4,
561 true,
562 true,
563 );
564
565 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
567 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
568 assert!(resolved.ignore_patterns.is_match("build/output.js"));
569 assert!(resolved.ignore_patterns.is_match(".git/config"));
570 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
571 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
572 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
573 }
574
575 #[test]
576 fn fallow_config_resolve_custom_ignores() {
577 let config = FallowConfig {
578 schema: None,
579 extends: vec![],
580 entry: vec!["src/**/*.ts".to_string()],
581 ignore_patterns: vec!["**/*.generated.ts".to_string()],
582 framework: vec![],
583 workspaces: None,
584 ignore_dependencies: vec![],
585 ignore_exports: vec![],
586 duplicates: DuplicatesConfig::default(),
587 health: HealthConfig::default(),
588 rules: RulesConfig::default(),
589 boundaries: BoundaryConfig::default(),
590 production: false,
591 plugins: vec![],
592 overrides: vec![],
593 regression: None,
594 };
595 let resolved = config.resolve(
596 PathBuf::from("/tmp/test"),
597 OutputFormat::Json,
598 4,
599 false,
600 true,
601 );
602
603 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
604 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
605 assert!(matches!(resolved.output, OutputFormat::Json));
606 assert!(!resolved.no_cache);
607 }
608
609 #[test]
610 fn fallow_config_resolve_cache_dir() {
611 let config = FallowConfig {
612 schema: None,
613 extends: vec![],
614 entry: vec![],
615 ignore_patterns: vec![],
616 framework: vec![],
617 workspaces: None,
618 ignore_dependencies: vec![],
619 ignore_exports: vec![],
620 duplicates: DuplicatesConfig::default(),
621 health: HealthConfig::default(),
622 rules: RulesConfig::default(),
623 boundaries: BoundaryConfig::default(),
624 production: false,
625 plugins: vec![],
626 overrides: vec![],
627 regression: None,
628 };
629 let resolved = config.resolve(
630 PathBuf::from("/tmp/project"),
631 OutputFormat::Human,
632 4,
633 true,
634 true,
635 );
636 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
637 assert!(resolved.no_cache);
638 }
639
640 #[test]
641 fn package_json_entry_points_main() {
642 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
643 let entries = pkg.entry_points();
644 assert!(entries.contains(&"dist/index.js".to_string()));
645 }
646
647 #[test]
648 fn package_json_entry_points_module() {
649 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
650 let entries = pkg.entry_points();
651 assert!(entries.contains(&"dist/index.mjs".to_string()));
652 }
653
654 #[test]
655 fn package_json_entry_points_types() {
656 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
657 let entries = pkg.entry_points();
658 assert!(entries.contains(&"dist/index.d.ts".to_string()));
659 }
660
661 #[test]
662 fn package_json_entry_points_bin_string() {
663 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
664 let entries = pkg.entry_points();
665 assert!(entries.contains(&"bin/cli.js".to_string()));
666 }
667
668 #[test]
669 fn package_json_entry_points_bin_object() {
670 let pkg: PackageJson =
671 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
672 .unwrap();
673 let entries = pkg.entry_points();
674 assert!(entries.contains(&"bin/cli.js".to_string()));
675 assert!(entries.contains(&"bin/serve.js".to_string()));
676 }
677
678 #[test]
679 fn package_json_entry_points_exports_string() {
680 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
681 let entries = pkg.entry_points();
682 assert!(entries.contains(&"./dist/index.js".to_string()));
683 }
684
685 #[test]
686 fn package_json_entry_points_exports_object() {
687 let pkg: PackageJson = serde_json::from_str(
688 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
689 )
690 .unwrap();
691 let entries = pkg.entry_points();
692 assert!(entries.contains(&"./dist/index.mjs".to_string()));
693 assert!(entries.contains(&"./dist/index.cjs".to_string()));
694 }
695
696 #[test]
697 fn package_json_dependency_names() {
698 let pkg: PackageJson = serde_json::from_str(
699 r#"{
700 "dependencies": {"react": "^18", "lodash": "^4"},
701 "devDependencies": {"typescript": "^5"},
702 "peerDependencies": {"react-dom": "^18"}
703 }"#,
704 )
705 .unwrap();
706
707 let all = pkg.all_dependency_names();
708 assert!(all.contains(&"react".to_string()));
709 assert!(all.contains(&"lodash".to_string()));
710 assert!(all.contains(&"typescript".to_string()));
711 assert!(all.contains(&"react-dom".to_string()));
712
713 let prod = pkg.production_dependency_names();
714 assert!(prod.contains(&"react".to_string()));
715 assert!(!prod.contains(&"typescript".to_string()));
716
717 let dev = pkg.dev_dependency_names();
718 assert!(dev.contains(&"typescript".to_string()));
719 assert!(!dev.contains(&"react".to_string()));
720 }
721
722 #[test]
723 fn package_json_no_dependencies() {
724 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
725 assert!(pkg.all_dependency_names().is_empty());
726 assert!(pkg.production_dependency_names().is_empty());
727 assert!(pkg.dev_dependency_names().is_empty());
728 assert!(pkg.entry_points().is_empty());
729 }
730
731 #[test]
732 fn rules_deserialize_toml_kebab_case() {
733 let toml_str = r#"
734[rules]
735unused-files = "error"
736unused-exports = "warn"
737unused-types = "off"
738"#;
739 let config: FallowConfig = toml::from_str(toml_str).unwrap();
740 assert_eq!(config.rules.unused_files, Severity::Error);
741 assert_eq!(config.rules.unused_exports, Severity::Warn);
742 assert_eq!(config.rules.unused_types, Severity::Off);
743 assert_eq!(config.rules.unresolved_imports, Severity::Error);
745 }
746
747 #[test]
748 fn config_without_rules_defaults_to_error() {
749 let toml_str = r#"
750entry = ["src/main.ts"]
751"#;
752 let config: FallowConfig = toml::from_str(toml_str).unwrap();
753 assert_eq!(config.rules.unused_files, Severity::Error);
754 assert_eq!(config.rules.unused_exports, Severity::Error);
755 }
756
757 #[test]
758 fn fallow_config_denies_unknown_fields() {
759 let toml_str = r"
760unknown_field = true
761";
762 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
763 assert!(result.is_err());
764 }
765
766 #[test]
767 fn fallow_config_deserialize_json() {
768 let json_str = r#"{"entry": ["src/main.ts"]}"#;
769 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
770 assert_eq!(config.entry, vec!["src/main.ts"]);
771 }
772
773 #[test]
774 fn fallow_config_deserialize_jsonc() {
775 let jsonc_str = r#"{
776 // This is a comment
777 "entry": ["src/main.ts"],
778 "rules": {
779 "unused-files": "warn"
780 }
781 }"#;
782 let mut stripped = String::new();
783 json_comments::StripComments::new(jsonc_str.as_bytes())
784 .read_to_string(&mut stripped)
785 .unwrap();
786 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
787 assert_eq!(config.entry, vec!["src/main.ts"]);
788 assert_eq!(config.rules.unused_files, Severity::Warn);
789 }
790
791 #[test]
792 fn fallow_config_json_with_schema_field() {
793 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
794 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
795 assert_eq!(config.entry, vec!["src/main.ts"]);
796 }
797
798 #[test]
799 fn fallow_config_json_schema_generation() {
800 let schema = FallowConfig::json_schema();
801 assert!(schema.is_object());
802 let obj = schema.as_object().unwrap();
803 assert!(obj.contains_key("properties"));
804 }
805
806 #[test]
807 fn config_format_detection() {
808 assert!(matches!(
809 ConfigFormat::from_path(Path::new("fallow.toml")),
810 ConfigFormat::Toml
811 ));
812 assert!(matches!(
813 ConfigFormat::from_path(Path::new(".fallowrc.json")),
814 ConfigFormat::Json
815 ));
816 assert!(matches!(
817 ConfigFormat::from_path(Path::new(".fallow.toml")),
818 ConfigFormat::Toml
819 ));
820 }
821
822 #[test]
823 fn config_names_priority_order() {
824 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
825 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
826 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
827 }
828
829 #[test]
830 fn load_json_config_file() {
831 let dir = test_dir("json-config");
832 let config_path = dir.path().join(".fallowrc.json");
833 std::fs::write(
834 &config_path,
835 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
836 )
837 .unwrap();
838
839 let config = FallowConfig::load(&config_path).unwrap();
840 assert_eq!(config.entry, vec!["src/index.ts"]);
841 assert_eq!(config.rules.unused_exports, Severity::Warn);
842 }
843
844 #[test]
845 fn load_jsonc_config_file() {
846 let dir = test_dir("jsonc-config");
847 let config_path = dir.path().join(".fallowrc.json");
848 std::fs::write(
849 &config_path,
850 r#"{
851 // Entry points for analysis
852 "entry": ["src/index.ts"],
853 /* Block comment */
854 "rules": {
855 "unused-exports": "warn"
856 }
857 }"#,
858 )
859 .unwrap();
860
861 let config = FallowConfig::load(&config_path).unwrap();
862 assert_eq!(config.entry, vec!["src/index.ts"]);
863 assert_eq!(config.rules.unused_exports, Severity::Warn);
864 }
865
866 #[test]
867 fn json_config_ignore_dependencies_camel_case() {
868 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
869 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
870 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
871 }
872
873 #[test]
874 fn json_config_all_fields() {
875 let json_str = r#"{
876 "ignoreDependencies": ["lodash"],
877 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
878 "rules": {
879 "unused-files": "off",
880 "unused-exports": "warn",
881 "unused-dependencies": "error",
882 "unused-dev-dependencies": "off",
883 "unused-types": "warn",
884 "unused-enum-members": "error",
885 "unused-class-members": "off",
886 "unresolved-imports": "warn",
887 "unlisted-dependencies": "error",
888 "duplicate-exports": "off"
889 },
890 "duplicates": {
891 "minTokens": 100,
892 "minLines": 10,
893 "skipLocal": true
894 }
895 }"#;
896 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
897 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
898 assert_eq!(config.rules.unused_files, Severity::Off);
899 assert_eq!(config.rules.unused_exports, Severity::Warn);
900 assert_eq!(config.rules.unused_dependencies, Severity::Error);
901 assert_eq!(config.duplicates.min_tokens, 100);
902 assert_eq!(config.duplicates.min_lines, 10);
903 assert!(config.duplicates.skip_local);
904 }
905
906 #[test]
909 fn extends_single_base() {
910 let dir = test_dir("extends-single");
911
912 std::fs::write(
913 dir.path().join("base.json"),
914 r#"{"rules": {"unused-files": "warn"}}"#,
915 )
916 .unwrap();
917 std::fs::write(
918 dir.path().join(".fallowrc.json"),
919 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
920 )
921 .unwrap();
922
923 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
924 assert_eq!(config.rules.unused_files, Severity::Warn);
925 assert_eq!(config.entry, vec!["src/index.ts"]);
926 assert_eq!(config.rules.unused_exports, Severity::Error);
928 }
929
930 #[test]
931 fn extends_overlay_overrides_base() {
932 let dir = test_dir("extends-overlay");
933
934 std::fs::write(
935 dir.path().join("base.json"),
936 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
937 )
938 .unwrap();
939 std::fs::write(
940 dir.path().join(".fallowrc.json"),
941 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
942 )
943 .unwrap();
944
945 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
946 assert_eq!(config.rules.unused_files, Severity::Error);
948 assert_eq!(config.rules.unused_exports, Severity::Off);
950 }
951
952 #[test]
953 fn extends_chained() {
954 let dir = test_dir("extends-chained");
955
956 std::fs::write(
957 dir.path().join("grandparent.json"),
958 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
959 )
960 .unwrap();
961 std::fs::write(
962 dir.path().join("parent.json"),
963 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
964 )
965 .unwrap();
966 std::fs::write(
967 dir.path().join(".fallowrc.json"),
968 r#"{"extends": ["parent.json"]}"#,
969 )
970 .unwrap();
971
972 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
973 assert_eq!(config.rules.unused_files, Severity::Warn);
975 assert_eq!(config.rules.unused_exports, Severity::Warn);
977 }
978
979 #[test]
980 fn extends_circular_detected() {
981 let dir = test_dir("extends-circular");
982
983 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
984 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
985
986 let result = FallowConfig::load(&dir.path().join("a.json"));
987 assert!(result.is_err());
988 let err_msg = format!("{}", result.unwrap_err());
989 assert!(
990 err_msg.contains("Circular extends"),
991 "Expected circular error, got: {err_msg}"
992 );
993 }
994
995 #[test]
996 fn extends_missing_file_errors() {
997 let dir = test_dir("extends-missing");
998
999 std::fs::write(
1000 dir.path().join(".fallowrc.json"),
1001 r#"{"extends": ["nonexistent.json"]}"#,
1002 )
1003 .unwrap();
1004
1005 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1006 assert!(result.is_err());
1007 let err_msg = format!("{}", result.unwrap_err());
1008 assert!(
1009 err_msg.contains("not found"),
1010 "Expected not found error, got: {err_msg}"
1011 );
1012 }
1013
1014 #[test]
1015 fn extends_string_sugar() {
1016 let dir = test_dir("extends-string");
1017
1018 std::fs::write(
1019 dir.path().join("base.json"),
1020 r#"{"ignorePatterns": ["gen/**"]}"#,
1021 )
1022 .unwrap();
1023 std::fs::write(
1025 dir.path().join(".fallowrc.json"),
1026 r#"{"extends": "base.json"}"#,
1027 )
1028 .unwrap();
1029
1030 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1031 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1032 }
1033
1034 #[test]
1035 fn extends_deep_merge_preserves_arrays() {
1036 let dir = test_dir("extends-array");
1037
1038 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1039 std::fs::write(
1040 dir.path().join(".fallowrc.json"),
1041 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1042 )
1043 .unwrap();
1044
1045 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1046 assert_eq!(config.entry, vec!["src/b.ts"]);
1048 }
1049
1050 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1054 let pkg_dir = root.join("node_modules").join(name);
1055 std::fs::create_dir_all(&pkg_dir).unwrap();
1056 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1057 }
1058
1059 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1061 let pkg_dir = root.join("node_modules").join(name);
1062 std::fs::create_dir_all(&pkg_dir).unwrap();
1063 std::fs::write(
1064 pkg_dir.join("package.json"),
1065 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1066 )
1067 .unwrap();
1068 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1069 }
1070
1071 #[test]
1072 fn extends_npm_basic_unscoped() {
1073 let dir = test_dir("npm-basic");
1074 create_npm_package(
1075 dir.path(),
1076 "fallow-config-acme",
1077 r#"{"rules": {"unused-files": "warn"}}"#,
1078 );
1079 std::fs::write(
1080 dir.path().join(".fallowrc.json"),
1081 r#"{"extends": "npm:fallow-config-acme"}"#,
1082 )
1083 .unwrap();
1084
1085 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1086 assert_eq!(config.rules.unused_files, Severity::Warn);
1087 }
1088
1089 #[test]
1090 fn extends_npm_scoped_package() {
1091 let dir = test_dir("npm-scoped");
1092 create_npm_package(
1093 dir.path(),
1094 "@company/fallow-config",
1095 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1096 );
1097 std::fs::write(
1098 dir.path().join(".fallowrc.json"),
1099 r#"{"extends": "npm:@company/fallow-config"}"#,
1100 )
1101 .unwrap();
1102
1103 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1104 assert_eq!(config.rules.unused_exports, Severity::Off);
1105 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1106 }
1107
1108 #[test]
1109 fn extends_npm_with_subpath() {
1110 let dir = test_dir("npm-subpath");
1111 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1112 std::fs::create_dir_all(&pkg_dir).unwrap();
1113 std::fs::write(
1114 pkg_dir.join("strict.json"),
1115 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1116 )
1117 .unwrap();
1118
1119 std::fs::write(
1120 dir.path().join(".fallowrc.json"),
1121 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1122 )
1123 .unwrap();
1124
1125 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1126 assert_eq!(config.rules.unused_files, Severity::Error);
1127 assert_eq!(config.rules.unused_exports, Severity::Error);
1128 }
1129
1130 #[test]
1131 fn extends_npm_package_json_main() {
1132 let dir = test_dir("npm-main");
1133 create_npm_package_with_main(
1134 dir.path(),
1135 "fallow-config-acme",
1136 "config.json",
1137 r#"{"rules": {"unused-types": "off"}}"#,
1138 );
1139 std::fs::write(
1140 dir.path().join(".fallowrc.json"),
1141 r#"{"extends": "npm:fallow-config-acme"}"#,
1142 )
1143 .unwrap();
1144
1145 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1146 assert_eq!(config.rules.unused_types, Severity::Off);
1147 }
1148
1149 #[test]
1150 fn extends_npm_package_json_exports_string() {
1151 let dir = test_dir("npm-exports-str");
1152 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1153 std::fs::create_dir_all(&pkg_dir).unwrap();
1154 std::fs::write(
1155 pkg_dir.join("package.json"),
1156 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1157 )
1158 .unwrap();
1159 std::fs::write(
1160 pkg_dir.join("base.json"),
1161 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1162 )
1163 .unwrap();
1164
1165 std::fs::write(
1166 dir.path().join(".fallowrc.json"),
1167 r#"{"extends": "npm:fallow-config-co"}"#,
1168 )
1169 .unwrap();
1170
1171 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1172 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1173 }
1174
1175 #[test]
1176 fn extends_npm_package_json_exports_object() {
1177 let dir = test_dir("npm-exports-obj");
1178 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1179 std::fs::create_dir_all(&pkg_dir).unwrap();
1180 std::fs::write(
1181 pkg_dir.join("package.json"),
1182 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1183 )
1184 .unwrap();
1185 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1186
1187 std::fs::write(
1188 dir.path().join(".fallowrc.json"),
1189 r#"{"extends": "npm:@co/cfg"}"#,
1190 )
1191 .unwrap();
1192
1193 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1194 assert_eq!(config.entry, vec!["src/app.ts"]);
1195 }
1196
1197 #[test]
1198 fn extends_npm_exports_takes_priority_over_main() {
1199 let dir = test_dir("npm-exports-prio");
1200 let pkg_dir = dir.path().join("node_modules/my-config");
1201 std::fs::create_dir_all(&pkg_dir).unwrap();
1202 std::fs::write(
1203 pkg_dir.join("package.json"),
1204 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1205 )
1206 .unwrap();
1207 std::fs::write(
1208 pkg_dir.join("old.json"),
1209 r#"{"rules": {"unused-files": "off"}}"#,
1210 )
1211 .unwrap();
1212 std::fs::write(
1213 pkg_dir.join("new.json"),
1214 r#"{"rules": {"unused-files": "warn"}}"#,
1215 )
1216 .unwrap();
1217
1218 std::fs::write(
1219 dir.path().join(".fallowrc.json"),
1220 r#"{"extends": "npm:my-config"}"#,
1221 )
1222 .unwrap();
1223
1224 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1225 assert_eq!(config.rules.unused_files, Severity::Warn);
1227 }
1228
1229 #[test]
1230 fn extends_npm_walk_up_directories() {
1231 let dir = test_dir("npm-walkup");
1232 create_npm_package(
1234 dir.path(),
1235 "shared-config",
1236 r#"{"rules": {"unused-files": "warn"}}"#,
1237 );
1238 let sub = dir.path().join("packages/app");
1240 std::fs::create_dir_all(&sub).unwrap();
1241 std::fs::write(
1242 sub.join(".fallowrc.json"),
1243 r#"{"extends": "npm:shared-config"}"#,
1244 )
1245 .unwrap();
1246
1247 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1248 assert_eq!(config.rules.unused_files, Severity::Warn);
1249 }
1250
1251 #[test]
1252 fn extends_npm_overlay_overrides_base() {
1253 let dir = test_dir("npm-overlay");
1254 create_npm_package(
1255 dir.path(),
1256 "@company/base",
1257 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1258 );
1259 std::fs::write(
1260 dir.path().join(".fallowrc.json"),
1261 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1262 )
1263 .unwrap();
1264
1265 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1266 assert_eq!(config.rules.unused_files, Severity::Error);
1267 assert_eq!(config.rules.unused_exports, Severity::Off);
1268 assert_eq!(config.entry, vec!["src/app.ts"]);
1269 }
1270
1271 #[test]
1272 fn extends_npm_chained_with_relative() {
1273 let dir = test_dir("npm-chained");
1274 let pkg_dir = dir.path().join("node_modules/my-config");
1276 std::fs::create_dir_all(&pkg_dir).unwrap();
1277 std::fs::write(
1278 pkg_dir.join("base.json"),
1279 r#"{"rules": {"unused-files": "warn"}}"#,
1280 )
1281 .unwrap();
1282 std::fs::write(
1283 pkg_dir.join(".fallowrc.json"),
1284 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1285 )
1286 .unwrap();
1287
1288 std::fs::write(
1289 dir.path().join(".fallowrc.json"),
1290 r#"{"extends": "npm:my-config"}"#,
1291 )
1292 .unwrap();
1293
1294 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1295 assert_eq!(config.rules.unused_files, Severity::Warn);
1296 assert_eq!(config.rules.unused_exports, Severity::Off);
1297 }
1298
1299 #[test]
1300 fn extends_npm_mixed_with_relative_paths() {
1301 let dir = test_dir("npm-mixed");
1302 create_npm_package(
1303 dir.path(),
1304 "shared-base",
1305 r#"{"rules": {"unused-files": "off"}}"#,
1306 );
1307 std::fs::write(
1308 dir.path().join("local-overrides.json"),
1309 r#"{"rules": {"unused-files": "warn"}}"#,
1310 )
1311 .unwrap();
1312 std::fs::write(
1313 dir.path().join(".fallowrc.json"),
1314 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1315 )
1316 .unwrap();
1317
1318 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1319 assert_eq!(config.rules.unused_files, Severity::Warn);
1321 }
1322
1323 #[test]
1324 fn extends_npm_missing_package_errors() {
1325 let dir = test_dir("npm-missing");
1326 std::fs::write(
1327 dir.path().join(".fallowrc.json"),
1328 r#"{"extends": "npm:nonexistent-package"}"#,
1329 )
1330 .unwrap();
1331
1332 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1333 assert!(result.is_err());
1334 let err_msg = format!("{}", result.unwrap_err());
1335 assert!(
1336 err_msg.contains("not found"),
1337 "Expected 'not found' error, got: {err_msg}"
1338 );
1339 assert!(
1340 err_msg.contains("nonexistent-package"),
1341 "Expected package name in error, got: {err_msg}"
1342 );
1343 assert!(
1344 err_msg.contains("install it"),
1345 "Expected install hint in error, got: {err_msg}"
1346 );
1347 }
1348
1349 #[test]
1350 fn extends_npm_no_config_in_package_errors() {
1351 let dir = test_dir("npm-no-config");
1352 let pkg_dir = dir.path().join("node_modules/empty-pkg");
1353 std::fs::create_dir_all(&pkg_dir).unwrap();
1354 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1356
1357 std::fs::write(
1358 dir.path().join(".fallowrc.json"),
1359 r#"{"extends": "npm:empty-pkg"}"#,
1360 )
1361 .unwrap();
1362
1363 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1364 assert!(result.is_err());
1365 let err_msg = format!("{}", result.unwrap_err());
1366 assert!(
1367 err_msg.contains("No fallow config found"),
1368 "Expected 'No fallow config found' error, got: {err_msg}"
1369 );
1370 }
1371
1372 #[test]
1373 fn extends_npm_missing_subpath_errors() {
1374 let dir = test_dir("npm-missing-sub");
1375 let pkg_dir = dir.path().join("node_modules/@co/config");
1376 std::fs::create_dir_all(&pkg_dir).unwrap();
1377
1378 std::fs::write(
1379 dir.path().join(".fallowrc.json"),
1380 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1381 )
1382 .unwrap();
1383
1384 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1385 assert!(result.is_err());
1386 let err_msg = format!("{}", result.unwrap_err());
1387 assert!(
1388 err_msg.contains("nonexistent.json"),
1389 "Expected subpath in error, got: {err_msg}"
1390 );
1391 }
1392
1393 #[test]
1394 fn extends_npm_empty_specifier_errors() {
1395 let dir = test_dir("npm-empty");
1396 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1397
1398 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1399 assert!(result.is_err());
1400 let err_msg = format!("{}", result.unwrap_err());
1401 assert!(
1402 err_msg.contains("Empty npm specifier"),
1403 "Expected 'Empty npm specifier' error, got: {err_msg}"
1404 );
1405 }
1406
1407 #[test]
1408 fn extends_npm_space_after_colon_trimmed() {
1409 let dir = test_dir("npm-space");
1410 create_npm_package(
1411 dir.path(),
1412 "fallow-config-acme",
1413 r#"{"rules": {"unused-files": "warn"}}"#,
1414 );
1415 std::fs::write(
1417 dir.path().join(".fallowrc.json"),
1418 r#"{"extends": "npm: fallow-config-acme"}"#,
1419 )
1420 .unwrap();
1421
1422 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1423 assert_eq!(config.rules.unused_files, Severity::Warn);
1424 }
1425
1426 #[test]
1427 fn extends_npm_exports_node_condition() {
1428 let dir = test_dir("npm-node-cond");
1429 let pkg_dir = dir.path().join("node_modules/node-config");
1430 std::fs::create_dir_all(&pkg_dir).unwrap();
1431 std::fs::write(
1432 pkg_dir.join("package.json"),
1433 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1434 )
1435 .unwrap();
1436 std::fs::write(
1437 pkg_dir.join("node.json"),
1438 r#"{"rules": {"unused-files": "off"}}"#,
1439 )
1440 .unwrap();
1441
1442 std::fs::write(
1443 dir.path().join(".fallowrc.json"),
1444 r#"{"extends": "npm:node-config"}"#,
1445 )
1446 .unwrap();
1447
1448 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1449 assert_eq!(config.rules.unused_files, Severity::Off);
1450 }
1451
1452 #[test]
1455 fn parse_npm_specifier_unscoped() {
1456 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1457 }
1458
1459 #[test]
1460 fn parse_npm_specifier_unscoped_with_subpath() {
1461 assert_eq!(
1462 parse_npm_specifier("my-config/strict.json"),
1463 ("my-config", Some("strict.json"))
1464 );
1465 }
1466
1467 #[test]
1468 fn parse_npm_specifier_scoped() {
1469 assert_eq!(
1470 parse_npm_specifier("@company/fallow-config"),
1471 ("@company/fallow-config", None)
1472 );
1473 }
1474
1475 #[test]
1476 fn parse_npm_specifier_scoped_with_subpath() {
1477 assert_eq!(
1478 parse_npm_specifier("@company/fallow-config/strict.json"),
1479 ("@company/fallow-config", Some("strict.json"))
1480 );
1481 }
1482
1483 #[test]
1484 fn parse_npm_specifier_scoped_with_nested_subpath() {
1485 assert_eq!(
1486 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1487 ("@company/fallow-config", Some("presets/strict.json"))
1488 );
1489 }
1490
1491 #[test]
1494 fn extends_npm_subpath_traversal_rejected() {
1495 let dir = test_dir("npm-traversal-sub");
1496 let pkg_dir = dir.path().join("node_modules/evil-pkg");
1497 std::fs::create_dir_all(&pkg_dir).unwrap();
1498 std::fs::write(
1500 dir.path().join("secret.json"),
1501 r#"{"entry": ["stolen.ts"]}"#,
1502 )
1503 .unwrap();
1504
1505 std::fs::write(
1506 dir.path().join(".fallowrc.json"),
1507 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1508 )
1509 .unwrap();
1510
1511 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1512 assert!(result.is_err());
1513 let err_msg = format!("{}", result.unwrap_err());
1514 assert!(
1515 err_msg.contains("traversal") || err_msg.contains("not found"),
1516 "Expected traversal or not-found error, got: {err_msg}"
1517 );
1518 }
1519
1520 #[test]
1521 fn extends_npm_dotdot_package_name_rejected() {
1522 let dir = test_dir("npm-dotdot-name");
1523 std::fs::write(
1524 dir.path().join(".fallowrc.json"),
1525 r#"{"extends": "npm:../relative"}"#,
1526 )
1527 .unwrap();
1528
1529 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1530 assert!(result.is_err());
1531 let err_msg = format!("{}", result.unwrap_err());
1532 assert!(
1533 err_msg.contains("path traversal"),
1534 "Expected 'path traversal' error, got: {err_msg}"
1535 );
1536 }
1537
1538 #[test]
1539 fn extends_npm_scoped_without_name_rejected() {
1540 let dir = test_dir("npm-scope-only");
1541 std::fs::write(
1542 dir.path().join(".fallowrc.json"),
1543 r#"{"extends": "npm:@scope"}"#,
1544 )
1545 .unwrap();
1546
1547 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1548 assert!(result.is_err());
1549 let err_msg = format!("{}", result.unwrap_err());
1550 assert!(
1551 err_msg.contains("@scope/name"),
1552 "Expected scoped name format error, got: {err_msg}"
1553 );
1554 }
1555
1556 #[test]
1557 fn extends_npm_malformed_package_json_errors() {
1558 let dir = test_dir("npm-bad-pkgjson");
1559 let pkg_dir = dir.path().join("node_modules/bad-pkg");
1560 std::fs::create_dir_all(&pkg_dir).unwrap();
1561 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1562
1563 std::fs::write(
1564 dir.path().join(".fallowrc.json"),
1565 r#"{"extends": "npm:bad-pkg"}"#,
1566 )
1567 .unwrap();
1568
1569 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1570 assert!(result.is_err());
1571 let err_msg = format!("{}", result.unwrap_err());
1572 assert!(
1573 err_msg.contains("Failed to parse"),
1574 "Expected parse error, got: {err_msg}"
1575 );
1576 }
1577
1578 #[test]
1579 fn extends_npm_exports_traversal_rejected() {
1580 let dir = test_dir("npm-exports-escape");
1581 let pkg_dir = dir.path().join("node_modules/evil-exports");
1582 std::fs::create_dir_all(&pkg_dir).unwrap();
1583 std::fs::write(
1584 pkg_dir.join("package.json"),
1585 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1586 )
1587 .unwrap();
1588 std::fs::write(
1590 dir.path().join("secret.json"),
1591 r#"{"entry": ["stolen.ts"]}"#,
1592 )
1593 .unwrap();
1594
1595 std::fs::write(
1596 dir.path().join(".fallowrc.json"),
1597 r#"{"extends": "npm:evil-exports"}"#,
1598 )
1599 .unwrap();
1600
1601 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1602 assert!(result.is_err());
1603 let err_msg = format!("{}", result.unwrap_err());
1604 assert!(
1605 err_msg.contains("traversal"),
1606 "Expected traversal error, got: {err_msg}"
1607 );
1608 }
1609
1610 #[test]
1613 fn deep_merge_scalar_overlay_replaces_base() {
1614 let mut base = serde_json::json!("hello");
1615 deep_merge_json(&mut base, serde_json::json!("world"));
1616 assert_eq!(base, serde_json::json!("world"));
1617 }
1618
1619 #[test]
1620 fn deep_merge_array_overlay_replaces_base() {
1621 let mut base = serde_json::json!(["a", "b"]);
1622 deep_merge_json(&mut base, serde_json::json!(["c"]));
1623 assert_eq!(base, serde_json::json!(["c"]));
1624 }
1625
1626 #[test]
1627 fn deep_merge_nested_object_merge() {
1628 let mut base = serde_json::json!({
1629 "level1": {
1630 "level2": {
1631 "a": 1,
1632 "b": 2
1633 }
1634 }
1635 });
1636 let overlay = serde_json::json!({
1637 "level1": {
1638 "level2": {
1639 "b": 99,
1640 "c": 3
1641 }
1642 }
1643 });
1644 deep_merge_json(&mut base, overlay);
1645 assert_eq!(base["level1"]["level2"]["a"], 1);
1646 assert_eq!(base["level1"]["level2"]["b"], 99);
1647 assert_eq!(base["level1"]["level2"]["c"], 3);
1648 }
1649
1650 #[test]
1651 fn deep_merge_overlay_adds_new_fields() {
1652 let mut base = serde_json::json!({"existing": true});
1653 let overlay = serde_json::json!({"new_field": "added", "another": 42});
1654 deep_merge_json(&mut base, overlay);
1655 assert_eq!(base["existing"], true);
1656 assert_eq!(base["new_field"], "added");
1657 assert_eq!(base["another"], 42);
1658 }
1659
1660 #[test]
1661 fn deep_merge_null_overlay_replaces_object() {
1662 let mut base = serde_json::json!({"key": "value"});
1663 deep_merge_json(&mut base, serde_json::json!(null));
1664 assert_eq!(base, serde_json::json!(null));
1665 }
1666
1667 #[test]
1668 fn deep_merge_empty_object_overlay_preserves_base() {
1669 let mut base = serde_json::json!({"a": 1, "b": 2});
1670 deep_merge_json(&mut base, serde_json::json!({}));
1671 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1672 }
1673
1674 #[test]
1677 fn rules_severity_error_warn_off_from_json() {
1678 let json_str = r#"{
1679 "rules": {
1680 "unused-files": "error",
1681 "unused-exports": "warn",
1682 "unused-types": "off"
1683 }
1684 }"#;
1685 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1686 assert_eq!(config.rules.unused_files, Severity::Error);
1687 assert_eq!(config.rules.unused_exports, Severity::Warn);
1688 assert_eq!(config.rules.unused_types, Severity::Off);
1689 }
1690
1691 #[test]
1692 fn rules_omitted_default_to_error() {
1693 let json_str = r#"{
1694 "rules": {
1695 "unused-files": "warn"
1696 }
1697 }"#;
1698 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1699 assert_eq!(config.rules.unused_files, Severity::Warn);
1700 assert_eq!(config.rules.unused_exports, Severity::Error);
1702 assert_eq!(config.rules.unused_types, Severity::Error);
1703 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1704 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1705 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
1706 assert_eq!(config.rules.duplicate_exports, Severity::Error);
1707 assert_eq!(config.rules.circular_dependencies, Severity::Error);
1708 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
1710 }
1711
1712 #[test]
1715 fn find_and_load_returns_none_when_no_config() {
1716 let dir = test_dir("find-none");
1717 std::fs::create_dir(dir.path().join(".git")).unwrap();
1719
1720 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1721 assert!(result.is_none());
1722 }
1723
1724 #[test]
1725 fn find_and_load_finds_fallowrc_json() {
1726 let dir = test_dir("find-json");
1727 std::fs::create_dir(dir.path().join(".git")).unwrap();
1728 std::fs::write(
1729 dir.path().join(".fallowrc.json"),
1730 r#"{"entry": ["src/main.ts"]}"#,
1731 )
1732 .unwrap();
1733
1734 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1735 assert_eq!(config.entry, vec!["src/main.ts"]);
1736 assert!(path.ends_with(".fallowrc.json"));
1737 }
1738
1739 #[test]
1740 fn find_and_load_prefers_fallowrc_json_over_toml() {
1741 let dir = test_dir("find-priority");
1742 std::fs::create_dir(dir.path().join(".git")).unwrap();
1743 std::fs::write(
1744 dir.path().join(".fallowrc.json"),
1745 r#"{"entry": ["from-json.ts"]}"#,
1746 )
1747 .unwrap();
1748 std::fs::write(
1749 dir.path().join("fallow.toml"),
1750 "entry = [\"from-toml.ts\"]\n",
1751 )
1752 .unwrap();
1753
1754 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1755 assert_eq!(config.entry, vec!["from-json.ts"]);
1756 assert!(path.ends_with(".fallowrc.json"));
1757 }
1758
1759 #[test]
1760 fn find_and_load_finds_fallow_toml() {
1761 let dir = test_dir("find-toml");
1762 std::fs::create_dir(dir.path().join(".git")).unwrap();
1763 std::fs::write(
1764 dir.path().join("fallow.toml"),
1765 "entry = [\"src/index.ts\"]\n",
1766 )
1767 .unwrap();
1768
1769 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1770 assert_eq!(config.entry, vec!["src/index.ts"]);
1771 }
1772
1773 #[test]
1774 fn find_and_load_stops_at_git_dir() {
1775 let dir = test_dir("find-git-stop");
1776 let sub = dir.path().join("sub");
1777 std::fs::create_dir(&sub).unwrap();
1778 std::fs::create_dir(dir.path().join(".git")).unwrap();
1780 let result = FallowConfig::find_and_load(&sub).unwrap();
1784 assert!(result.is_none());
1785 }
1786
1787 #[test]
1788 fn find_and_load_stops_at_package_json() {
1789 let dir = test_dir("find-pkg-stop");
1790 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
1791
1792 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1793 assert!(result.is_none());
1794 }
1795
1796 #[test]
1797 fn find_and_load_returns_error_for_invalid_config() {
1798 let dir = test_dir("find-invalid");
1799 std::fs::create_dir(dir.path().join(".git")).unwrap();
1800 std::fs::write(
1801 dir.path().join(".fallowrc.json"),
1802 r"{ this is not valid json }",
1803 )
1804 .unwrap();
1805
1806 let result = FallowConfig::find_and_load(dir.path());
1807 assert!(result.is_err());
1808 }
1809
1810 #[test]
1813 fn load_toml_config_file() {
1814 let dir = test_dir("toml-config");
1815 let config_path = dir.path().join("fallow.toml");
1816 std::fs::write(
1817 &config_path,
1818 r#"
1819entry = ["src/index.ts"]
1820ignorePatterns = ["dist/**"]
1821
1822[rules]
1823unused-files = "warn"
1824
1825[duplicates]
1826minTokens = 100
1827"#,
1828 )
1829 .unwrap();
1830
1831 let config = FallowConfig::load(&config_path).unwrap();
1832 assert_eq!(config.entry, vec!["src/index.ts"]);
1833 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1834 assert_eq!(config.rules.unused_files, Severity::Warn);
1835 assert_eq!(config.duplicates.min_tokens, 100);
1836 }
1837
1838 #[test]
1841 fn extends_absolute_path_rejected() {
1842 let dir = test_dir("extends-absolute");
1843
1844 #[cfg(unix)]
1846 let abs_path = "/absolute/path/config.json";
1847 #[cfg(windows)]
1848 let abs_path = "C:\\absolute\\path\\config.json";
1849
1850 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1851 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1852
1853 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1854 assert!(result.is_err());
1855 let err_msg = format!("{}", result.unwrap_err());
1856 assert!(
1857 err_msg.contains("must be relative"),
1858 "Expected 'must be relative' error, got: {err_msg}"
1859 );
1860 }
1861
1862 #[test]
1865 fn resolve_production_mode_disables_dev_deps() {
1866 let config = FallowConfig {
1867 schema: None,
1868 extends: vec![],
1869 entry: vec![],
1870 ignore_patterns: vec![],
1871 framework: vec![],
1872 workspaces: None,
1873 ignore_dependencies: vec![],
1874 ignore_exports: vec![],
1875 duplicates: DuplicatesConfig::default(),
1876 health: HealthConfig::default(),
1877 rules: RulesConfig::default(),
1878 boundaries: BoundaryConfig::default(),
1879 production: true,
1880 plugins: vec![],
1881 overrides: vec![],
1882 regression: None,
1883 };
1884 let resolved = config.resolve(
1885 PathBuf::from("/tmp/test"),
1886 OutputFormat::Human,
1887 4,
1888 false,
1889 true,
1890 );
1891 assert!(resolved.production);
1892 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1893 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1894 assert_eq!(resolved.rules.unused_files, Severity::Error);
1896 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1897 }
1898
1899 #[test]
1902 fn config_format_defaults_to_toml_for_unknown() {
1903 assert!(matches!(
1904 ConfigFormat::from_path(Path::new("config.yaml")),
1905 ConfigFormat::Toml
1906 ));
1907 assert!(matches!(
1908 ConfigFormat::from_path(Path::new("config")),
1909 ConfigFormat::Toml
1910 ));
1911 }
1912
1913 #[test]
1916 fn deep_merge_object_over_scalar_replaces() {
1917 let mut base = serde_json::json!("just a string");
1918 let overlay = serde_json::json!({"key": "value"});
1919 deep_merge_json(&mut base, overlay);
1920 assert_eq!(base, serde_json::json!({"key": "value"}));
1921 }
1922
1923 #[test]
1924 fn deep_merge_scalar_over_object_replaces() {
1925 let mut base = serde_json::json!({"key": "value"});
1926 let overlay = serde_json::json!(42);
1927 deep_merge_json(&mut base, overlay);
1928 assert_eq!(base, serde_json::json!(42));
1929 }
1930
1931 #[test]
1934 fn extends_non_string_non_array_ignored() {
1935 let dir = test_dir("extends-numeric");
1936 std::fs::write(
1937 dir.path().join(".fallowrc.json"),
1938 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1939 )
1940 .unwrap();
1941
1942 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1944 assert_eq!(config.entry, vec!["src/index.ts"]);
1945 }
1946
1947 #[test]
1950 fn extends_multiple_bases_later_wins() {
1951 let dir = test_dir("extends-multi-base");
1952
1953 std::fs::write(
1954 dir.path().join("base-a.json"),
1955 r#"{"rules": {"unused-files": "warn"}}"#,
1956 )
1957 .unwrap();
1958 std::fs::write(
1959 dir.path().join("base-b.json"),
1960 r#"{"rules": {"unused-files": "off"}}"#,
1961 )
1962 .unwrap();
1963 std::fs::write(
1964 dir.path().join(".fallowrc.json"),
1965 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1966 )
1967 .unwrap();
1968
1969 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1970 assert_eq!(config.rules.unused_files, Severity::Off);
1972 }
1973
1974 #[test]
1977 fn fallow_config_deserialize_production() {
1978 let json_str = r#"{"production": true}"#;
1979 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1980 assert!(config.production);
1981 }
1982
1983 #[test]
1984 fn fallow_config_production_defaults_false() {
1985 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1986 assert!(!config.production);
1987 }
1988
1989 #[test]
1992 fn package_json_optional_dependency_names() {
1993 let pkg: PackageJson = serde_json::from_str(
1994 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1995 )
1996 .unwrap();
1997 let opt = pkg.optional_dependency_names();
1998 assert_eq!(opt.len(), 2);
1999 assert!(opt.contains(&"fsevents".to_string()));
2000 assert!(opt.contains(&"chokidar".to_string()));
2001 }
2002
2003 #[test]
2004 fn package_json_optional_deps_empty_when_missing() {
2005 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2006 assert!(pkg.optional_dependency_names().is_empty());
2007 }
2008
2009 #[test]
2012 fn find_config_path_returns_fallowrc_json() {
2013 let dir = test_dir("find-path-json");
2014 std::fs::create_dir(dir.path().join(".git")).unwrap();
2015 std::fs::write(
2016 dir.path().join(".fallowrc.json"),
2017 r#"{"entry": ["src/main.ts"]}"#,
2018 )
2019 .unwrap();
2020
2021 let path = FallowConfig::find_config_path(dir.path());
2022 assert!(path.is_some());
2023 assert!(path.unwrap().ends_with(".fallowrc.json"));
2024 }
2025
2026 #[test]
2027 fn find_config_path_returns_fallow_toml() {
2028 let dir = test_dir("find-path-toml");
2029 std::fs::create_dir(dir.path().join(".git")).unwrap();
2030 std::fs::write(
2031 dir.path().join("fallow.toml"),
2032 "entry = [\"src/main.ts\"]\n",
2033 )
2034 .unwrap();
2035
2036 let path = FallowConfig::find_config_path(dir.path());
2037 assert!(path.is_some());
2038 assert!(path.unwrap().ends_with("fallow.toml"));
2039 }
2040
2041 #[test]
2042 fn find_config_path_returns_dot_fallow_toml() {
2043 let dir = test_dir("find-path-dot-toml");
2044 std::fs::create_dir(dir.path().join(".git")).unwrap();
2045 std::fs::write(
2046 dir.path().join(".fallow.toml"),
2047 "entry = [\"src/main.ts\"]\n",
2048 )
2049 .unwrap();
2050
2051 let path = FallowConfig::find_config_path(dir.path());
2052 assert!(path.is_some());
2053 assert!(path.unwrap().ends_with(".fallow.toml"));
2054 }
2055
2056 #[test]
2057 fn find_config_path_prefers_json_over_toml() {
2058 let dir = test_dir("find-path-priority");
2059 std::fs::create_dir(dir.path().join(".git")).unwrap();
2060 std::fs::write(
2061 dir.path().join(".fallowrc.json"),
2062 r#"{"entry": ["json.ts"]}"#,
2063 )
2064 .unwrap();
2065 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2066
2067 let path = FallowConfig::find_config_path(dir.path());
2068 assert!(path.unwrap().ends_with(".fallowrc.json"));
2069 }
2070
2071 #[test]
2072 fn find_config_path_none_when_no_config() {
2073 let dir = test_dir("find-path-none");
2074 std::fs::create_dir(dir.path().join(".git")).unwrap();
2075
2076 let path = FallowConfig::find_config_path(dir.path());
2077 assert!(path.is_none());
2078 }
2079
2080 #[test]
2081 fn find_config_path_stops_at_package_json() {
2082 let dir = test_dir("find-path-pkg-stop");
2083 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
2084
2085 let path = FallowConfig::find_config_path(dir.path());
2086 assert!(path.is_none());
2087 }
2088
2089 #[test]
2092 fn extends_toml_base() {
2093 let dir = test_dir("extends-toml");
2094
2095 std::fs::write(
2096 dir.path().join("base.json"),
2097 r#"{"rules": {"unused-files": "warn"}}"#,
2098 )
2099 .unwrap();
2100 std::fs::write(
2101 dir.path().join("fallow.toml"),
2102 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2103 )
2104 .unwrap();
2105
2106 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2107 assert_eq!(config.rules.unused_files, Severity::Warn);
2108 assert_eq!(config.entry, vec!["src/index.ts"]);
2109 }
2110
2111 #[test]
2114 fn deep_merge_boolean_overlay() {
2115 let mut base = serde_json::json!(true);
2116 deep_merge_json(&mut base, serde_json::json!(false));
2117 assert_eq!(base, serde_json::json!(false));
2118 }
2119
2120 #[test]
2121 fn deep_merge_number_overlay() {
2122 let mut base = serde_json::json!(42);
2123 deep_merge_json(&mut base, serde_json::json!(99));
2124 assert_eq!(base, serde_json::json!(99));
2125 }
2126
2127 #[test]
2128 fn deep_merge_disjoint_objects() {
2129 let mut base = serde_json::json!({"a": 1});
2130 let overlay = serde_json::json!({"b": 2});
2131 deep_merge_json(&mut base, overlay);
2132 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2133 }
2134
2135 #[test]
2138 fn max_extends_depth_is_reasonable() {
2139 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2140 }
2141
2142 #[test]
2145 fn config_names_has_three_entries() {
2146 assert_eq!(CONFIG_NAMES.len(), 3);
2147 for name in CONFIG_NAMES {
2149 assert!(
2150 name.starts_with('.') || name.starts_with("fallow"),
2151 "unexpected config name: {name}"
2152 );
2153 }
2154 }
2155
2156 #[test]
2159 fn package_json_peer_dependency_names() {
2160 let pkg: PackageJson = serde_json::from_str(
2161 r#"{
2162 "dependencies": {"react": "^18"},
2163 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2164 }"#,
2165 )
2166 .unwrap();
2167 let all = pkg.all_dependency_names();
2168 assert!(all.contains(&"react".to_string()));
2169 assert!(all.contains(&"react-dom".to_string()));
2170 assert!(all.contains(&"react-native".to_string()));
2171 }
2172
2173 #[test]
2176 fn package_json_scripts_field() {
2177 let pkg: PackageJson = serde_json::from_str(
2178 r#"{
2179 "scripts": {
2180 "build": "tsc",
2181 "test": "vitest",
2182 "lint": "fallow check"
2183 }
2184 }"#,
2185 )
2186 .unwrap();
2187 let scripts = pkg.scripts.unwrap();
2188 assert_eq!(scripts.len(), 3);
2189 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2190 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2191 }
2192
2193 #[test]
2196 fn extends_toml_chain() {
2197 let dir = test_dir("extends-toml-chain");
2198
2199 std::fs::write(
2200 dir.path().join("base.json"),
2201 r#"{"entry": ["src/base.ts"]}"#,
2202 )
2203 .unwrap();
2204 std::fs::write(
2205 dir.path().join("middle.json"),
2206 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2207 )
2208 .unwrap();
2209 std::fs::write(
2210 dir.path().join("fallow.toml"),
2211 "extends = [\"middle.json\"]\n",
2212 )
2213 .unwrap();
2214
2215 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2216 assert_eq!(config.entry, vec!["src/base.ts"]);
2217 assert_eq!(config.rules.unused_files, Severity::Off);
2218 }
2219
2220 #[test]
2223 fn find_and_load_walks_up_directories() {
2224 let dir = test_dir("find-walk-up");
2225 let sub = dir.path().join("src").join("deep");
2226 std::fs::create_dir_all(&sub).unwrap();
2227 std::fs::write(
2228 dir.path().join(".fallowrc.json"),
2229 r#"{"entry": ["src/main.ts"]}"#,
2230 )
2231 .unwrap();
2232 std::fs::create_dir(dir.path().join(".git")).unwrap();
2234
2235 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2236 assert_eq!(config.entry, vec!["src/main.ts"]);
2237 assert!(path.ends_with(".fallowrc.json"));
2238 }
2239
2240 #[test]
2243 fn json_schema_contains_entry_field() {
2244 let schema = FallowConfig::json_schema();
2245 let obj = schema.as_object().unwrap();
2246 let props = obj.get("properties").and_then(|v| v.as_object());
2247 assert!(props.is_some(), "schema should have properties");
2248 assert!(
2249 props.unwrap().contains_key("entry"),
2250 "schema should contain entry property"
2251 );
2252 }
2253
2254 #[test]
2257 fn fallow_config_json_duplicates_all_fields() {
2258 let json = r#"{
2259 "duplicates": {
2260 "enabled": true,
2261 "mode": "semantic",
2262 "minTokens": 200,
2263 "minLines": 20,
2264 "threshold": 10.5,
2265 "ignore": ["**/*.test.ts"],
2266 "skipLocal": true,
2267 "crossLanguage": true,
2268 "normalization": {
2269 "ignoreIdentifiers": true,
2270 "ignoreStringValues": false
2271 }
2272 }
2273 }"#;
2274 let config: FallowConfig = serde_json::from_str(json).unwrap();
2275 assert!(config.duplicates.enabled);
2276 assert_eq!(
2277 config.duplicates.mode,
2278 crate::config::DetectionMode::Semantic
2279 );
2280 assert_eq!(config.duplicates.min_tokens, 200);
2281 assert_eq!(config.duplicates.min_lines, 20);
2282 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2283 assert!(config.duplicates.skip_local);
2284 assert!(config.duplicates.cross_language);
2285 assert_eq!(
2286 config.duplicates.normalization.ignore_identifiers,
2287 Some(true)
2288 );
2289 assert_eq!(
2290 config.duplicates.normalization.ignore_string_values,
2291 Some(false)
2292 );
2293 }
2294}