1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9pub(super) const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
14
15pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
16
17const NPM_PREFIX: &str = "npm:";
19
20const HTTPS_PREFIX: &str = "https://";
22
23const HTTP_PREFIX: &str = "http://";
25
26const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
28
29pub(super) enum ConfigFormat {
31 Toml,
32 Json,
33}
34
35impl ConfigFormat {
36 pub(super) fn from_path(path: &Path) -> Self {
37 match path.extension().and_then(|e| e.to_str()) {
38 Some("json") => Self::Json,
39 _ => Self::Toml,
40 }
41 }
42}
43
44pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
47 match (base, overlay) {
48 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
49 for (key, value) in overlay_map {
50 if let Some(base_value) = base_map.get_mut(&key) {
51 deep_merge_json(base_value, value);
52 } else {
53 base_map.insert(key, value);
54 }
55 }
56 }
57 (base, overlay) => {
58 *base = overlay;
59 }
60 }
61}
62
63pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
64 let content = std::fs::read_to_string(path)
65 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
66
67 match ConfigFormat::from_path(path) {
68 ConfigFormat::Toml => {
69 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
70 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
71 })?;
72 serde_json::to_value(toml_value).map_err(|e| {
73 miette::miette!(
74 "Failed to convert TOML to JSON for {}: {}",
75 path.display(),
76 e
77 )
78 })
79 }
80 ConfigFormat::Json => {
81 let mut stripped = String::new();
82 json_comments::StripComments::new(content.as_bytes())
83 .read_to_string(&mut stripped)
84 .map_err(|e| {
85 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
86 })?;
87 serde_json::from_str(&stripped).map_err(|e| {
88 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
89 })
90 }
91 }
92}
93
94fn resolve_confined(
99 base_dir: &Path,
100 resolved: &Path,
101 context: &str,
102 source_config: &Path,
103) -> Result<PathBuf, miette::Report> {
104 let canonical_base = dunce::canonicalize(base_dir)
105 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
106 let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
107 miette::miette!(
108 "Config file not found: {} ({}, referenced from {}): {}",
109 resolved.display(),
110 context,
111 source_config.display(),
112 e
113 )
114 })?;
115 if !canonical_file.starts_with(&canonical_base) {
116 return Err(miette::miette!(
117 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
118 resolved.display(),
119 base_dir.display(),
120 context,
121 source_config.display()
122 ));
123 }
124 Ok(canonical_file)
125}
126
127fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
129 if name.starts_with('@') && !name.contains('/') {
130 return Err(miette::miette!(
131 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
132 name,
133 source_config.display()
134 ));
135 }
136 if name.split('/').any(|c| c == ".." || c == ".") {
137 return Err(miette::miette!(
138 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
139 name,
140 source_config.display()
141 ));
142 }
143 Ok(())
144}
145
146fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
153 if specifier.starts_with('@') {
154 let mut slashes = 0;
157 for (i, ch) in specifier.char_indices() {
158 if ch == '/' {
159 slashes += 1;
160 if slashes == 2 {
161 return (&specifier[..i], Some(&specifier[i + 1..]));
162 }
163 }
164 }
165 (specifier, None)
167 } else if let Some(slash) = specifier.find('/') {
168 (&specifier[..slash], Some(&specifier[slash + 1..]))
169 } else {
170 (specifier, None)
171 }
172}
173
174fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
181 let exports = pkg.get("exports")?;
182 match exports {
183 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
184 serde_json::Value::Object(map) => {
185 let dot_export = map.get(".")?;
186 match dot_export {
187 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
188 serde_json::Value::Object(conditions) => {
189 for key in ["default", "node", "import", "require"] {
190 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
191 return Some(package_dir.join(s.as_str()));
192 }
193 }
194 None
195 }
196 _ => None,
197 }
198 }
199 _ => None,
202 }
203}
204
205fn find_config_in_npm_package(
215 package_dir: &Path,
216 source_config: &Path,
217) -> Result<PathBuf, miette::Report> {
218 let pkg_json_path = package_dir.join("package.json");
219 if pkg_json_path.exists() {
220 let content = std::fs::read_to_string(&pkg_json_path)
221 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
222 let pkg: serde_json::Value = serde_json::from_str(&content)
223 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
224 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
225 && config_path.exists()
226 {
227 return resolve_confined(
228 package_dir,
229 &config_path,
230 "package.json exports",
231 source_config,
232 );
233 }
234 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
235 let main_path = package_dir.join(main);
236 if main_path.exists() {
237 return resolve_confined(
238 package_dir,
239 &main_path,
240 "package.json main",
241 source_config,
242 );
243 }
244 }
245 }
246
247 for config_name in CONFIG_NAMES {
248 let config_path = package_dir.join(config_name);
249 if config_path.exists() {
250 return resolve_confined(
251 package_dir,
252 &config_path,
253 "config name fallback",
254 source_config,
255 );
256 }
257 }
258
259 Err(miette::miette!(
260 "No fallow config found in npm package at {}. \
261 Expected package.json with main/exports pointing to a config file, \
262 or one of: {}",
263 package_dir.display(),
264 CONFIG_NAMES.join(", ")
265 ))
266}
267
268fn resolve_npm_package(
274 config_dir: &Path,
275 specifier: &str,
276 source_config: &Path,
277) -> Result<PathBuf, miette::Report> {
278 let specifier = specifier.trim();
279 if specifier.is_empty() {
280 return Err(miette::miette!(
281 "Empty npm specifier in extends (in {})",
282 source_config.display()
283 ));
284 }
285
286 let (package_name, subpath) = parse_npm_specifier(specifier);
287 validate_npm_package_name(package_name, source_config)?;
288
289 let mut dir = Some(config_dir);
290 while let Some(d) = dir {
291 let candidate = d.join("node_modules").join(package_name);
292 if candidate.is_dir() {
293 return if let Some(sub) = subpath {
294 let file = candidate.join(sub);
295 if file.exists() {
296 resolve_confined(
297 &candidate,
298 &file,
299 &format!("subpath '{sub}'"),
300 source_config,
301 )
302 } else {
303 Err(miette::miette!(
304 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
305 file.display(),
306 sub,
307 candidate.display(),
308 source_config.display()
309 ))
310 }
311 } else {
312 find_config_in_npm_package(&candidate, source_config)
313 };
314 }
315 dir = d.parent();
316 }
317
318 Err(miette::miette!(
319 "npm package '{}' not found. \
320 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
321 If this package should be available, install it and ensure it is listed in your project's dependencies",
322 package_name,
323 package_name,
324 config_dir.display(),
325 source_config.display()
326 ))
327}
328
329fn normalize_url_for_dedup(url: &str) -> String {
336 let Some((scheme, rest)) = url.split_once("://") else {
338 return url.to_string();
339 };
340 let scheme = scheme.to_ascii_lowercase();
341
342 let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
344 let authority = authority.to_ascii_lowercase();
345
346 let authority = authority.strip_suffix(":443").unwrap_or(&authority);
348
349 let path = path.split_once('#').map_or(path, |(p, _)| p);
351 let path = path.split_once('?').map_or(path, |(p, _)| p);
352 let path = path.strip_suffix('/').unwrap_or(path);
353
354 if path.is_empty() {
355 format!("{scheme}://{authority}")
356 } else {
357 format!("{scheme}://{authority}/{path}")
358 }
359}
360
361fn url_timeout() -> Duration {
366 std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
367 .ok()
368 .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
369 .map_or(
370 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
371 Duration::from_secs,
372 )
373}
374
375const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
378
379fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
384 let timeout = url_timeout();
385 let agent = ureq::Agent::config_builder()
386 .timeout_global(Some(timeout))
387 .https_only(true)
388 .build()
389 .new_agent();
390
391 let mut response = agent.get(url).call().map_err(|e| {
392 miette::miette!(
393 "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
394 If this URL is unavailable, use a local path or npm: specifier instead"
395 )
396 })?;
397
398 let body = response
399 .body_mut()
400 .with_config()
401 .limit(MAX_URL_CONFIG_BYTES)
402 .read_to_string()
403 .map_err(|e| {
404 miette::miette!(
405 "Failed to read response body from {url} (referenced from {source}): {e}"
406 )
407 })?;
408
409 let mut stripped = String::new();
411 json_comments::StripComments::new(body.as_bytes())
412 .read_to_string(&mut stripped)
413 .map_err(|e| {
414 miette::miette!(
415 "Failed to strip comments from remote config {url} (referenced from {source}): {e}"
416 )
417 })?;
418
419 serde_json::from_str(&stripped).map_err(|e| {
420 miette::miette!(
421 "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
422 Only JSON/JSONC is supported for URL-sourced configs"
423 )
424 })
425}
426
427fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
429 value
430 .as_object_mut()
431 .and_then(|obj| obj.remove("extends"))
432 .and_then(|v| match v {
433 serde_json::Value::Array(arr) => Some(
434 arr.into_iter()
435 .filter_map(|v| v.as_str().map(String::from))
436 .collect::<Vec<_>>(),
437 ),
438 serde_json::Value::String(s) => Some(vec![s]),
439 _ => None,
440 })
441 .unwrap_or_default()
442}
443
444fn resolve_url_extends(
449 url: &str,
450 visited: &mut FxHashSet<String>,
451 depth: usize,
452) -> Result<serde_json::Value, miette::Report> {
453 if depth >= MAX_EXTENDS_DEPTH {
454 return Err(miette::miette!(
455 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
456 ));
457 }
458
459 let normalized = normalize_url_for_dedup(url);
460 if !visited.insert(normalized) {
461 return Err(miette::miette!(
462 "Circular extends detected: {url} was already visited in the extends chain"
463 ));
464 }
465
466 let mut value = fetch_url_config(url, url)?;
467 let extends = extract_extends(&mut value);
468
469 if extends.is_empty() {
470 return Ok(value);
471 }
472
473 let mut merged = serde_json::Value::Object(serde_json::Map::new());
474
475 for entry in &extends {
476 let base = if entry.starts_with(HTTPS_PREFIX) {
477 resolve_url_extends(entry, visited, depth + 1)?
478 } else if entry.starts_with(HTTP_PREFIX) {
479 return Err(miette::miette!(
480 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
481 Change the URL to use https:// instead",
482 entry,
483 url
484 ));
485 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
486 let cwd = std::env::current_dir().map_err(|e| {
490 miette::miette!(
491 "Cannot resolve npm: specifier from URL-sourced config: \
492 failed to determine current directory: {e}"
493 )
494 })?;
495 tracing::warn!(
496 "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
497 current working directory for node_modules lookup"
498 );
499 let path_placeholder = PathBuf::from(url);
500 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
501 resolve_extends_file(&npm_path, visited, depth + 1)?
502 } else {
503 return Err(miette::miette!(
504 "Relative paths in 'extends' are not supported when the base config was \
505 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
506 instead. Got: '{entry}'"
507 ));
508 };
509 deep_merge_json(&mut merged, base);
510 }
511
512 deep_merge_json(&mut merged, value);
513 Ok(merged)
514}
515
516fn resolve_extends_file(
522 path: &Path,
523 visited: &mut FxHashSet<String>,
524 depth: usize,
525) -> Result<serde_json::Value, miette::Report> {
526 if depth >= MAX_EXTENDS_DEPTH {
527 return Err(miette::miette!(
528 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
529 path.display()
530 ));
531 }
532
533 let canonical = dunce::canonicalize(path).map_err(|e| {
534 miette::miette!(
535 "Config file not found or unresolvable: {}: {}",
536 path.display(),
537 e
538 )
539 })?;
540
541 if !visited.insert(canonical.to_string_lossy().into_owned()) {
542 return Err(miette::miette!(
543 "Circular extends detected: {} was already visited in the extends chain",
544 path.display()
545 ));
546 }
547
548 let mut value = parse_config_to_value(path)?;
549 let extends = extract_extends(&mut value);
550
551 if extends.is_empty() {
552 return Ok(value);
553 }
554
555 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
556 let mut merged = serde_json::Value::Object(serde_json::Map::new());
557
558 for extend_path_str in &extends {
559 let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
560 resolve_url_extends(extend_path_str, visited, depth + 1)?
561 } else if extend_path_str.starts_with(HTTP_PREFIX) {
562 return Err(miette::miette!(
563 "URL extends must use https://, got http:// URL '{}' (in {}). \
564 Change the URL to use https:// instead",
565 extend_path_str,
566 path.display()
567 ));
568 } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
569 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
570 resolve_extends_file(&npm_path, visited, depth + 1)?
571 } else {
572 if Path::new(extend_path_str).is_absolute() {
573 return Err(miette::miette!(
574 "extends paths must be relative, got absolute path: {} (in {})",
575 extend_path_str,
576 path.display()
577 ));
578 }
579 let p = config_dir.join(extend_path_str);
580 if !p.exists() {
581 return Err(miette::miette!(
582 "Extended config file not found: {} (referenced from {})",
583 p.display(),
584 path.display()
585 ));
586 }
587 resolve_extends_file(&p, visited, depth + 1)?
588 };
589 deep_merge_json(&mut merged, base);
590 }
591
592 deep_merge_json(&mut merged, value);
593 Ok(merged)
594}
595
596pub(super) fn resolve_extends(
600 path: &Path,
601 visited: &mut FxHashSet<String>,
602 depth: usize,
603) -> Result<serde_json::Value, miette::Report> {
604 resolve_extends_file(path, visited, depth)
605}
606
607impl FallowConfig {
608 pub fn load(path: &Path) -> Result<Self, miette::Report> {
621 let mut visited = FxHashSet::default();
622 let merged = resolve_extends(path, &mut visited, 0)?;
623
624 serde_json::from_value(merged).map_err(|e| {
625 miette::miette!(
626 "Failed to deserialize config from {}: {}",
627 path.display(),
628 e
629 )
630 })
631 }
632
633 #[must_use]
636 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
637 let mut dir = start;
638 loop {
639 for name in CONFIG_NAMES {
640 let candidate = dir.join(name);
641 if candidate.exists() {
642 return Some(candidate);
643 }
644 }
645 if dir.join(".git").exists() || dir.join("package.json").exists() {
646 break;
647 }
648 dir = dir.parent()?;
649 }
650 None
651 }
652
653 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
659 let mut dir = start;
660 loop {
661 for name in CONFIG_NAMES {
662 let candidate = dir.join(name);
663 if candidate.exists() {
664 match Self::load(&candidate) {
665 Ok(config) => return Ok(Some((config, candidate))),
666 Err(e) => {
667 return Err(format!("Failed to parse {}: {e}", candidate.display()));
668 }
669 }
670 }
671 }
672 if dir.join(".git").exists() || dir.join("package.json").exists() {
674 break;
675 }
676 dir = match dir.parent() {
677 Some(parent) => parent,
678 None => break,
679 };
680 }
681 Ok(None)
682 }
683
684 #[must_use]
686 pub fn json_schema() -> serde_json::Value {
687 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use std::io::Read as _;
694
695 use super::*;
696 use crate::PackageJson;
697 use crate::config::format::OutputFormat;
698 use crate::config::rules::Severity;
699
700 fn test_dir(_name: &str) -> tempfile::TempDir {
702 tempfile::tempdir().expect("create temp dir")
703 }
704
705 #[test]
706 fn fallow_config_deserialize_minimal() {
707 let toml_str = r#"
708entry = ["src/main.ts"]
709"#;
710 let config: FallowConfig = toml::from_str(toml_str).unwrap();
711 assert_eq!(config.entry, vec!["src/main.ts"]);
712 assert!(config.ignore_patterns.is_empty());
713 }
714
715 #[test]
716 fn fallow_config_deserialize_ignore_exports() {
717 let toml_str = r#"
718[[ignoreExports]]
719file = "src/types/*.ts"
720exports = ["*"]
721
722[[ignoreExports]]
723file = "src/constants.ts"
724exports = ["FOO", "BAR"]
725"#;
726 let config: FallowConfig = toml::from_str(toml_str).unwrap();
727 assert_eq!(config.ignore_exports.len(), 2);
728 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
729 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
730 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
731 }
732
733 #[test]
734 fn fallow_config_deserialize_ignore_dependencies() {
735 let toml_str = r#"
736ignoreDependencies = ["autoprefixer", "postcss"]
737"#;
738 let config: FallowConfig = toml::from_str(toml_str).unwrap();
739 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
740 }
741
742 #[test]
743 fn fallow_config_resolve_default_ignores() {
744 let config = FallowConfig::default();
745 let resolved = config.resolve(
746 PathBuf::from("/tmp/test"),
747 OutputFormat::Human,
748 4,
749 true,
750 true,
751 );
752
753 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
755 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
756 assert!(resolved.ignore_patterns.is_match("build/output.js"));
757 assert!(resolved.ignore_patterns.is_match(".git/config"));
758 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
759 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
760 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
761 }
762
763 #[test]
764 fn fallow_config_resolve_custom_ignores() {
765 let config = FallowConfig {
766 entry: vec!["src/**/*.ts".to_string()],
767 ignore_patterns: vec!["**/*.generated.ts".to_string()],
768 ..Default::default()
769 };
770 let resolved = config.resolve(
771 PathBuf::from("/tmp/test"),
772 OutputFormat::Json,
773 4,
774 false,
775 true,
776 );
777
778 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
779 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
780 assert!(matches!(resolved.output, OutputFormat::Json));
781 assert!(!resolved.no_cache);
782 }
783
784 #[test]
785 fn fallow_config_resolve_cache_dir() {
786 let config = FallowConfig::default();
787 let resolved = config.resolve(
788 PathBuf::from("/tmp/project"),
789 OutputFormat::Human,
790 4,
791 true,
792 true,
793 );
794 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
795 assert!(resolved.no_cache);
796 }
797
798 #[test]
799 fn package_json_entry_points_main() {
800 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
801 let entries = pkg.entry_points();
802 assert!(entries.contains(&"dist/index.js".to_string()));
803 }
804
805 #[test]
806 fn package_json_entry_points_module() {
807 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
808 let entries = pkg.entry_points();
809 assert!(entries.contains(&"dist/index.mjs".to_string()));
810 }
811
812 #[test]
813 fn package_json_entry_points_types() {
814 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
815 let entries = pkg.entry_points();
816 assert!(entries.contains(&"dist/index.d.ts".to_string()));
817 }
818
819 #[test]
820 fn package_json_entry_points_bin_string() {
821 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
822 let entries = pkg.entry_points();
823 assert!(entries.contains(&"bin/cli.js".to_string()));
824 }
825
826 #[test]
827 fn package_json_entry_points_bin_object() {
828 let pkg: PackageJson =
829 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
830 .unwrap();
831 let entries = pkg.entry_points();
832 assert!(entries.contains(&"bin/cli.js".to_string()));
833 assert!(entries.contains(&"bin/serve.js".to_string()));
834 }
835
836 #[test]
837 fn package_json_entry_points_exports_string() {
838 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
839 let entries = pkg.entry_points();
840 assert!(entries.contains(&"./dist/index.js".to_string()));
841 }
842
843 #[test]
844 fn package_json_entry_points_exports_object() {
845 let pkg: PackageJson = serde_json::from_str(
846 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
847 )
848 .unwrap();
849 let entries = pkg.entry_points();
850 assert!(entries.contains(&"./dist/index.mjs".to_string()));
851 assert!(entries.contains(&"./dist/index.cjs".to_string()));
852 }
853
854 #[test]
855 fn package_json_dependency_names() {
856 let pkg: PackageJson = serde_json::from_str(
857 r#"{
858 "dependencies": {"react": "^18", "lodash": "^4"},
859 "devDependencies": {"typescript": "^5"},
860 "peerDependencies": {"react-dom": "^18"}
861 }"#,
862 )
863 .unwrap();
864
865 let all = pkg.all_dependency_names();
866 assert!(all.contains(&"react".to_string()));
867 assert!(all.contains(&"lodash".to_string()));
868 assert!(all.contains(&"typescript".to_string()));
869 assert!(all.contains(&"react-dom".to_string()));
870
871 let prod = pkg.production_dependency_names();
872 assert!(prod.contains(&"react".to_string()));
873 assert!(!prod.contains(&"typescript".to_string()));
874
875 let dev = pkg.dev_dependency_names();
876 assert!(dev.contains(&"typescript".to_string()));
877 assert!(!dev.contains(&"react".to_string()));
878 }
879
880 #[test]
881 fn package_json_no_dependencies() {
882 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
883 assert!(pkg.all_dependency_names().is_empty());
884 assert!(pkg.production_dependency_names().is_empty());
885 assert!(pkg.dev_dependency_names().is_empty());
886 assert!(pkg.entry_points().is_empty());
887 }
888
889 #[test]
890 fn rules_deserialize_toml_kebab_case() {
891 let toml_str = r#"
892[rules]
893unused-files = "error"
894unused-exports = "warn"
895unused-types = "off"
896"#;
897 let config: FallowConfig = toml::from_str(toml_str).unwrap();
898 assert_eq!(config.rules.unused_files, Severity::Error);
899 assert_eq!(config.rules.unused_exports, Severity::Warn);
900 assert_eq!(config.rules.unused_types, Severity::Off);
901 assert_eq!(config.rules.unresolved_imports, Severity::Error);
903 }
904
905 #[test]
906 fn config_without_rules_defaults_to_error() {
907 let toml_str = r#"
908entry = ["src/main.ts"]
909"#;
910 let config: FallowConfig = toml::from_str(toml_str).unwrap();
911 assert_eq!(config.rules.unused_files, Severity::Error);
912 assert_eq!(config.rules.unused_exports, Severity::Error);
913 }
914
915 #[test]
916 fn fallow_config_denies_unknown_fields() {
917 let toml_str = r"
918unknown_field = true
919";
920 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
921 assert!(result.is_err());
922 }
923
924 #[test]
925 fn fallow_config_deserialize_json() {
926 let json_str = r#"{"entry": ["src/main.ts"]}"#;
927 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
928 assert_eq!(config.entry, vec!["src/main.ts"]);
929 }
930
931 #[test]
932 fn fallow_config_deserialize_jsonc() {
933 let jsonc_str = r#"{
934 // This is a comment
935 "entry": ["src/main.ts"],
936 "rules": {
937 "unused-files": "warn"
938 }
939 }"#;
940 let mut stripped = String::new();
941 json_comments::StripComments::new(jsonc_str.as_bytes())
942 .read_to_string(&mut stripped)
943 .unwrap();
944 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
945 assert_eq!(config.entry, vec!["src/main.ts"]);
946 assert_eq!(config.rules.unused_files, Severity::Warn);
947 }
948
949 #[test]
950 fn fallow_config_json_with_schema_field() {
951 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
952 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
953 assert_eq!(config.entry, vec!["src/main.ts"]);
954 }
955
956 #[test]
957 fn fallow_config_json_schema_generation() {
958 let schema = FallowConfig::json_schema();
959 assert!(schema.is_object());
960 let obj = schema.as_object().unwrap();
961 assert!(obj.contains_key("properties"));
962 }
963
964 #[test]
965 fn config_format_detection() {
966 assert!(matches!(
967 ConfigFormat::from_path(Path::new("fallow.toml")),
968 ConfigFormat::Toml
969 ));
970 assert!(matches!(
971 ConfigFormat::from_path(Path::new(".fallowrc.json")),
972 ConfigFormat::Json
973 ));
974 assert!(matches!(
975 ConfigFormat::from_path(Path::new(".fallow.toml")),
976 ConfigFormat::Toml
977 ));
978 }
979
980 #[test]
981 fn config_names_priority_order() {
982 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
983 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
984 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
985 }
986
987 #[test]
988 fn load_json_config_file() {
989 let dir = test_dir("json-config");
990 let config_path = dir.path().join(".fallowrc.json");
991 std::fs::write(
992 &config_path,
993 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
994 )
995 .unwrap();
996
997 let config = FallowConfig::load(&config_path).unwrap();
998 assert_eq!(config.entry, vec!["src/index.ts"]);
999 assert_eq!(config.rules.unused_exports, Severity::Warn);
1000 }
1001
1002 #[test]
1003 fn load_jsonc_config_file() {
1004 let dir = test_dir("jsonc-config");
1005 let config_path = dir.path().join(".fallowrc.json");
1006 std::fs::write(
1007 &config_path,
1008 r#"{
1009 // Entry points for analysis
1010 "entry": ["src/index.ts"],
1011 /* Block comment */
1012 "rules": {
1013 "unused-exports": "warn"
1014 }
1015 }"#,
1016 )
1017 .unwrap();
1018
1019 let config = FallowConfig::load(&config_path).unwrap();
1020 assert_eq!(config.entry, vec!["src/index.ts"]);
1021 assert_eq!(config.rules.unused_exports, Severity::Warn);
1022 }
1023
1024 #[test]
1025 fn json_config_ignore_dependencies_camel_case() {
1026 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1027 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1028 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1029 }
1030
1031 #[test]
1032 fn json_config_all_fields() {
1033 let json_str = r#"{
1034 "ignoreDependencies": ["lodash"],
1035 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1036 "rules": {
1037 "unused-files": "off",
1038 "unused-exports": "warn",
1039 "unused-dependencies": "error",
1040 "unused-dev-dependencies": "off",
1041 "unused-types": "warn",
1042 "unused-enum-members": "error",
1043 "unused-class-members": "off",
1044 "unresolved-imports": "warn",
1045 "unlisted-dependencies": "error",
1046 "duplicate-exports": "off"
1047 },
1048 "duplicates": {
1049 "minTokens": 100,
1050 "minLines": 10,
1051 "skipLocal": true
1052 }
1053 }"#;
1054 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1055 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1056 assert_eq!(config.rules.unused_files, Severity::Off);
1057 assert_eq!(config.rules.unused_exports, Severity::Warn);
1058 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1059 assert_eq!(config.duplicates.min_tokens, 100);
1060 assert_eq!(config.duplicates.min_lines, 10);
1061 assert!(config.duplicates.skip_local);
1062 }
1063
1064 #[test]
1067 fn extends_single_base() {
1068 let dir = test_dir("extends-single");
1069
1070 std::fs::write(
1071 dir.path().join("base.json"),
1072 r#"{"rules": {"unused-files": "warn"}}"#,
1073 )
1074 .unwrap();
1075 std::fs::write(
1076 dir.path().join(".fallowrc.json"),
1077 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1078 )
1079 .unwrap();
1080
1081 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1082 assert_eq!(config.rules.unused_files, Severity::Warn);
1083 assert_eq!(config.entry, vec!["src/index.ts"]);
1084 assert_eq!(config.rules.unused_exports, Severity::Error);
1086 }
1087
1088 #[test]
1089 fn extends_overlay_overrides_base() {
1090 let dir = test_dir("extends-overlay");
1091
1092 std::fs::write(
1093 dir.path().join("base.json"),
1094 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1095 )
1096 .unwrap();
1097 std::fs::write(
1098 dir.path().join(".fallowrc.json"),
1099 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1100 )
1101 .unwrap();
1102
1103 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1104 assert_eq!(config.rules.unused_files, Severity::Error);
1106 assert_eq!(config.rules.unused_exports, Severity::Off);
1108 }
1109
1110 #[test]
1111 fn extends_chained() {
1112 let dir = test_dir("extends-chained");
1113
1114 std::fs::write(
1115 dir.path().join("grandparent.json"),
1116 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1117 )
1118 .unwrap();
1119 std::fs::write(
1120 dir.path().join("parent.json"),
1121 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1122 )
1123 .unwrap();
1124 std::fs::write(
1125 dir.path().join(".fallowrc.json"),
1126 r#"{"extends": ["parent.json"]}"#,
1127 )
1128 .unwrap();
1129
1130 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1131 assert_eq!(config.rules.unused_files, Severity::Warn);
1133 assert_eq!(config.rules.unused_exports, Severity::Warn);
1135 }
1136
1137 #[test]
1138 fn extends_circular_detected() {
1139 let dir = test_dir("extends-circular");
1140
1141 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1142 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1143
1144 let result = FallowConfig::load(&dir.path().join("a.json"));
1145 assert!(result.is_err());
1146 let err_msg = format!("{}", result.unwrap_err());
1147 assert!(
1148 err_msg.contains("Circular extends"),
1149 "Expected circular error, got: {err_msg}"
1150 );
1151 }
1152
1153 #[test]
1154 fn extends_missing_file_errors() {
1155 let dir = test_dir("extends-missing");
1156
1157 std::fs::write(
1158 dir.path().join(".fallowrc.json"),
1159 r#"{"extends": ["nonexistent.json"]}"#,
1160 )
1161 .unwrap();
1162
1163 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1164 assert!(result.is_err());
1165 let err_msg = format!("{}", result.unwrap_err());
1166 assert!(
1167 err_msg.contains("not found"),
1168 "Expected not found error, got: {err_msg}"
1169 );
1170 }
1171
1172 #[test]
1173 fn extends_string_sugar() {
1174 let dir = test_dir("extends-string");
1175
1176 std::fs::write(
1177 dir.path().join("base.json"),
1178 r#"{"ignorePatterns": ["gen/**"]}"#,
1179 )
1180 .unwrap();
1181 std::fs::write(
1183 dir.path().join(".fallowrc.json"),
1184 r#"{"extends": "base.json"}"#,
1185 )
1186 .unwrap();
1187
1188 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1189 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1190 }
1191
1192 #[test]
1193 fn extends_deep_merge_preserves_arrays() {
1194 let dir = test_dir("extends-array");
1195
1196 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1197 std::fs::write(
1198 dir.path().join(".fallowrc.json"),
1199 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1200 )
1201 .unwrap();
1202
1203 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1204 assert_eq!(config.entry, vec!["src/b.ts"]);
1206 }
1207
1208 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1212 let pkg_dir = root.join("node_modules").join(name);
1213 std::fs::create_dir_all(&pkg_dir).unwrap();
1214 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1215 }
1216
1217 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1219 let pkg_dir = root.join("node_modules").join(name);
1220 std::fs::create_dir_all(&pkg_dir).unwrap();
1221 std::fs::write(
1222 pkg_dir.join("package.json"),
1223 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1224 )
1225 .unwrap();
1226 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1227 }
1228
1229 #[test]
1230 fn extends_npm_basic_unscoped() {
1231 let dir = test_dir("npm-basic");
1232 create_npm_package(
1233 dir.path(),
1234 "fallow-config-acme",
1235 r#"{"rules": {"unused-files": "warn"}}"#,
1236 );
1237 std::fs::write(
1238 dir.path().join(".fallowrc.json"),
1239 r#"{"extends": "npm:fallow-config-acme"}"#,
1240 )
1241 .unwrap();
1242
1243 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1244 assert_eq!(config.rules.unused_files, Severity::Warn);
1245 }
1246
1247 #[test]
1248 fn extends_npm_scoped_package() {
1249 let dir = test_dir("npm-scoped");
1250 create_npm_package(
1251 dir.path(),
1252 "@company/fallow-config",
1253 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1254 );
1255 std::fs::write(
1256 dir.path().join(".fallowrc.json"),
1257 r#"{"extends": "npm:@company/fallow-config"}"#,
1258 )
1259 .unwrap();
1260
1261 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1262 assert_eq!(config.rules.unused_exports, Severity::Off);
1263 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1264 }
1265
1266 #[test]
1267 fn extends_npm_with_subpath() {
1268 let dir = test_dir("npm-subpath");
1269 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1270 std::fs::create_dir_all(&pkg_dir).unwrap();
1271 std::fs::write(
1272 pkg_dir.join("strict.json"),
1273 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1274 )
1275 .unwrap();
1276
1277 std::fs::write(
1278 dir.path().join(".fallowrc.json"),
1279 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1280 )
1281 .unwrap();
1282
1283 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1284 assert_eq!(config.rules.unused_files, Severity::Error);
1285 assert_eq!(config.rules.unused_exports, Severity::Error);
1286 }
1287
1288 #[test]
1289 fn extends_npm_package_json_main() {
1290 let dir = test_dir("npm-main");
1291 create_npm_package_with_main(
1292 dir.path(),
1293 "fallow-config-acme",
1294 "config.json",
1295 r#"{"rules": {"unused-types": "off"}}"#,
1296 );
1297 std::fs::write(
1298 dir.path().join(".fallowrc.json"),
1299 r#"{"extends": "npm:fallow-config-acme"}"#,
1300 )
1301 .unwrap();
1302
1303 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1304 assert_eq!(config.rules.unused_types, Severity::Off);
1305 }
1306
1307 #[test]
1308 fn extends_npm_package_json_exports_string() {
1309 let dir = test_dir("npm-exports-str");
1310 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1311 std::fs::create_dir_all(&pkg_dir).unwrap();
1312 std::fs::write(
1313 pkg_dir.join("package.json"),
1314 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1315 )
1316 .unwrap();
1317 std::fs::write(
1318 pkg_dir.join("base.json"),
1319 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1320 )
1321 .unwrap();
1322
1323 std::fs::write(
1324 dir.path().join(".fallowrc.json"),
1325 r#"{"extends": "npm:fallow-config-co"}"#,
1326 )
1327 .unwrap();
1328
1329 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1330 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1331 }
1332
1333 #[test]
1334 fn extends_npm_package_json_exports_object() {
1335 let dir = test_dir("npm-exports-obj");
1336 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1337 std::fs::create_dir_all(&pkg_dir).unwrap();
1338 std::fs::write(
1339 pkg_dir.join("package.json"),
1340 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1341 )
1342 .unwrap();
1343 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1344
1345 std::fs::write(
1346 dir.path().join(".fallowrc.json"),
1347 r#"{"extends": "npm:@co/cfg"}"#,
1348 )
1349 .unwrap();
1350
1351 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1352 assert_eq!(config.entry, vec!["src/app.ts"]);
1353 }
1354
1355 #[test]
1356 fn extends_npm_exports_takes_priority_over_main() {
1357 let dir = test_dir("npm-exports-prio");
1358 let pkg_dir = dir.path().join("node_modules/my-config");
1359 std::fs::create_dir_all(&pkg_dir).unwrap();
1360 std::fs::write(
1361 pkg_dir.join("package.json"),
1362 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1363 )
1364 .unwrap();
1365 std::fs::write(
1366 pkg_dir.join("old.json"),
1367 r#"{"rules": {"unused-files": "off"}}"#,
1368 )
1369 .unwrap();
1370 std::fs::write(
1371 pkg_dir.join("new.json"),
1372 r#"{"rules": {"unused-files": "warn"}}"#,
1373 )
1374 .unwrap();
1375
1376 std::fs::write(
1377 dir.path().join(".fallowrc.json"),
1378 r#"{"extends": "npm:my-config"}"#,
1379 )
1380 .unwrap();
1381
1382 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1383 assert_eq!(config.rules.unused_files, Severity::Warn);
1385 }
1386
1387 #[test]
1388 fn extends_npm_walk_up_directories() {
1389 let dir = test_dir("npm-walkup");
1390 create_npm_package(
1392 dir.path(),
1393 "shared-config",
1394 r#"{"rules": {"unused-files": "warn"}}"#,
1395 );
1396 let sub = dir.path().join("packages/app");
1398 std::fs::create_dir_all(&sub).unwrap();
1399 std::fs::write(
1400 sub.join(".fallowrc.json"),
1401 r#"{"extends": "npm:shared-config"}"#,
1402 )
1403 .unwrap();
1404
1405 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1406 assert_eq!(config.rules.unused_files, Severity::Warn);
1407 }
1408
1409 #[test]
1410 fn extends_npm_overlay_overrides_base() {
1411 let dir = test_dir("npm-overlay");
1412 create_npm_package(
1413 dir.path(),
1414 "@company/base",
1415 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1416 );
1417 std::fs::write(
1418 dir.path().join(".fallowrc.json"),
1419 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1420 )
1421 .unwrap();
1422
1423 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1424 assert_eq!(config.rules.unused_files, Severity::Error);
1425 assert_eq!(config.rules.unused_exports, Severity::Off);
1426 assert_eq!(config.entry, vec!["src/app.ts"]);
1427 }
1428
1429 #[test]
1430 fn extends_npm_chained_with_relative() {
1431 let dir = test_dir("npm-chained");
1432 let pkg_dir = dir.path().join("node_modules/my-config");
1434 std::fs::create_dir_all(&pkg_dir).unwrap();
1435 std::fs::write(
1436 pkg_dir.join("base.json"),
1437 r#"{"rules": {"unused-files": "warn"}}"#,
1438 )
1439 .unwrap();
1440 std::fs::write(
1441 pkg_dir.join(".fallowrc.json"),
1442 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1443 )
1444 .unwrap();
1445
1446 std::fs::write(
1447 dir.path().join(".fallowrc.json"),
1448 r#"{"extends": "npm:my-config"}"#,
1449 )
1450 .unwrap();
1451
1452 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1453 assert_eq!(config.rules.unused_files, Severity::Warn);
1454 assert_eq!(config.rules.unused_exports, Severity::Off);
1455 }
1456
1457 #[test]
1458 fn extends_npm_mixed_with_relative_paths() {
1459 let dir = test_dir("npm-mixed");
1460 create_npm_package(
1461 dir.path(),
1462 "shared-base",
1463 r#"{"rules": {"unused-files": "off"}}"#,
1464 );
1465 std::fs::write(
1466 dir.path().join("local-overrides.json"),
1467 r#"{"rules": {"unused-files": "warn"}}"#,
1468 )
1469 .unwrap();
1470 std::fs::write(
1471 dir.path().join(".fallowrc.json"),
1472 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1473 )
1474 .unwrap();
1475
1476 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1477 assert_eq!(config.rules.unused_files, Severity::Warn);
1479 }
1480
1481 #[test]
1482 fn extends_npm_missing_package_errors() {
1483 let dir = test_dir("npm-missing");
1484 std::fs::write(
1485 dir.path().join(".fallowrc.json"),
1486 r#"{"extends": "npm:nonexistent-package"}"#,
1487 )
1488 .unwrap();
1489
1490 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1491 assert!(result.is_err());
1492 let err_msg = format!("{}", result.unwrap_err());
1493 assert!(
1494 err_msg.contains("not found"),
1495 "Expected 'not found' error, got: {err_msg}"
1496 );
1497 assert!(
1498 err_msg.contains("nonexistent-package"),
1499 "Expected package name in error, got: {err_msg}"
1500 );
1501 assert!(
1502 err_msg.contains("install it"),
1503 "Expected install hint in error, got: {err_msg}"
1504 );
1505 }
1506
1507 #[test]
1508 fn extends_npm_no_config_in_package_errors() {
1509 let dir = test_dir("npm-no-config");
1510 let pkg_dir = dir.path().join("node_modules/empty-pkg");
1511 std::fs::create_dir_all(&pkg_dir).unwrap();
1512 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1514
1515 std::fs::write(
1516 dir.path().join(".fallowrc.json"),
1517 r#"{"extends": "npm:empty-pkg"}"#,
1518 )
1519 .unwrap();
1520
1521 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1522 assert!(result.is_err());
1523 let err_msg = format!("{}", result.unwrap_err());
1524 assert!(
1525 err_msg.contains("No fallow config found"),
1526 "Expected 'No fallow config found' error, got: {err_msg}"
1527 );
1528 }
1529
1530 #[test]
1531 fn extends_npm_missing_subpath_errors() {
1532 let dir = test_dir("npm-missing-sub");
1533 let pkg_dir = dir.path().join("node_modules/@co/config");
1534 std::fs::create_dir_all(&pkg_dir).unwrap();
1535
1536 std::fs::write(
1537 dir.path().join(".fallowrc.json"),
1538 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1539 )
1540 .unwrap();
1541
1542 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1543 assert!(result.is_err());
1544 let err_msg = format!("{}", result.unwrap_err());
1545 assert!(
1546 err_msg.contains("nonexistent.json"),
1547 "Expected subpath in error, got: {err_msg}"
1548 );
1549 }
1550
1551 #[test]
1552 fn extends_npm_empty_specifier_errors() {
1553 let dir = test_dir("npm-empty");
1554 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1555
1556 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1557 assert!(result.is_err());
1558 let err_msg = format!("{}", result.unwrap_err());
1559 assert!(
1560 err_msg.contains("Empty npm specifier"),
1561 "Expected 'Empty npm specifier' error, got: {err_msg}"
1562 );
1563 }
1564
1565 #[test]
1566 fn extends_npm_space_after_colon_trimmed() {
1567 let dir = test_dir("npm-space");
1568 create_npm_package(
1569 dir.path(),
1570 "fallow-config-acme",
1571 r#"{"rules": {"unused-files": "warn"}}"#,
1572 );
1573 std::fs::write(
1575 dir.path().join(".fallowrc.json"),
1576 r#"{"extends": "npm: fallow-config-acme"}"#,
1577 )
1578 .unwrap();
1579
1580 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1581 assert_eq!(config.rules.unused_files, Severity::Warn);
1582 }
1583
1584 #[test]
1585 fn extends_npm_exports_node_condition() {
1586 let dir = test_dir("npm-node-cond");
1587 let pkg_dir = dir.path().join("node_modules/node-config");
1588 std::fs::create_dir_all(&pkg_dir).unwrap();
1589 std::fs::write(
1590 pkg_dir.join("package.json"),
1591 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1592 )
1593 .unwrap();
1594 std::fs::write(
1595 pkg_dir.join("node.json"),
1596 r#"{"rules": {"unused-files": "off"}}"#,
1597 )
1598 .unwrap();
1599
1600 std::fs::write(
1601 dir.path().join(".fallowrc.json"),
1602 r#"{"extends": "npm:node-config"}"#,
1603 )
1604 .unwrap();
1605
1606 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1607 assert_eq!(config.rules.unused_files, Severity::Off);
1608 }
1609
1610 #[test]
1613 fn parse_npm_specifier_unscoped() {
1614 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1615 }
1616
1617 #[test]
1618 fn parse_npm_specifier_unscoped_with_subpath() {
1619 assert_eq!(
1620 parse_npm_specifier("my-config/strict.json"),
1621 ("my-config", Some("strict.json"))
1622 );
1623 }
1624
1625 #[test]
1626 fn parse_npm_specifier_scoped() {
1627 assert_eq!(
1628 parse_npm_specifier("@company/fallow-config"),
1629 ("@company/fallow-config", None)
1630 );
1631 }
1632
1633 #[test]
1634 fn parse_npm_specifier_scoped_with_subpath() {
1635 assert_eq!(
1636 parse_npm_specifier("@company/fallow-config/strict.json"),
1637 ("@company/fallow-config", Some("strict.json"))
1638 );
1639 }
1640
1641 #[test]
1642 fn parse_npm_specifier_scoped_with_nested_subpath() {
1643 assert_eq!(
1644 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1645 ("@company/fallow-config", Some("presets/strict.json"))
1646 );
1647 }
1648
1649 #[test]
1652 fn extends_npm_subpath_traversal_rejected() {
1653 let dir = test_dir("npm-traversal-sub");
1654 let pkg_dir = dir.path().join("node_modules/evil-pkg");
1655 std::fs::create_dir_all(&pkg_dir).unwrap();
1656 std::fs::write(
1658 dir.path().join("secret.json"),
1659 r#"{"entry": ["stolen.ts"]}"#,
1660 )
1661 .unwrap();
1662
1663 std::fs::write(
1664 dir.path().join(".fallowrc.json"),
1665 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1666 )
1667 .unwrap();
1668
1669 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1670 assert!(result.is_err());
1671 let err_msg = format!("{}", result.unwrap_err());
1672 assert!(
1673 err_msg.contains("traversal") || err_msg.contains("not found"),
1674 "Expected traversal or not-found error, got: {err_msg}"
1675 );
1676 }
1677
1678 #[test]
1679 fn extends_npm_dotdot_package_name_rejected() {
1680 let dir = test_dir("npm-dotdot-name");
1681 std::fs::write(
1682 dir.path().join(".fallowrc.json"),
1683 r#"{"extends": "npm:../relative"}"#,
1684 )
1685 .unwrap();
1686
1687 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1688 assert!(result.is_err());
1689 let err_msg = format!("{}", result.unwrap_err());
1690 assert!(
1691 err_msg.contains("path traversal"),
1692 "Expected 'path traversal' error, got: {err_msg}"
1693 );
1694 }
1695
1696 #[test]
1697 fn extends_npm_scoped_without_name_rejected() {
1698 let dir = test_dir("npm-scope-only");
1699 std::fs::write(
1700 dir.path().join(".fallowrc.json"),
1701 r#"{"extends": "npm:@scope"}"#,
1702 )
1703 .unwrap();
1704
1705 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1706 assert!(result.is_err());
1707 let err_msg = format!("{}", result.unwrap_err());
1708 assert!(
1709 err_msg.contains("@scope/name"),
1710 "Expected scoped name format error, got: {err_msg}"
1711 );
1712 }
1713
1714 #[test]
1715 fn extends_npm_malformed_package_json_errors() {
1716 let dir = test_dir("npm-bad-pkgjson");
1717 let pkg_dir = dir.path().join("node_modules/bad-pkg");
1718 std::fs::create_dir_all(&pkg_dir).unwrap();
1719 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1720
1721 std::fs::write(
1722 dir.path().join(".fallowrc.json"),
1723 r#"{"extends": "npm:bad-pkg"}"#,
1724 )
1725 .unwrap();
1726
1727 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1728 assert!(result.is_err());
1729 let err_msg = format!("{}", result.unwrap_err());
1730 assert!(
1731 err_msg.contains("Failed to parse"),
1732 "Expected parse error, got: {err_msg}"
1733 );
1734 }
1735
1736 #[test]
1737 fn extends_npm_exports_traversal_rejected() {
1738 let dir = test_dir("npm-exports-escape");
1739 let pkg_dir = dir.path().join("node_modules/evil-exports");
1740 std::fs::create_dir_all(&pkg_dir).unwrap();
1741 std::fs::write(
1742 pkg_dir.join("package.json"),
1743 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1744 )
1745 .unwrap();
1746 std::fs::write(
1748 dir.path().join("secret.json"),
1749 r#"{"entry": ["stolen.ts"]}"#,
1750 )
1751 .unwrap();
1752
1753 std::fs::write(
1754 dir.path().join(".fallowrc.json"),
1755 r#"{"extends": "npm:evil-exports"}"#,
1756 )
1757 .unwrap();
1758
1759 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1760 assert!(result.is_err());
1761 let err_msg = format!("{}", result.unwrap_err());
1762 assert!(
1763 err_msg.contains("traversal"),
1764 "Expected traversal error, got: {err_msg}"
1765 );
1766 }
1767
1768 #[test]
1771 fn deep_merge_scalar_overlay_replaces_base() {
1772 let mut base = serde_json::json!("hello");
1773 deep_merge_json(&mut base, serde_json::json!("world"));
1774 assert_eq!(base, serde_json::json!("world"));
1775 }
1776
1777 #[test]
1778 fn deep_merge_array_overlay_replaces_base() {
1779 let mut base = serde_json::json!(["a", "b"]);
1780 deep_merge_json(&mut base, serde_json::json!(["c"]));
1781 assert_eq!(base, serde_json::json!(["c"]));
1782 }
1783
1784 #[test]
1785 fn deep_merge_nested_object_merge() {
1786 let mut base = serde_json::json!({
1787 "level1": {
1788 "level2": {
1789 "a": 1,
1790 "b": 2
1791 }
1792 }
1793 });
1794 let overlay = serde_json::json!({
1795 "level1": {
1796 "level2": {
1797 "b": 99,
1798 "c": 3
1799 }
1800 }
1801 });
1802 deep_merge_json(&mut base, overlay);
1803 assert_eq!(base["level1"]["level2"]["a"], 1);
1804 assert_eq!(base["level1"]["level2"]["b"], 99);
1805 assert_eq!(base["level1"]["level2"]["c"], 3);
1806 }
1807
1808 #[test]
1809 fn deep_merge_overlay_adds_new_fields() {
1810 let mut base = serde_json::json!({"existing": true});
1811 let overlay = serde_json::json!({"new_field": "added", "another": 42});
1812 deep_merge_json(&mut base, overlay);
1813 assert_eq!(base["existing"], true);
1814 assert_eq!(base["new_field"], "added");
1815 assert_eq!(base["another"], 42);
1816 }
1817
1818 #[test]
1819 fn deep_merge_null_overlay_replaces_object() {
1820 let mut base = serde_json::json!({"key": "value"});
1821 deep_merge_json(&mut base, serde_json::json!(null));
1822 assert_eq!(base, serde_json::json!(null));
1823 }
1824
1825 #[test]
1826 fn deep_merge_empty_object_overlay_preserves_base() {
1827 let mut base = serde_json::json!({"a": 1, "b": 2});
1828 deep_merge_json(&mut base, serde_json::json!({}));
1829 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1830 }
1831
1832 #[test]
1835 fn rules_severity_error_warn_off_from_json() {
1836 let json_str = r#"{
1837 "rules": {
1838 "unused-files": "error",
1839 "unused-exports": "warn",
1840 "unused-types": "off"
1841 }
1842 }"#;
1843 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1844 assert_eq!(config.rules.unused_files, Severity::Error);
1845 assert_eq!(config.rules.unused_exports, Severity::Warn);
1846 assert_eq!(config.rules.unused_types, Severity::Off);
1847 }
1848
1849 #[test]
1850 fn rules_omitted_default_to_error() {
1851 let json_str = r#"{
1852 "rules": {
1853 "unused-files": "warn"
1854 }
1855 }"#;
1856 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1857 assert_eq!(config.rules.unused_files, Severity::Warn);
1858 assert_eq!(config.rules.unused_exports, Severity::Error);
1860 assert_eq!(config.rules.unused_types, Severity::Error);
1861 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1862 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1863 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
1864 assert_eq!(config.rules.duplicate_exports, Severity::Error);
1865 assert_eq!(config.rules.circular_dependencies, Severity::Error);
1866 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
1868 }
1869
1870 #[test]
1873 fn find_and_load_returns_none_when_no_config() {
1874 let dir = test_dir("find-none");
1875 std::fs::create_dir(dir.path().join(".git")).unwrap();
1877
1878 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1879 assert!(result.is_none());
1880 }
1881
1882 #[test]
1883 fn find_and_load_finds_fallowrc_json() {
1884 let dir = test_dir("find-json");
1885 std::fs::create_dir(dir.path().join(".git")).unwrap();
1886 std::fs::write(
1887 dir.path().join(".fallowrc.json"),
1888 r#"{"entry": ["src/main.ts"]}"#,
1889 )
1890 .unwrap();
1891
1892 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1893 assert_eq!(config.entry, vec!["src/main.ts"]);
1894 assert!(path.ends_with(".fallowrc.json"));
1895 }
1896
1897 #[test]
1898 fn find_and_load_prefers_fallowrc_json_over_toml() {
1899 let dir = test_dir("find-priority");
1900 std::fs::create_dir(dir.path().join(".git")).unwrap();
1901 std::fs::write(
1902 dir.path().join(".fallowrc.json"),
1903 r#"{"entry": ["from-json.ts"]}"#,
1904 )
1905 .unwrap();
1906 std::fs::write(
1907 dir.path().join("fallow.toml"),
1908 "entry = [\"from-toml.ts\"]\n",
1909 )
1910 .unwrap();
1911
1912 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1913 assert_eq!(config.entry, vec!["from-json.ts"]);
1914 assert!(path.ends_with(".fallowrc.json"));
1915 }
1916
1917 #[test]
1918 fn find_and_load_finds_fallow_toml() {
1919 let dir = test_dir("find-toml");
1920 std::fs::create_dir(dir.path().join(".git")).unwrap();
1921 std::fs::write(
1922 dir.path().join("fallow.toml"),
1923 "entry = [\"src/index.ts\"]\n",
1924 )
1925 .unwrap();
1926
1927 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1928 assert_eq!(config.entry, vec!["src/index.ts"]);
1929 }
1930
1931 #[test]
1932 fn find_and_load_stops_at_git_dir() {
1933 let dir = test_dir("find-git-stop");
1934 let sub = dir.path().join("sub");
1935 std::fs::create_dir(&sub).unwrap();
1936 std::fs::create_dir(dir.path().join(".git")).unwrap();
1938 let result = FallowConfig::find_and_load(&sub).unwrap();
1942 assert!(result.is_none());
1943 }
1944
1945 #[test]
1946 fn find_and_load_stops_at_package_json() {
1947 let dir = test_dir("find-pkg-stop");
1948 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
1949
1950 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1951 assert!(result.is_none());
1952 }
1953
1954 #[test]
1955 fn find_and_load_returns_error_for_invalid_config() {
1956 let dir = test_dir("find-invalid");
1957 std::fs::create_dir(dir.path().join(".git")).unwrap();
1958 std::fs::write(
1959 dir.path().join(".fallowrc.json"),
1960 r"{ this is not valid json }",
1961 )
1962 .unwrap();
1963
1964 let result = FallowConfig::find_and_load(dir.path());
1965 assert!(result.is_err());
1966 }
1967
1968 #[test]
1971 fn load_toml_config_file() {
1972 let dir = test_dir("toml-config");
1973 let config_path = dir.path().join("fallow.toml");
1974 std::fs::write(
1975 &config_path,
1976 r#"
1977entry = ["src/index.ts"]
1978ignorePatterns = ["dist/**"]
1979
1980[rules]
1981unused-files = "warn"
1982
1983[duplicates]
1984minTokens = 100
1985"#,
1986 )
1987 .unwrap();
1988
1989 let config = FallowConfig::load(&config_path).unwrap();
1990 assert_eq!(config.entry, vec!["src/index.ts"]);
1991 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1992 assert_eq!(config.rules.unused_files, Severity::Warn);
1993 assert_eq!(config.duplicates.min_tokens, 100);
1994 }
1995
1996 #[test]
1999 fn extends_absolute_path_rejected() {
2000 let dir = test_dir("extends-absolute");
2001
2002 #[cfg(unix)]
2004 let abs_path = "/absolute/path/config.json";
2005 #[cfg(windows)]
2006 let abs_path = "C:\\absolute\\path\\config.json";
2007
2008 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2009 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2010
2011 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2012 assert!(result.is_err());
2013 let err_msg = format!("{}", result.unwrap_err());
2014 assert!(
2015 err_msg.contains("must be relative"),
2016 "Expected 'must be relative' error, got: {err_msg}"
2017 );
2018 }
2019
2020 #[test]
2023 fn resolve_production_mode_disables_dev_deps() {
2024 let config = FallowConfig {
2025 production: true,
2026 ..Default::default()
2027 };
2028 let resolved = config.resolve(
2029 PathBuf::from("/tmp/test"),
2030 OutputFormat::Human,
2031 4,
2032 false,
2033 true,
2034 );
2035 assert!(resolved.production);
2036 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2037 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2038 assert_eq!(resolved.rules.unused_files, Severity::Error);
2040 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2041 }
2042
2043 #[test]
2046 fn config_format_defaults_to_toml_for_unknown() {
2047 assert!(matches!(
2048 ConfigFormat::from_path(Path::new("config.yaml")),
2049 ConfigFormat::Toml
2050 ));
2051 assert!(matches!(
2052 ConfigFormat::from_path(Path::new("config")),
2053 ConfigFormat::Toml
2054 ));
2055 }
2056
2057 #[test]
2060 fn deep_merge_object_over_scalar_replaces() {
2061 let mut base = serde_json::json!("just a string");
2062 let overlay = serde_json::json!({"key": "value"});
2063 deep_merge_json(&mut base, overlay);
2064 assert_eq!(base, serde_json::json!({"key": "value"}));
2065 }
2066
2067 #[test]
2068 fn deep_merge_scalar_over_object_replaces() {
2069 let mut base = serde_json::json!({"key": "value"});
2070 let overlay = serde_json::json!(42);
2071 deep_merge_json(&mut base, overlay);
2072 assert_eq!(base, serde_json::json!(42));
2073 }
2074
2075 #[test]
2078 fn extends_non_string_non_array_ignored() {
2079 let dir = test_dir("extends-numeric");
2080 std::fs::write(
2081 dir.path().join(".fallowrc.json"),
2082 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2083 )
2084 .unwrap();
2085
2086 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2088 assert_eq!(config.entry, vec!["src/index.ts"]);
2089 }
2090
2091 #[test]
2094 fn extends_multiple_bases_later_wins() {
2095 let dir = test_dir("extends-multi-base");
2096
2097 std::fs::write(
2098 dir.path().join("base-a.json"),
2099 r#"{"rules": {"unused-files": "warn"}}"#,
2100 )
2101 .unwrap();
2102 std::fs::write(
2103 dir.path().join("base-b.json"),
2104 r#"{"rules": {"unused-files": "off"}}"#,
2105 )
2106 .unwrap();
2107 std::fs::write(
2108 dir.path().join(".fallowrc.json"),
2109 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2110 )
2111 .unwrap();
2112
2113 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2114 assert_eq!(config.rules.unused_files, Severity::Off);
2116 }
2117
2118 #[test]
2121 fn fallow_config_deserialize_production() {
2122 let json_str = r#"{"production": true}"#;
2123 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2124 assert!(config.production);
2125 }
2126
2127 #[test]
2128 fn fallow_config_production_defaults_false() {
2129 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2130 assert!(!config.production);
2131 }
2132
2133 #[test]
2136 fn package_json_optional_dependency_names() {
2137 let pkg: PackageJson = serde_json::from_str(
2138 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2139 )
2140 .unwrap();
2141 let opt = pkg.optional_dependency_names();
2142 assert_eq!(opt.len(), 2);
2143 assert!(opt.contains(&"fsevents".to_string()));
2144 assert!(opt.contains(&"chokidar".to_string()));
2145 }
2146
2147 #[test]
2148 fn package_json_optional_deps_empty_when_missing() {
2149 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2150 assert!(pkg.optional_dependency_names().is_empty());
2151 }
2152
2153 #[test]
2156 fn find_config_path_returns_fallowrc_json() {
2157 let dir = test_dir("find-path-json");
2158 std::fs::create_dir(dir.path().join(".git")).unwrap();
2159 std::fs::write(
2160 dir.path().join(".fallowrc.json"),
2161 r#"{"entry": ["src/main.ts"]}"#,
2162 )
2163 .unwrap();
2164
2165 let path = FallowConfig::find_config_path(dir.path());
2166 assert!(path.is_some());
2167 assert!(path.unwrap().ends_with(".fallowrc.json"));
2168 }
2169
2170 #[test]
2171 fn find_config_path_returns_fallow_toml() {
2172 let dir = test_dir("find-path-toml");
2173 std::fs::create_dir(dir.path().join(".git")).unwrap();
2174 std::fs::write(
2175 dir.path().join("fallow.toml"),
2176 "entry = [\"src/main.ts\"]\n",
2177 )
2178 .unwrap();
2179
2180 let path = FallowConfig::find_config_path(dir.path());
2181 assert!(path.is_some());
2182 assert!(path.unwrap().ends_with("fallow.toml"));
2183 }
2184
2185 #[test]
2186 fn find_config_path_returns_dot_fallow_toml() {
2187 let dir = test_dir("find-path-dot-toml");
2188 std::fs::create_dir(dir.path().join(".git")).unwrap();
2189 std::fs::write(
2190 dir.path().join(".fallow.toml"),
2191 "entry = [\"src/main.ts\"]\n",
2192 )
2193 .unwrap();
2194
2195 let path = FallowConfig::find_config_path(dir.path());
2196 assert!(path.is_some());
2197 assert!(path.unwrap().ends_with(".fallow.toml"));
2198 }
2199
2200 #[test]
2201 fn find_config_path_prefers_json_over_toml() {
2202 let dir = test_dir("find-path-priority");
2203 std::fs::create_dir(dir.path().join(".git")).unwrap();
2204 std::fs::write(
2205 dir.path().join(".fallowrc.json"),
2206 r#"{"entry": ["json.ts"]}"#,
2207 )
2208 .unwrap();
2209 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2210
2211 let path = FallowConfig::find_config_path(dir.path());
2212 assert!(path.unwrap().ends_with(".fallowrc.json"));
2213 }
2214
2215 #[test]
2216 fn find_config_path_none_when_no_config() {
2217 let dir = test_dir("find-path-none");
2218 std::fs::create_dir(dir.path().join(".git")).unwrap();
2219
2220 let path = FallowConfig::find_config_path(dir.path());
2221 assert!(path.is_none());
2222 }
2223
2224 #[test]
2225 fn find_config_path_stops_at_package_json() {
2226 let dir = test_dir("find-path-pkg-stop");
2227 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
2228
2229 let path = FallowConfig::find_config_path(dir.path());
2230 assert!(path.is_none());
2231 }
2232
2233 #[test]
2236 fn extends_toml_base() {
2237 let dir = test_dir("extends-toml");
2238
2239 std::fs::write(
2240 dir.path().join("base.json"),
2241 r#"{"rules": {"unused-files": "warn"}}"#,
2242 )
2243 .unwrap();
2244 std::fs::write(
2245 dir.path().join("fallow.toml"),
2246 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2247 )
2248 .unwrap();
2249
2250 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2251 assert_eq!(config.rules.unused_files, Severity::Warn);
2252 assert_eq!(config.entry, vec!["src/index.ts"]);
2253 }
2254
2255 #[test]
2258 fn deep_merge_boolean_overlay() {
2259 let mut base = serde_json::json!(true);
2260 deep_merge_json(&mut base, serde_json::json!(false));
2261 assert_eq!(base, serde_json::json!(false));
2262 }
2263
2264 #[test]
2265 fn deep_merge_number_overlay() {
2266 let mut base = serde_json::json!(42);
2267 deep_merge_json(&mut base, serde_json::json!(99));
2268 assert_eq!(base, serde_json::json!(99));
2269 }
2270
2271 #[test]
2272 fn deep_merge_disjoint_objects() {
2273 let mut base = serde_json::json!({"a": 1});
2274 let overlay = serde_json::json!({"b": 2});
2275 deep_merge_json(&mut base, overlay);
2276 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2277 }
2278
2279 #[test]
2282 fn max_extends_depth_is_reasonable() {
2283 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2284 }
2285
2286 #[test]
2289 fn config_names_has_three_entries() {
2290 assert_eq!(CONFIG_NAMES.len(), 3);
2291 for name in CONFIG_NAMES {
2293 assert!(
2294 name.starts_with('.') || name.starts_with("fallow"),
2295 "unexpected config name: {name}"
2296 );
2297 }
2298 }
2299
2300 #[test]
2303 fn package_json_peer_dependency_names() {
2304 let pkg: PackageJson = serde_json::from_str(
2305 r#"{
2306 "dependencies": {"react": "^18"},
2307 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2308 }"#,
2309 )
2310 .unwrap();
2311 let all = pkg.all_dependency_names();
2312 assert!(all.contains(&"react".to_string()));
2313 assert!(all.contains(&"react-dom".to_string()));
2314 assert!(all.contains(&"react-native".to_string()));
2315 }
2316
2317 #[test]
2320 fn package_json_scripts_field() {
2321 let pkg: PackageJson = serde_json::from_str(
2322 r#"{
2323 "scripts": {
2324 "build": "tsc",
2325 "test": "vitest",
2326 "lint": "fallow check"
2327 }
2328 }"#,
2329 )
2330 .unwrap();
2331 let scripts = pkg.scripts.unwrap();
2332 assert_eq!(scripts.len(), 3);
2333 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2334 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2335 }
2336
2337 #[test]
2340 fn extends_toml_chain() {
2341 let dir = test_dir("extends-toml-chain");
2342
2343 std::fs::write(
2344 dir.path().join("base.json"),
2345 r#"{"entry": ["src/base.ts"]}"#,
2346 )
2347 .unwrap();
2348 std::fs::write(
2349 dir.path().join("middle.json"),
2350 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2351 )
2352 .unwrap();
2353 std::fs::write(
2354 dir.path().join("fallow.toml"),
2355 "extends = [\"middle.json\"]\n",
2356 )
2357 .unwrap();
2358
2359 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2360 assert_eq!(config.entry, vec!["src/base.ts"]);
2361 assert_eq!(config.rules.unused_files, Severity::Off);
2362 }
2363
2364 #[test]
2367 fn find_and_load_walks_up_directories() {
2368 let dir = test_dir("find-walk-up");
2369 let sub = dir.path().join("src").join("deep");
2370 std::fs::create_dir_all(&sub).unwrap();
2371 std::fs::write(
2372 dir.path().join(".fallowrc.json"),
2373 r#"{"entry": ["src/main.ts"]}"#,
2374 )
2375 .unwrap();
2376 std::fs::create_dir(dir.path().join(".git")).unwrap();
2378
2379 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2380 assert_eq!(config.entry, vec!["src/main.ts"]);
2381 assert!(path.ends_with(".fallowrc.json"));
2382 }
2383
2384 #[test]
2387 fn json_schema_contains_entry_field() {
2388 let schema = FallowConfig::json_schema();
2389 let obj = schema.as_object().unwrap();
2390 let props = obj.get("properties").and_then(|v| v.as_object());
2391 assert!(props.is_some(), "schema should have properties");
2392 assert!(
2393 props.unwrap().contains_key("entry"),
2394 "schema should contain entry property"
2395 );
2396 }
2397
2398 #[test]
2401 fn fallow_config_json_duplicates_all_fields() {
2402 let json = r#"{
2403 "duplicates": {
2404 "enabled": true,
2405 "mode": "semantic",
2406 "minTokens": 200,
2407 "minLines": 20,
2408 "threshold": 10.5,
2409 "ignore": ["**/*.test.ts"],
2410 "skipLocal": true,
2411 "crossLanguage": true,
2412 "normalization": {
2413 "ignoreIdentifiers": true,
2414 "ignoreStringValues": false
2415 }
2416 }
2417 }"#;
2418 let config: FallowConfig = serde_json::from_str(json).unwrap();
2419 assert!(config.duplicates.enabled);
2420 assert_eq!(
2421 config.duplicates.mode,
2422 crate::config::DetectionMode::Semantic
2423 );
2424 assert_eq!(config.duplicates.min_tokens, 200);
2425 assert_eq!(config.duplicates.min_lines, 20);
2426 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2427 assert!(config.duplicates.skip_local);
2428 assert!(config.duplicates.cross_language);
2429 assert_eq!(
2430 config.duplicates.normalization.ignore_identifiers,
2431 Some(true)
2432 );
2433 assert_eq!(
2434 config.duplicates.normalization.ignore_string_values,
2435 Some(false)
2436 );
2437 }
2438
2439 #[test]
2442 fn normalize_url_basic() {
2443 assert_eq!(
2444 normalize_url_for_dedup("https://example.com/config.json"),
2445 "https://example.com/config.json"
2446 );
2447 }
2448
2449 #[test]
2450 fn normalize_url_trailing_slash() {
2451 assert_eq!(
2452 normalize_url_for_dedup("https://example.com/config/"),
2453 "https://example.com/config"
2454 );
2455 }
2456
2457 #[test]
2458 fn normalize_url_uppercase_scheme_and_host() {
2459 assert_eq!(
2460 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2461 "https://example.com/Config.json"
2462 );
2463 }
2464
2465 #[test]
2466 fn normalize_url_root_path() {
2467 assert_eq!(
2468 normalize_url_for_dedup("https://example.com/"),
2469 "https://example.com"
2470 );
2471 assert_eq!(
2472 normalize_url_for_dedup("https://example.com"),
2473 "https://example.com"
2474 );
2475 }
2476
2477 #[test]
2478 fn normalize_url_preserves_path_case() {
2479 assert_eq!(
2481 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2482 "https://github.com/Org/Repo/Fallow.json"
2483 );
2484 }
2485
2486 #[test]
2487 fn normalize_url_strips_query_string() {
2488 assert_eq!(
2489 normalize_url_for_dedup("https://example.com/config.json?v=1"),
2490 "https://example.com/config.json"
2491 );
2492 }
2493
2494 #[test]
2495 fn normalize_url_strips_fragment() {
2496 assert_eq!(
2497 normalize_url_for_dedup("https://example.com/config.json#section"),
2498 "https://example.com/config.json"
2499 );
2500 }
2501
2502 #[test]
2503 fn normalize_url_strips_query_and_fragment() {
2504 assert_eq!(
2505 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2506 "https://example.com/config.json"
2507 );
2508 }
2509
2510 #[test]
2511 fn normalize_url_default_https_port() {
2512 assert_eq!(
2513 normalize_url_for_dedup("https://example.com:443/config.json"),
2514 "https://example.com/config.json"
2515 );
2516 assert_eq!(
2518 normalize_url_for_dedup("https://example.com:8443/config.json"),
2519 "https://example.com:8443/config.json"
2520 );
2521 }
2522
2523 #[test]
2524 fn extends_http_rejected() {
2525 let dir = test_dir("http-rejected");
2526 std::fs::write(
2527 dir.path().join(".fallowrc.json"),
2528 r#"{"extends": "http://example.com/config.json"}"#,
2529 )
2530 .unwrap();
2531
2532 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2533 assert!(result.is_err());
2534 let err_msg = format!("{}", result.unwrap_err());
2535 assert!(
2536 err_msg.contains("https://"),
2537 "Expected https hint in error, got: {err_msg}"
2538 );
2539 assert!(
2540 err_msg.contains("http://"),
2541 "Expected http:// mention in error, got: {err_msg}"
2542 );
2543 }
2544
2545 #[test]
2546 fn extends_url_circular_detection() {
2547 let mut visited = FxHashSet::default();
2549 let url = "https://example.com/config.json";
2550 let normalized = normalize_url_for_dedup(url);
2551 visited.insert(normalized.clone());
2552
2553 assert!(
2555 !visited.insert(normalized),
2556 "Same URL should be detected as duplicate"
2557 );
2558 }
2559
2560 #[test]
2561 fn extends_url_circular_case_insensitive() {
2562 let mut visited = FxHashSet::default();
2564 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2565
2566 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2567 assert!(
2568 !visited.insert(normalized),
2569 "Case-different URLs should normalize to the same key"
2570 );
2571 }
2572
2573 #[test]
2574 fn extract_extends_array() {
2575 let mut value = serde_json::json!({
2576 "extends": ["a.json", "b.json"],
2577 "entry": ["src/index.ts"]
2578 });
2579 let extends = extract_extends(&mut value);
2580 assert_eq!(extends, vec!["a.json", "b.json"]);
2581 assert!(value.get("extends").is_none());
2583 assert!(value.get("entry").is_some());
2584 }
2585
2586 #[test]
2587 fn extract_extends_string_sugar() {
2588 let mut value = serde_json::json!({
2589 "extends": "base.json",
2590 "entry": ["src/index.ts"]
2591 });
2592 let extends = extract_extends(&mut value);
2593 assert_eq!(extends, vec!["base.json"]);
2594 }
2595
2596 #[test]
2597 fn extract_extends_none() {
2598 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2599 let extends = extract_extends(&mut value);
2600 assert!(extends.is_empty());
2601 }
2602
2603 #[test]
2604 fn url_timeout_default() {
2605 let timeout = url_timeout();
2607 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2610 }
2611
2612 #[test]
2613 fn extends_url_mixed_with_file_and_npm() {
2614 let dir = test_dir("url-mixed");
2617 std::fs::write(
2618 dir.path().join("local.json"),
2619 r#"{"rules": {"unused-files": "warn"}}"#,
2620 )
2621 .unwrap();
2622 std::fs::write(
2623 dir.path().join(".fallowrc.json"),
2624 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2625 )
2626 .unwrap();
2627
2628 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2629 assert!(result.is_err());
2630 let err_msg = format!("{}", result.unwrap_err());
2631 assert!(
2632 err_msg.contains("unreachable.invalid"),
2633 "Expected URL in error message, got: {err_msg}"
2634 );
2635 }
2636
2637 #[test]
2638 fn extends_https_url_unreachable_errors() {
2639 let dir = test_dir("url-unreachable");
2640 std::fs::write(
2641 dir.path().join(".fallowrc.json"),
2642 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2643 )
2644 .unwrap();
2645
2646 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2647 assert!(result.is_err());
2648 let err_msg = format!("{}", result.unwrap_err());
2649 assert!(
2650 err_msg.contains("unreachable.invalid"),
2651 "Expected URL in error, got: {err_msg}"
2652 );
2653 assert!(
2654 err_msg.contains("local path or npm:"),
2655 "Expected remediation hint, got: {err_msg}"
2656 );
2657 }
2658}