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] = &[
16 ".fallowrc.json",
17 ".fallowrc.jsonc",
18 "fallow.toml",
19 ".fallow.toml",
20];
21
22pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
23
24const NPM_PREFIX: &str = "npm:";
26
27const HTTPS_PREFIX: &str = "https://";
29
30const HTTP_PREFIX: &str = "http://";
32
33const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
35
36pub(super) enum ConfigFormat {
38 Toml,
39 Json,
40}
41
42impl ConfigFormat {
43 pub(super) fn from_path(path: &Path) -> Self {
44 match path.extension().and_then(|e| e.to_str()) {
45 Some("json" | "jsonc") => Self::Json,
46 _ => Self::Toml,
47 }
48 }
49}
50
51pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
54 match (base, overlay) {
55 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
56 for (key, value) in overlay_map {
57 if let Some(base_value) = base_map.get_mut(&key) {
58 deep_merge_json(base_value, value);
59 } else {
60 base_map.insert(key, value);
61 }
62 }
63 }
64 (base, overlay) => {
65 *base = overlay;
66 }
67 }
68}
69
70pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
71 let content = std::fs::read_to_string(path)
72 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
73
74 match ConfigFormat::from_path(path) {
75 ConfigFormat::Toml => {
76 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
77 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
78 })?;
79 serde_json::to_value(toml_value).map_err(|e| {
80 miette::miette!(
81 "Failed to convert TOML to JSON for {}: {}",
82 path.display(),
83 e
84 )
85 })
86 }
87 ConfigFormat::Json => {
88 let mut stripped = String::new();
89 json_comments::StripComments::new(content.as_bytes())
90 .read_to_string(&mut stripped)
91 .map_err(|e| {
92 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
93 })?;
94 serde_json::from_str(&stripped).map_err(|e| {
95 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
96 })
97 }
98 }
99}
100
101fn is_repo_root(dir: &Path) -> bool {
109 dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
110}
111
112fn resolve_confined(
117 base_dir: &Path,
118 resolved: &Path,
119 context: &str,
120 source_config: &Path,
121) -> Result<PathBuf, miette::Report> {
122 let canonical_base = dunce::canonicalize(base_dir)
123 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
124 let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
125 miette::miette!(
126 "Config file not found: {} ({}, referenced from {}): {}",
127 resolved.display(),
128 context,
129 source_config.display(),
130 e
131 )
132 })?;
133 if !canonical_file.starts_with(&canonical_base) {
134 return Err(miette::miette!(
135 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
136 resolved.display(),
137 base_dir.display(),
138 context,
139 source_config.display()
140 ));
141 }
142 Ok(canonical_file)
143}
144
145fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
147 if name.starts_with('@') && !name.contains('/') {
148 return Err(miette::miette!(
149 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
150 name,
151 source_config.display()
152 ));
153 }
154 if name.split('/').any(|c| c == ".." || c == ".") {
155 return Err(miette::miette!(
156 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
157 name,
158 source_config.display()
159 ));
160 }
161 Ok(())
162}
163
164fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
171 if specifier.starts_with('@') {
172 let mut slashes = 0;
175 for (i, ch) in specifier.char_indices() {
176 if ch == '/' {
177 slashes += 1;
178 if slashes == 2 {
179 return (&specifier[..i], Some(&specifier[i + 1..]));
180 }
181 }
182 }
183 (specifier, None)
185 } else if let Some(slash) = specifier.find('/') {
186 (&specifier[..slash], Some(&specifier[slash + 1..]))
187 } else {
188 (specifier, None)
189 }
190}
191
192fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
199 let exports = pkg.get("exports")?;
200 match exports {
201 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
202 serde_json::Value::Object(map) => {
203 let dot_export = map.get(".")?;
204 match dot_export {
205 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
206 serde_json::Value::Object(conditions) => {
207 for key in ["default", "node", "import", "require"] {
208 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
209 return Some(package_dir.join(s.as_str()));
210 }
211 }
212 None
213 }
214 _ => None,
215 }
216 }
217 _ => None,
220 }
221}
222
223fn find_config_in_npm_package(
233 package_dir: &Path,
234 source_config: &Path,
235) -> Result<PathBuf, miette::Report> {
236 let pkg_json_path = package_dir.join("package.json");
237 if pkg_json_path.exists() {
238 let content = std::fs::read_to_string(&pkg_json_path)
239 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
240 let pkg: serde_json::Value = serde_json::from_str(&content)
241 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
242 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
243 && config_path.exists()
244 {
245 return resolve_confined(
246 package_dir,
247 &config_path,
248 "package.json exports",
249 source_config,
250 );
251 }
252 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
253 let main_path = package_dir.join(main);
254 if main_path.exists() {
255 return resolve_confined(
256 package_dir,
257 &main_path,
258 "package.json main",
259 source_config,
260 );
261 }
262 }
263 }
264
265 for config_name in CONFIG_NAMES {
266 let config_path = package_dir.join(config_name);
267 if config_path.exists() {
268 return resolve_confined(
269 package_dir,
270 &config_path,
271 "config name fallback",
272 source_config,
273 );
274 }
275 }
276
277 Err(miette::miette!(
278 "No fallow config found in npm package at {}. \
279 Expected package.json with main/exports pointing to a config file, \
280 or one of: {}",
281 package_dir.display(),
282 CONFIG_NAMES.join(", ")
283 ))
284}
285
286fn resolve_npm_package(
292 config_dir: &Path,
293 specifier: &str,
294 source_config: &Path,
295) -> Result<PathBuf, miette::Report> {
296 let specifier = specifier.trim();
297 if specifier.is_empty() {
298 return Err(miette::miette!(
299 "Empty npm specifier in extends (in {})",
300 source_config.display()
301 ));
302 }
303
304 let (package_name, subpath) = parse_npm_specifier(specifier);
305 validate_npm_package_name(package_name, source_config)?;
306
307 let mut dir = Some(config_dir);
308 while let Some(d) = dir {
309 let candidate = d.join("node_modules").join(package_name);
310 if candidate.is_dir() {
311 return if let Some(sub) = subpath {
312 let file = candidate.join(sub);
313 if file.exists() {
314 resolve_confined(
315 &candidate,
316 &file,
317 &format!("subpath '{sub}'"),
318 source_config,
319 )
320 } else {
321 Err(miette::miette!(
322 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
323 file.display(),
324 sub,
325 candidate.display(),
326 source_config.display()
327 ))
328 }
329 } else {
330 find_config_in_npm_package(&candidate, source_config)
331 };
332 }
333 dir = d.parent();
334 }
335
336 Err(miette::miette!(
337 "npm package '{}' not found. \
338 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
339 If this package should be available, install it and ensure it is listed in your project's dependencies",
340 package_name,
341 package_name,
342 config_dir.display(),
343 source_config.display()
344 ))
345}
346
347fn normalize_url_for_dedup(url: &str) -> String {
354 let Some((scheme, rest)) = url.split_once("://") else {
356 return url.to_string();
357 };
358 let scheme = scheme.to_ascii_lowercase();
359
360 let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
362 let authority = authority.to_ascii_lowercase();
363
364 let authority = authority.strip_suffix(":443").unwrap_or(&authority);
366
367 let path = path.split_once('#').map_or(path, |(p, _)| p);
369 let path = path.split_once('?').map_or(path, |(p, _)| p);
370 let path = path.strip_suffix('/').unwrap_or(path);
371
372 if path.is_empty() {
373 format!("{scheme}://{authority}")
374 } else {
375 format!("{scheme}://{authority}/{path}")
376 }
377}
378
379fn url_timeout() -> Duration {
384 std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
385 .ok()
386 .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
387 .map_or(
388 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
389 Duration::from_secs,
390 )
391}
392
393const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
396
397fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
402 let timeout = url_timeout();
403 let agent = ureq::Agent::config_builder()
404 .timeout_global(Some(timeout))
405 .https_only(true)
406 .build()
407 .new_agent();
408
409 let mut response = agent.get(url).call().map_err(|e| {
410 miette::miette!(
411 "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
412 If this URL is unavailable, use a local path or npm: specifier instead"
413 )
414 })?;
415
416 let body = response
417 .body_mut()
418 .with_config()
419 .limit(MAX_URL_CONFIG_BYTES)
420 .read_to_string()
421 .map_err(|e| {
422 miette::miette!(
423 "Failed to read response body from {url} (referenced from {source}): {e}"
424 )
425 })?;
426
427 let mut stripped = String::new();
429 json_comments::StripComments::new(body.as_bytes())
430 .read_to_string(&mut stripped)
431 .map_err(|e| {
432 miette::miette!(
433 "Failed to strip comments from remote config {url} (referenced from {source}): {e}"
434 )
435 })?;
436
437 serde_json::from_str(&stripped).map_err(|e| {
438 miette::miette!(
439 "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
440 Only JSON/JSONC is supported for URL-sourced configs"
441 )
442 })
443}
444
445fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
447 value
448 .as_object_mut()
449 .and_then(|obj| obj.remove("extends"))
450 .and_then(|v| match v {
451 serde_json::Value::Array(arr) => Some(
452 arr.into_iter()
453 .filter_map(|v| v.as_str().map(String::from))
454 .collect::<Vec<_>>(),
455 ),
456 serde_json::Value::String(s) => Some(vec![s]),
457 _ => None,
458 })
459 .unwrap_or_default()
460}
461
462fn resolve_url_extends(
467 url: &str,
468 visited: &mut FxHashSet<String>,
469 depth: usize,
470) -> Result<serde_json::Value, miette::Report> {
471 if depth >= MAX_EXTENDS_DEPTH {
472 return Err(miette::miette!(
473 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
474 ));
475 }
476
477 let normalized = normalize_url_for_dedup(url);
478 if !visited.insert(normalized) {
479 return Err(miette::miette!(
480 "Circular extends detected: {url} was already visited in the extends chain"
481 ));
482 }
483
484 let mut value = fetch_url_config(url, url)?;
485 let extends = extract_extends(&mut value);
486
487 if extends.is_empty() {
488 return Ok(value);
489 }
490
491 let mut merged = serde_json::Value::Object(serde_json::Map::new());
492
493 for entry in &extends {
494 let base = if entry.starts_with(HTTPS_PREFIX) {
495 resolve_url_extends(entry, visited, depth + 1)?
496 } else if entry.starts_with(HTTP_PREFIX) {
497 return Err(miette::miette!(
498 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
499 Change the URL to use https:// instead",
500 entry,
501 url
502 ));
503 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
504 let cwd = std::env::current_dir().map_err(|e| {
508 miette::miette!(
509 "Cannot resolve npm: specifier from URL-sourced config: \
510 failed to determine current directory: {e}"
511 )
512 })?;
513 tracing::warn!(
514 "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
515 current working directory for node_modules lookup"
516 );
517 let path_placeholder = PathBuf::from(url);
518 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
519 resolve_extends_file(&npm_path, visited, depth + 1)?
520 } else {
521 return Err(miette::miette!(
522 "Relative paths in 'extends' are not supported when the base config was \
523 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
524 instead. Got: '{entry}'"
525 ));
526 };
527 deep_merge_json(&mut merged, base);
528 }
529
530 deep_merge_json(&mut merged, value);
531 Ok(merged)
532}
533
534fn resolve_extends_file(
540 path: &Path,
541 visited: &mut FxHashSet<String>,
542 depth: usize,
543) -> Result<serde_json::Value, miette::Report> {
544 if depth >= MAX_EXTENDS_DEPTH {
545 return Err(miette::miette!(
546 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
547 path.display()
548 ));
549 }
550
551 let canonical = dunce::canonicalize(path).map_err(|e| {
552 miette::miette!(
553 "Config file not found or unresolvable: {}: {}",
554 path.display(),
555 e
556 )
557 })?;
558
559 if !visited.insert(canonical.to_string_lossy().into_owned()) {
560 return Err(miette::miette!(
561 "Circular extends detected: {} was already visited in the extends chain",
562 path.display()
563 ));
564 }
565
566 let mut value = parse_config_to_value(path)?;
567 let extends = extract_extends(&mut value);
568
569 if extends.is_empty() {
570 return Ok(value);
571 }
572
573 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
574 let sealed = value
575 .get("sealed")
576 .and_then(serde_json::Value::as_bool)
577 .unwrap_or(false);
578 let sealed_dir_canonical = if sealed {
581 Some(dunce::canonicalize(config_dir).map_err(|e| {
582 miette::miette!(
583 "Sealed config directory '{}' could not be canonicalized: {e}",
584 config_dir.display()
585 )
586 })?)
587 } else {
588 None
589 };
590 let mut merged = serde_json::Value::Object(serde_json::Map::new());
591
592 for extend_path_str in &extends {
593 let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
594 if sealed {
595 return Err(miette::miette!(
596 "'sealed: true' config at {} rejects URL extends '{}'. \
597 Sealed configs only allow file-relative extends within \
598 the config's directory",
599 path.display(),
600 extend_path_str
601 ));
602 }
603 resolve_url_extends(extend_path_str, visited, depth + 1)?
604 } else if extend_path_str.starts_with(HTTP_PREFIX) {
605 return Err(miette::miette!(
606 "URL extends must use https://, got http:// URL '{}' (in {}). \
607 Change the URL to use https:// instead",
608 extend_path_str,
609 path.display()
610 ));
611 } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
612 if sealed {
613 return Err(miette::miette!(
614 "'sealed: true' config at {} rejects npm extends '{}'. \
615 Sealed configs only allow file-relative extends within \
616 the config's directory",
617 path.display(),
618 extend_path_str
619 ));
620 }
621 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
622 resolve_extends_file(&npm_path, visited, depth + 1)?
623 } else {
624 if Path::new(extend_path_str).is_absolute() {
625 return Err(miette::miette!(
626 "extends paths must be relative, got absolute path: {} (in {})",
627 extend_path_str,
628 path.display()
629 ));
630 }
631 let p = config_dir.join(extend_path_str);
632 if !p.exists() {
633 return Err(miette::miette!(
634 "Extended config file not found: {} (referenced from {})",
635 p.display(),
636 path.display()
637 ));
638 }
639 if let Some(dir_canonical) = &sealed_dir_canonical {
640 let p_canonical = dunce::canonicalize(&p).map_err(|e| {
641 miette::miette!(
642 "Sealed config extends path '{}' could not be canonicalized: {e}",
643 p.display()
644 )
645 })?;
646 if !p_canonical.starts_with(dir_canonical) {
647 return Err(miette::miette!(
648 "'sealed: true' config at {} rejects extends '{}' which resolves \
649 outside the config's directory ({}). Sealed configs only allow \
650 extends within the config's directory",
651 path.display(),
652 extend_path_str,
653 p_canonical.display()
654 ));
655 }
656 }
657 resolve_extends_file(&p, visited, depth + 1)?
658 };
659 deep_merge_json(&mut merged, base);
660 }
661
662 deep_merge_json(&mut merged, value);
663 Ok(merged)
664}
665
666pub(super) fn resolve_extends(
670 path: &Path,
671 visited: &mut FxHashSet<String>,
672 depth: usize,
673) -> Result<serde_json::Value, miette::Report> {
674 resolve_extends_file(path, visited, depth)
675}
676
677impl FallowConfig {
678 pub fn load(path: &Path) -> Result<Self, miette::Report> {
691 let mut visited = FxHashSet::default();
692 let merged = resolve_extends(path, &mut visited, 0)?;
693
694 serde_json::from_value(merged).map_err(|e| {
695 miette::miette!(
696 "Failed to deserialize config from {}: {}",
697 path.display(),
698 e
699 )
700 })
701 }
702
703 #[must_use]
706 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
707 let mut dir = start;
708 loop {
709 for name in CONFIG_NAMES {
710 let candidate = dir.join(name);
711 if candidate.exists() {
712 return Some(candidate);
713 }
714 }
715 if is_repo_root(dir) {
716 break;
717 }
718 dir = dir.parent()?;
719 }
720 None
721 }
722
723 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
729 let mut dir = start;
730 loop {
731 for name in CONFIG_NAMES {
732 let candidate = dir.join(name);
733 if candidate.exists() {
734 match Self::load(&candidate) {
735 Ok(config) => return Ok(Some((config, candidate))),
736 Err(e) => {
737 return Err(format!("Failed to parse {}: {e}", candidate.display()));
738 }
739 }
740 }
741 }
742 if is_repo_root(dir) {
746 break;
747 }
748 dir = match dir.parent() {
749 Some(parent) => parent,
750 None => break,
751 };
752 }
753 Ok(None)
754 }
755
756 #[must_use]
758 pub fn json_schema() -> serde_json::Value {
759 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use std::io::Read as _;
766
767 use super::*;
768 use crate::PackageJson;
769 use crate::config::format::OutputFormat;
770 use crate::config::rules::Severity;
771
772 fn test_dir(_name: &str) -> tempfile::TempDir {
774 tempfile::tempdir().expect("create temp dir")
775 }
776
777 #[test]
778 fn fallow_config_deserialize_minimal() {
779 let toml_str = r#"
780entry = ["src/main.ts"]
781"#;
782 let config: FallowConfig = toml::from_str(toml_str).unwrap();
783 assert_eq!(config.entry, vec!["src/main.ts"]);
784 assert!(config.ignore_patterns.is_empty());
785 }
786
787 #[test]
788 fn fallow_config_deserialize_ignore_exports() {
789 let toml_str = r#"
790[[ignoreExports]]
791file = "src/types/*.ts"
792exports = ["*"]
793
794[[ignoreExports]]
795file = "src/constants.ts"
796exports = ["FOO", "BAR"]
797"#;
798 let config: FallowConfig = toml::from_str(toml_str).unwrap();
799 assert_eq!(config.ignore_exports.len(), 2);
800 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
801 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
802 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
803 }
804
805 #[test]
806 fn fallow_config_deserialize_ignore_dependencies() {
807 let toml_str = r#"
808ignoreDependencies = ["autoprefixer", "postcss"]
809"#;
810 let config: FallowConfig = toml::from_str(toml_str).unwrap();
811 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
812 }
813
814 #[test]
815 fn fallow_config_resolve_default_ignores() {
816 let config = FallowConfig::default();
817 let resolved = config.resolve(
818 PathBuf::from("/tmp/test"),
819 OutputFormat::Human,
820 4,
821 true,
822 true,
823 );
824
825 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
827 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
828 assert!(resolved.ignore_patterns.is_match("build/output.js"));
829 assert!(resolved.ignore_patterns.is_match(".git/config"));
830 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
831 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
832 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
833 }
834
835 #[test]
836 fn fallow_config_resolve_custom_ignores() {
837 let config = FallowConfig {
838 entry: vec!["src/**/*.ts".to_string()],
839 ignore_patterns: vec!["**/*.generated.ts".to_string()],
840 ..Default::default()
841 };
842 let resolved = config.resolve(
843 PathBuf::from("/tmp/test"),
844 OutputFormat::Json,
845 4,
846 false,
847 true,
848 );
849
850 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
851 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
852 assert!(matches!(resolved.output, OutputFormat::Json));
853 assert!(!resolved.no_cache);
854 }
855
856 #[test]
857 fn fallow_config_resolve_cache_dir() {
858 let config = FallowConfig::default();
859 let resolved = config.resolve(
860 PathBuf::from("/tmp/project"),
861 OutputFormat::Human,
862 4,
863 true,
864 true,
865 );
866 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
867 assert!(resolved.no_cache);
868 }
869
870 #[test]
871 fn package_json_entry_points_main() {
872 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
873 let entries = pkg.entry_points();
874 assert!(entries.contains(&"dist/index.js".to_string()));
875 }
876
877 #[test]
878 fn package_json_entry_points_module() {
879 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
880 let entries = pkg.entry_points();
881 assert!(entries.contains(&"dist/index.mjs".to_string()));
882 }
883
884 #[test]
885 fn package_json_entry_points_types() {
886 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
887 let entries = pkg.entry_points();
888 assert!(entries.contains(&"dist/index.d.ts".to_string()));
889 }
890
891 #[test]
892 fn package_json_entry_points_bin_string() {
893 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
894 let entries = pkg.entry_points();
895 assert!(entries.contains(&"bin/cli.js".to_string()));
896 }
897
898 #[test]
899 fn package_json_entry_points_bin_object() {
900 let pkg: PackageJson =
901 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
902 .unwrap();
903 let entries = pkg.entry_points();
904 assert!(entries.contains(&"bin/cli.js".to_string()));
905 assert!(entries.contains(&"bin/serve.js".to_string()));
906 }
907
908 #[test]
909 fn package_json_entry_points_exports_string() {
910 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
911 let entries = pkg.entry_points();
912 assert!(entries.contains(&"./dist/index.js".to_string()));
913 }
914
915 #[test]
916 fn package_json_entry_points_exports_object() {
917 let pkg: PackageJson = serde_json::from_str(
918 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
919 )
920 .unwrap();
921 let entries = pkg.entry_points();
922 assert!(entries.contains(&"./dist/index.mjs".to_string()));
923 assert!(entries.contains(&"./dist/index.cjs".to_string()));
924 }
925
926 #[test]
927 fn package_json_dependency_names() {
928 let pkg: PackageJson = serde_json::from_str(
929 r#"{
930 "dependencies": {"react": "^18", "lodash": "^4"},
931 "devDependencies": {"typescript": "^5"},
932 "peerDependencies": {"react-dom": "^18"}
933 }"#,
934 )
935 .unwrap();
936
937 let all = pkg.all_dependency_names();
938 assert!(all.contains(&"react".to_string()));
939 assert!(all.contains(&"lodash".to_string()));
940 assert!(all.contains(&"typescript".to_string()));
941 assert!(all.contains(&"react-dom".to_string()));
942
943 let prod = pkg.production_dependency_names();
944 assert!(prod.contains(&"react".to_string()));
945 assert!(!prod.contains(&"typescript".to_string()));
946
947 let dev = pkg.dev_dependency_names();
948 assert!(dev.contains(&"typescript".to_string()));
949 assert!(!dev.contains(&"react".to_string()));
950 }
951
952 #[test]
953 fn package_json_no_dependencies() {
954 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
955 assert!(pkg.all_dependency_names().is_empty());
956 assert!(pkg.production_dependency_names().is_empty());
957 assert!(pkg.dev_dependency_names().is_empty());
958 assert!(pkg.entry_points().is_empty());
959 }
960
961 #[test]
962 fn rules_deserialize_toml_kebab_case() {
963 let toml_str = r#"
964[rules]
965unused-files = "error"
966unused-exports = "warn"
967unused-types = "off"
968"#;
969 let config: FallowConfig = toml::from_str(toml_str).unwrap();
970 assert_eq!(config.rules.unused_files, Severity::Error);
971 assert_eq!(config.rules.unused_exports, Severity::Warn);
972 assert_eq!(config.rules.unused_types, Severity::Off);
973 assert_eq!(config.rules.unresolved_imports, Severity::Error);
975 }
976
977 #[test]
978 fn config_without_rules_defaults_to_error() {
979 let toml_str = r#"
980entry = ["src/main.ts"]
981"#;
982 let config: FallowConfig = toml::from_str(toml_str).unwrap();
983 assert_eq!(config.rules.unused_files, Severity::Error);
984 assert_eq!(config.rules.unused_exports, Severity::Error);
985 }
986
987 #[test]
988 fn fallow_config_denies_unknown_fields() {
989 let toml_str = r"
990unknown_field = true
991";
992 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
993 assert!(result.is_err());
994 }
995
996 #[test]
997 fn fallow_config_deserialize_json() {
998 let json_str = r#"{"entry": ["src/main.ts"]}"#;
999 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1000 assert_eq!(config.entry, vec!["src/main.ts"]);
1001 }
1002
1003 #[test]
1004 fn fallow_config_deserialize_jsonc() {
1005 let jsonc_str = r#"{
1006 // This is a comment
1007 "entry": ["src/main.ts"],
1008 "rules": {
1009 "unused-files": "warn"
1010 }
1011 }"#;
1012 let mut stripped = String::new();
1013 json_comments::StripComments::new(jsonc_str.as_bytes())
1014 .read_to_string(&mut stripped)
1015 .unwrap();
1016 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1017 assert_eq!(config.entry, vec!["src/main.ts"]);
1018 assert_eq!(config.rules.unused_files, Severity::Warn);
1019 }
1020
1021 #[test]
1022 fn fallow_config_json_with_schema_field() {
1023 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1024 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1025 assert_eq!(config.entry, vec!["src/main.ts"]);
1026 }
1027
1028 #[test]
1029 fn fallow_config_json_schema_generation() {
1030 let schema = FallowConfig::json_schema();
1031 assert!(schema.is_object());
1032 let obj = schema.as_object().unwrap();
1033 assert!(obj.contains_key("properties"));
1034 }
1035
1036 #[test]
1037 fn config_format_detection() {
1038 assert!(matches!(
1039 ConfigFormat::from_path(Path::new("fallow.toml")),
1040 ConfigFormat::Toml
1041 ));
1042 assert!(matches!(
1043 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1044 ConfigFormat::Json
1045 ));
1046 assert!(matches!(
1047 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1048 ConfigFormat::Json
1049 ));
1050 assert!(matches!(
1051 ConfigFormat::from_path(Path::new(".fallow.toml")),
1052 ConfigFormat::Toml
1053 ));
1054 }
1055
1056 #[test]
1057 fn config_names_priority_order() {
1058 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1059 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1060 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1061 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1062 }
1063
1064 #[test]
1065 fn load_json_config_file() {
1066 let dir = test_dir("json-config");
1067 let config_path = dir.path().join(".fallowrc.json");
1068 std::fs::write(
1069 &config_path,
1070 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1071 )
1072 .unwrap();
1073
1074 let config = FallowConfig::load(&config_path).unwrap();
1075 assert_eq!(config.entry, vec!["src/index.ts"]);
1076 assert_eq!(config.rules.unused_exports, Severity::Warn);
1077 }
1078
1079 #[test]
1080 fn load_jsonc_config_file() {
1081 let dir = test_dir("jsonc-config");
1082 let config_path = dir.path().join(".fallowrc.json");
1083 std::fs::write(
1084 &config_path,
1085 r#"{
1086 // Entry points for analysis
1087 "entry": ["src/index.ts"],
1088 /* Block comment */
1089 "rules": {
1090 "unused-exports": "warn"
1091 }
1092 }"#,
1093 )
1094 .unwrap();
1095
1096 let config = FallowConfig::load(&config_path).unwrap();
1097 assert_eq!(config.entry, vec!["src/index.ts"]);
1098 assert_eq!(config.rules.unused_exports, Severity::Warn);
1099 }
1100
1101 #[test]
1102 fn load_fallowrc_jsonc_extension() {
1103 let dir = test_dir("jsonc-extension");
1104 let config_path = dir.path().join(".fallowrc.jsonc");
1105 std::fs::write(
1106 &config_path,
1107 r#"{
1108 // editors that recognize the .jsonc extension show
1109 // proper JSON-with-comments syntax highlighting
1110 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1111 "entry": ["src/index.ts"]
1112 }"#,
1113 )
1114 .unwrap();
1115
1116 let config = FallowConfig::load(&config_path).unwrap();
1117 assert_eq!(config.entry, vec!["src/index.ts"]);
1118 assert_eq!(
1119 config.ignore_dependencies,
1120 vec!["tailwindcss-react-aria-components"]
1121 );
1122 }
1123
1124 #[test]
1125 fn json_config_ignore_dependencies_camel_case() {
1126 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1127 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1128 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1129 }
1130
1131 #[test]
1132 fn json_config_all_fields() {
1133 let json_str = r#"{
1134 "ignoreDependencies": ["lodash"],
1135 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1136 "rules": {
1137 "unused-files": "off",
1138 "unused-exports": "warn",
1139 "unused-dependencies": "error",
1140 "unused-dev-dependencies": "off",
1141 "unused-types": "warn",
1142 "unused-enum-members": "error",
1143 "unused-class-members": "off",
1144 "unresolved-imports": "warn",
1145 "unlisted-dependencies": "error",
1146 "duplicate-exports": "off"
1147 },
1148 "duplicates": {
1149 "minTokens": 100,
1150 "minLines": 10,
1151 "skipLocal": true
1152 }
1153 }"#;
1154 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1155 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1156 assert_eq!(config.rules.unused_files, Severity::Off);
1157 assert_eq!(config.rules.unused_exports, Severity::Warn);
1158 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1159 assert_eq!(config.duplicates.min_tokens, 100);
1160 assert_eq!(config.duplicates.min_lines, 10);
1161 assert!(config.duplicates.skip_local);
1162 }
1163
1164 #[test]
1167 fn extends_single_base() {
1168 let dir = test_dir("extends-single");
1169
1170 std::fs::write(
1171 dir.path().join("base.json"),
1172 r#"{"rules": {"unused-files": "warn"}}"#,
1173 )
1174 .unwrap();
1175 std::fs::write(
1176 dir.path().join(".fallowrc.json"),
1177 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1178 )
1179 .unwrap();
1180
1181 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1182 assert_eq!(config.rules.unused_files, Severity::Warn);
1183 assert_eq!(config.entry, vec!["src/index.ts"]);
1184 assert_eq!(config.rules.unused_exports, Severity::Error);
1186 }
1187
1188 #[test]
1189 fn extends_overlay_overrides_base() {
1190 let dir = test_dir("extends-overlay");
1191
1192 std::fs::write(
1193 dir.path().join("base.json"),
1194 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1195 )
1196 .unwrap();
1197 std::fs::write(
1198 dir.path().join(".fallowrc.json"),
1199 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1200 )
1201 .unwrap();
1202
1203 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1204 assert_eq!(config.rules.unused_files, Severity::Error);
1206 assert_eq!(config.rules.unused_exports, Severity::Off);
1208 }
1209
1210 #[test]
1211 fn extends_chained() {
1212 let dir = test_dir("extends-chained");
1213
1214 std::fs::write(
1215 dir.path().join("grandparent.json"),
1216 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1217 )
1218 .unwrap();
1219 std::fs::write(
1220 dir.path().join("parent.json"),
1221 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1222 )
1223 .unwrap();
1224 std::fs::write(
1225 dir.path().join(".fallowrc.json"),
1226 r#"{"extends": ["parent.json"]}"#,
1227 )
1228 .unwrap();
1229
1230 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1231 assert_eq!(config.rules.unused_files, Severity::Warn);
1233 assert_eq!(config.rules.unused_exports, Severity::Warn);
1235 }
1236
1237 #[test]
1238 fn extends_circular_detected() {
1239 let dir = test_dir("extends-circular");
1240
1241 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1242 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1243
1244 let result = FallowConfig::load(&dir.path().join("a.json"));
1245 assert!(result.is_err());
1246 let err_msg = format!("{}", result.unwrap_err());
1247 assert!(
1248 err_msg.contains("Circular extends"),
1249 "Expected circular error, got: {err_msg}"
1250 );
1251 }
1252
1253 #[test]
1254 fn extends_missing_file_errors() {
1255 let dir = test_dir("extends-missing");
1256
1257 std::fs::write(
1258 dir.path().join(".fallowrc.json"),
1259 r#"{"extends": ["nonexistent.json"]}"#,
1260 )
1261 .unwrap();
1262
1263 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1264 assert!(result.is_err());
1265 let err_msg = format!("{}", result.unwrap_err());
1266 assert!(
1267 err_msg.contains("not found"),
1268 "Expected not found error, got: {err_msg}"
1269 );
1270 }
1271
1272 #[test]
1275 fn sealed_allows_in_directory_extends() {
1276 let dir = test_dir("sealed-allows-local");
1277 std::fs::write(
1278 dir.path().join("base.json"),
1279 r#"{"ignorePatterns": ["gen/**"]}"#,
1280 )
1281 .unwrap();
1282 std::fs::write(
1283 dir.path().join(".fallowrc.json"),
1284 r#"{"sealed": true, "extends": ["./base.json"]}"#,
1285 )
1286 .unwrap();
1287
1288 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1289 assert!(config.sealed);
1290 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1291 }
1292
1293 #[test]
1294 fn sealed_rejects_extends_escaping_directory() {
1295 let dir = test_dir("sealed-rejects-escape");
1296 let sub = dir.path().join("packages").join("app");
1297 std::fs::create_dir_all(&sub).unwrap();
1298
1299 std::fs::write(
1301 dir.path().join("base.json"),
1302 r#"{"ignorePatterns": ["dist/**"]}"#,
1303 )
1304 .unwrap();
1305 std::fs::write(
1306 sub.join(".fallowrc.json"),
1307 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1308 )
1309 .unwrap();
1310
1311 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1312 assert!(
1313 result.is_err(),
1314 "Expected sealed config to reject escaping extends"
1315 );
1316 let err_msg = format!("{}", result.unwrap_err());
1317 assert!(
1318 err_msg.contains("sealed"),
1319 "Error must mention sealed: {err_msg}"
1320 );
1321 assert!(
1322 err_msg.contains("outside the config's directory"),
1323 "Error must explain the constraint: {err_msg}"
1324 );
1325 }
1326
1327 #[test]
1328 fn sealed_rejects_https_extends() {
1329 let dir = test_dir("sealed-rejects-https");
1330 std::fs::write(
1331 dir.path().join(".fallowrc.json"),
1332 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1333 )
1334 .unwrap();
1335
1336 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1337 assert!(result.is_err());
1338 let err_msg = format!("{}", result.unwrap_err());
1339 assert!(
1340 err_msg.contains("sealed"),
1341 "Error must mention sealed: {err_msg}"
1342 );
1343 assert!(
1344 err_msg.contains("URL extends"),
1345 "Error must mention URL: {err_msg}"
1346 );
1347 }
1348
1349 #[test]
1350 fn sealed_rejects_npm_extends() {
1351 let dir = test_dir("sealed-rejects-npm");
1352 std::fs::write(
1353 dir.path().join(".fallowrc.json"),
1354 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1355 )
1356 .unwrap();
1357
1358 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1359 assert!(result.is_err());
1360 let err_msg = format!("{}", result.unwrap_err());
1361 assert!(
1362 err_msg.contains("sealed"),
1363 "Error must mention sealed: {err_msg}"
1364 );
1365 assert!(
1366 err_msg.contains("npm extends"),
1367 "Error must mention npm: {err_msg}"
1368 );
1369 }
1370
1371 #[test]
1372 fn sealed_default_is_false() {
1373 let dir = test_dir("sealed-default");
1374 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1375 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1376 assert!(!config.sealed);
1377 }
1378
1379 #[test]
1380 fn sealed_false_allows_escaping_extends() {
1381 let dir = test_dir("sealed-false-allows");
1383 let sub = dir.path().join("packages").join("app");
1384 std::fs::create_dir_all(&sub).unwrap();
1385
1386 std::fs::write(
1387 dir.path().join("base.json"),
1388 r#"{"ignorePatterns": ["dist/**"]}"#,
1389 )
1390 .unwrap();
1391 std::fs::write(
1392 sub.join(".fallowrc.json"),
1393 r#"{"extends": ["../../base.json"]}"#,
1394 )
1395 .unwrap();
1396
1397 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1398 assert!(!config.sealed);
1399 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1400 }
1401
1402 #[test]
1403 fn extends_string_sugar() {
1404 let dir = test_dir("extends-string");
1405
1406 std::fs::write(
1407 dir.path().join("base.json"),
1408 r#"{"ignorePatterns": ["gen/**"]}"#,
1409 )
1410 .unwrap();
1411 std::fs::write(
1413 dir.path().join(".fallowrc.json"),
1414 r#"{"extends": "base.json"}"#,
1415 )
1416 .unwrap();
1417
1418 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1419 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1420 }
1421
1422 #[test]
1423 fn extends_deep_merge_preserves_arrays() {
1424 let dir = test_dir("extends-array");
1425
1426 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1427 std::fs::write(
1428 dir.path().join(".fallowrc.json"),
1429 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1430 )
1431 .unwrap();
1432
1433 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1434 assert_eq!(config.entry, vec!["src/b.ts"]);
1436 }
1437
1438 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1442 let pkg_dir = root.join("node_modules").join(name);
1443 std::fs::create_dir_all(&pkg_dir).unwrap();
1444 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1445 }
1446
1447 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1449 let pkg_dir = root.join("node_modules").join(name);
1450 std::fs::create_dir_all(&pkg_dir).unwrap();
1451 std::fs::write(
1452 pkg_dir.join("package.json"),
1453 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1454 )
1455 .unwrap();
1456 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1457 }
1458
1459 #[test]
1460 fn extends_npm_basic_unscoped() {
1461 let dir = test_dir("npm-basic");
1462 create_npm_package(
1463 dir.path(),
1464 "fallow-config-acme",
1465 r#"{"rules": {"unused-files": "warn"}}"#,
1466 );
1467 std::fs::write(
1468 dir.path().join(".fallowrc.json"),
1469 r#"{"extends": "npm:fallow-config-acme"}"#,
1470 )
1471 .unwrap();
1472
1473 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1474 assert_eq!(config.rules.unused_files, Severity::Warn);
1475 }
1476
1477 #[test]
1478 fn extends_npm_scoped_package() {
1479 let dir = test_dir("npm-scoped");
1480 create_npm_package(
1481 dir.path(),
1482 "@company/fallow-config",
1483 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1484 );
1485 std::fs::write(
1486 dir.path().join(".fallowrc.json"),
1487 r#"{"extends": "npm:@company/fallow-config"}"#,
1488 )
1489 .unwrap();
1490
1491 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1492 assert_eq!(config.rules.unused_exports, Severity::Off);
1493 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1494 }
1495
1496 #[test]
1497 fn extends_npm_with_subpath() {
1498 let dir = test_dir("npm-subpath");
1499 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1500 std::fs::create_dir_all(&pkg_dir).unwrap();
1501 std::fs::write(
1502 pkg_dir.join("strict.json"),
1503 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1504 )
1505 .unwrap();
1506
1507 std::fs::write(
1508 dir.path().join(".fallowrc.json"),
1509 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1510 )
1511 .unwrap();
1512
1513 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1514 assert_eq!(config.rules.unused_files, Severity::Error);
1515 assert_eq!(config.rules.unused_exports, Severity::Error);
1516 }
1517
1518 #[test]
1519 fn extends_npm_package_json_main() {
1520 let dir = test_dir("npm-main");
1521 create_npm_package_with_main(
1522 dir.path(),
1523 "fallow-config-acme",
1524 "config.json",
1525 r#"{"rules": {"unused-types": "off"}}"#,
1526 );
1527 std::fs::write(
1528 dir.path().join(".fallowrc.json"),
1529 r#"{"extends": "npm:fallow-config-acme"}"#,
1530 )
1531 .unwrap();
1532
1533 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1534 assert_eq!(config.rules.unused_types, Severity::Off);
1535 }
1536
1537 #[test]
1538 fn extends_npm_package_json_exports_string() {
1539 let dir = test_dir("npm-exports-str");
1540 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1541 std::fs::create_dir_all(&pkg_dir).unwrap();
1542 std::fs::write(
1543 pkg_dir.join("package.json"),
1544 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1545 )
1546 .unwrap();
1547 std::fs::write(
1548 pkg_dir.join("base.json"),
1549 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1550 )
1551 .unwrap();
1552
1553 std::fs::write(
1554 dir.path().join(".fallowrc.json"),
1555 r#"{"extends": "npm:fallow-config-co"}"#,
1556 )
1557 .unwrap();
1558
1559 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1560 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1561 }
1562
1563 #[test]
1564 fn extends_npm_package_json_exports_object() {
1565 let dir = test_dir("npm-exports-obj");
1566 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1567 std::fs::create_dir_all(&pkg_dir).unwrap();
1568 std::fs::write(
1569 pkg_dir.join("package.json"),
1570 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1571 )
1572 .unwrap();
1573 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1574
1575 std::fs::write(
1576 dir.path().join(".fallowrc.json"),
1577 r#"{"extends": "npm:@co/cfg"}"#,
1578 )
1579 .unwrap();
1580
1581 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1582 assert_eq!(config.entry, vec!["src/app.ts"]);
1583 }
1584
1585 #[test]
1586 fn extends_npm_exports_takes_priority_over_main() {
1587 let dir = test_dir("npm-exports-prio");
1588 let pkg_dir = dir.path().join("node_modules/my-config");
1589 std::fs::create_dir_all(&pkg_dir).unwrap();
1590 std::fs::write(
1591 pkg_dir.join("package.json"),
1592 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1593 )
1594 .unwrap();
1595 std::fs::write(
1596 pkg_dir.join("old.json"),
1597 r#"{"rules": {"unused-files": "off"}}"#,
1598 )
1599 .unwrap();
1600 std::fs::write(
1601 pkg_dir.join("new.json"),
1602 r#"{"rules": {"unused-files": "warn"}}"#,
1603 )
1604 .unwrap();
1605
1606 std::fs::write(
1607 dir.path().join(".fallowrc.json"),
1608 r#"{"extends": "npm:my-config"}"#,
1609 )
1610 .unwrap();
1611
1612 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1613 assert_eq!(config.rules.unused_files, Severity::Warn);
1615 }
1616
1617 #[test]
1618 fn extends_npm_walk_up_directories() {
1619 let dir = test_dir("npm-walkup");
1620 create_npm_package(
1622 dir.path(),
1623 "shared-config",
1624 r#"{"rules": {"unused-files": "warn"}}"#,
1625 );
1626 let sub = dir.path().join("packages/app");
1628 std::fs::create_dir_all(&sub).unwrap();
1629 std::fs::write(
1630 sub.join(".fallowrc.json"),
1631 r#"{"extends": "npm:shared-config"}"#,
1632 )
1633 .unwrap();
1634
1635 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1636 assert_eq!(config.rules.unused_files, Severity::Warn);
1637 }
1638
1639 #[test]
1640 fn extends_npm_overlay_overrides_base() {
1641 let dir = test_dir("npm-overlay");
1642 create_npm_package(
1643 dir.path(),
1644 "@company/base",
1645 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1646 );
1647 std::fs::write(
1648 dir.path().join(".fallowrc.json"),
1649 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1650 )
1651 .unwrap();
1652
1653 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1654 assert_eq!(config.rules.unused_files, Severity::Error);
1655 assert_eq!(config.rules.unused_exports, Severity::Off);
1656 assert_eq!(config.entry, vec!["src/app.ts"]);
1657 }
1658
1659 #[test]
1660 fn extends_npm_chained_with_relative() {
1661 let dir = test_dir("npm-chained");
1662 let pkg_dir = dir.path().join("node_modules/my-config");
1664 std::fs::create_dir_all(&pkg_dir).unwrap();
1665 std::fs::write(
1666 pkg_dir.join("base.json"),
1667 r#"{"rules": {"unused-files": "warn"}}"#,
1668 )
1669 .unwrap();
1670 std::fs::write(
1671 pkg_dir.join(".fallowrc.json"),
1672 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1673 )
1674 .unwrap();
1675
1676 std::fs::write(
1677 dir.path().join(".fallowrc.json"),
1678 r#"{"extends": "npm:my-config"}"#,
1679 )
1680 .unwrap();
1681
1682 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1683 assert_eq!(config.rules.unused_files, Severity::Warn);
1684 assert_eq!(config.rules.unused_exports, Severity::Off);
1685 }
1686
1687 #[test]
1688 fn extends_npm_mixed_with_relative_paths() {
1689 let dir = test_dir("npm-mixed");
1690 create_npm_package(
1691 dir.path(),
1692 "shared-base",
1693 r#"{"rules": {"unused-files": "off"}}"#,
1694 );
1695 std::fs::write(
1696 dir.path().join("local-overrides.json"),
1697 r#"{"rules": {"unused-files": "warn"}}"#,
1698 )
1699 .unwrap();
1700 std::fs::write(
1701 dir.path().join(".fallowrc.json"),
1702 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1703 )
1704 .unwrap();
1705
1706 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1707 assert_eq!(config.rules.unused_files, Severity::Warn);
1709 }
1710
1711 #[test]
1712 fn extends_npm_missing_package_errors() {
1713 let dir = test_dir("npm-missing");
1714 std::fs::write(
1715 dir.path().join(".fallowrc.json"),
1716 r#"{"extends": "npm:nonexistent-package"}"#,
1717 )
1718 .unwrap();
1719
1720 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1721 assert!(result.is_err());
1722 let err_msg = format!("{}", result.unwrap_err());
1723 assert!(
1724 err_msg.contains("not found"),
1725 "Expected 'not found' error, got: {err_msg}"
1726 );
1727 assert!(
1728 err_msg.contains("nonexistent-package"),
1729 "Expected package name in error, got: {err_msg}"
1730 );
1731 assert!(
1732 err_msg.contains("install it"),
1733 "Expected install hint in error, got: {err_msg}"
1734 );
1735 }
1736
1737 #[test]
1738 fn extends_npm_no_config_in_package_errors() {
1739 let dir = test_dir("npm-no-config");
1740 let pkg_dir = dir.path().join("node_modules/empty-pkg");
1741 std::fs::create_dir_all(&pkg_dir).unwrap();
1742 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1744
1745 std::fs::write(
1746 dir.path().join(".fallowrc.json"),
1747 r#"{"extends": "npm:empty-pkg"}"#,
1748 )
1749 .unwrap();
1750
1751 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1752 assert!(result.is_err());
1753 let err_msg = format!("{}", result.unwrap_err());
1754 assert!(
1755 err_msg.contains("No fallow config found"),
1756 "Expected 'No fallow config found' error, got: {err_msg}"
1757 );
1758 }
1759
1760 #[test]
1761 fn extends_npm_missing_subpath_errors() {
1762 let dir = test_dir("npm-missing-sub");
1763 let pkg_dir = dir.path().join("node_modules/@co/config");
1764 std::fs::create_dir_all(&pkg_dir).unwrap();
1765
1766 std::fs::write(
1767 dir.path().join(".fallowrc.json"),
1768 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1769 )
1770 .unwrap();
1771
1772 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1773 assert!(result.is_err());
1774 let err_msg = format!("{}", result.unwrap_err());
1775 assert!(
1776 err_msg.contains("nonexistent.json"),
1777 "Expected subpath in error, got: {err_msg}"
1778 );
1779 }
1780
1781 #[test]
1782 fn extends_npm_empty_specifier_errors() {
1783 let dir = test_dir("npm-empty");
1784 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1785
1786 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1787 assert!(result.is_err());
1788 let err_msg = format!("{}", result.unwrap_err());
1789 assert!(
1790 err_msg.contains("Empty npm specifier"),
1791 "Expected 'Empty npm specifier' error, got: {err_msg}"
1792 );
1793 }
1794
1795 #[test]
1796 fn extends_npm_space_after_colon_trimmed() {
1797 let dir = test_dir("npm-space");
1798 create_npm_package(
1799 dir.path(),
1800 "fallow-config-acme",
1801 r#"{"rules": {"unused-files": "warn"}}"#,
1802 );
1803 std::fs::write(
1805 dir.path().join(".fallowrc.json"),
1806 r#"{"extends": "npm: fallow-config-acme"}"#,
1807 )
1808 .unwrap();
1809
1810 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1811 assert_eq!(config.rules.unused_files, Severity::Warn);
1812 }
1813
1814 #[test]
1815 fn extends_npm_exports_node_condition() {
1816 let dir = test_dir("npm-node-cond");
1817 let pkg_dir = dir.path().join("node_modules/node-config");
1818 std::fs::create_dir_all(&pkg_dir).unwrap();
1819 std::fs::write(
1820 pkg_dir.join("package.json"),
1821 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1822 )
1823 .unwrap();
1824 std::fs::write(
1825 pkg_dir.join("node.json"),
1826 r#"{"rules": {"unused-files": "off"}}"#,
1827 )
1828 .unwrap();
1829
1830 std::fs::write(
1831 dir.path().join(".fallowrc.json"),
1832 r#"{"extends": "npm:node-config"}"#,
1833 )
1834 .unwrap();
1835
1836 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1837 assert_eq!(config.rules.unused_files, Severity::Off);
1838 }
1839
1840 #[test]
1843 fn parse_npm_specifier_unscoped() {
1844 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1845 }
1846
1847 #[test]
1848 fn parse_npm_specifier_unscoped_with_subpath() {
1849 assert_eq!(
1850 parse_npm_specifier("my-config/strict.json"),
1851 ("my-config", Some("strict.json"))
1852 );
1853 }
1854
1855 #[test]
1856 fn parse_npm_specifier_scoped() {
1857 assert_eq!(
1858 parse_npm_specifier("@company/fallow-config"),
1859 ("@company/fallow-config", None)
1860 );
1861 }
1862
1863 #[test]
1864 fn parse_npm_specifier_scoped_with_subpath() {
1865 assert_eq!(
1866 parse_npm_specifier("@company/fallow-config/strict.json"),
1867 ("@company/fallow-config", Some("strict.json"))
1868 );
1869 }
1870
1871 #[test]
1872 fn parse_npm_specifier_scoped_with_nested_subpath() {
1873 assert_eq!(
1874 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1875 ("@company/fallow-config", Some("presets/strict.json"))
1876 );
1877 }
1878
1879 #[test]
1882 fn extends_npm_subpath_traversal_rejected() {
1883 let dir = test_dir("npm-traversal-sub");
1884 let pkg_dir = dir.path().join("node_modules/evil-pkg");
1885 std::fs::create_dir_all(&pkg_dir).unwrap();
1886 std::fs::write(
1888 dir.path().join("secret.json"),
1889 r#"{"entry": ["stolen.ts"]}"#,
1890 )
1891 .unwrap();
1892
1893 std::fs::write(
1894 dir.path().join(".fallowrc.json"),
1895 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1896 )
1897 .unwrap();
1898
1899 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1900 assert!(result.is_err());
1901 let err_msg = format!("{}", result.unwrap_err());
1902 assert!(
1903 err_msg.contains("traversal") || err_msg.contains("not found"),
1904 "Expected traversal or not-found error, got: {err_msg}"
1905 );
1906 }
1907
1908 #[test]
1909 fn extends_npm_dotdot_package_name_rejected() {
1910 let dir = test_dir("npm-dotdot-name");
1911 std::fs::write(
1912 dir.path().join(".fallowrc.json"),
1913 r#"{"extends": "npm:../relative"}"#,
1914 )
1915 .unwrap();
1916
1917 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1918 assert!(result.is_err());
1919 let err_msg = format!("{}", result.unwrap_err());
1920 assert!(
1921 err_msg.contains("path traversal"),
1922 "Expected 'path traversal' error, got: {err_msg}"
1923 );
1924 }
1925
1926 #[test]
1927 fn extends_npm_scoped_without_name_rejected() {
1928 let dir = test_dir("npm-scope-only");
1929 std::fs::write(
1930 dir.path().join(".fallowrc.json"),
1931 r#"{"extends": "npm:@scope"}"#,
1932 )
1933 .unwrap();
1934
1935 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1936 assert!(result.is_err());
1937 let err_msg = format!("{}", result.unwrap_err());
1938 assert!(
1939 err_msg.contains("@scope/name"),
1940 "Expected scoped name format error, got: {err_msg}"
1941 );
1942 }
1943
1944 #[test]
1945 fn extends_npm_malformed_package_json_errors() {
1946 let dir = test_dir("npm-bad-pkgjson");
1947 let pkg_dir = dir.path().join("node_modules/bad-pkg");
1948 std::fs::create_dir_all(&pkg_dir).unwrap();
1949 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1950
1951 std::fs::write(
1952 dir.path().join(".fallowrc.json"),
1953 r#"{"extends": "npm:bad-pkg"}"#,
1954 )
1955 .unwrap();
1956
1957 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1958 assert!(result.is_err());
1959 let err_msg = format!("{}", result.unwrap_err());
1960 assert!(
1961 err_msg.contains("Failed to parse"),
1962 "Expected parse error, got: {err_msg}"
1963 );
1964 }
1965
1966 #[test]
1967 fn extends_npm_exports_traversal_rejected() {
1968 let dir = test_dir("npm-exports-escape");
1969 let pkg_dir = dir.path().join("node_modules/evil-exports");
1970 std::fs::create_dir_all(&pkg_dir).unwrap();
1971 std::fs::write(
1972 pkg_dir.join("package.json"),
1973 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1974 )
1975 .unwrap();
1976 std::fs::write(
1978 dir.path().join("secret.json"),
1979 r#"{"entry": ["stolen.ts"]}"#,
1980 )
1981 .unwrap();
1982
1983 std::fs::write(
1984 dir.path().join(".fallowrc.json"),
1985 r#"{"extends": "npm:evil-exports"}"#,
1986 )
1987 .unwrap();
1988
1989 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1990 assert!(result.is_err());
1991 let err_msg = format!("{}", result.unwrap_err());
1992 assert!(
1993 err_msg.contains("traversal"),
1994 "Expected traversal error, got: {err_msg}"
1995 );
1996 }
1997
1998 #[test]
2001 fn deep_merge_scalar_overlay_replaces_base() {
2002 let mut base = serde_json::json!("hello");
2003 deep_merge_json(&mut base, serde_json::json!("world"));
2004 assert_eq!(base, serde_json::json!("world"));
2005 }
2006
2007 #[test]
2008 fn deep_merge_array_overlay_replaces_base() {
2009 let mut base = serde_json::json!(["a", "b"]);
2010 deep_merge_json(&mut base, serde_json::json!(["c"]));
2011 assert_eq!(base, serde_json::json!(["c"]));
2012 }
2013
2014 #[test]
2015 fn deep_merge_nested_object_merge() {
2016 let mut base = serde_json::json!({
2017 "level1": {
2018 "level2": {
2019 "a": 1,
2020 "b": 2
2021 }
2022 }
2023 });
2024 let overlay = serde_json::json!({
2025 "level1": {
2026 "level2": {
2027 "b": 99,
2028 "c": 3
2029 }
2030 }
2031 });
2032 deep_merge_json(&mut base, overlay);
2033 assert_eq!(base["level1"]["level2"]["a"], 1);
2034 assert_eq!(base["level1"]["level2"]["b"], 99);
2035 assert_eq!(base["level1"]["level2"]["c"], 3);
2036 }
2037
2038 #[test]
2039 fn deep_merge_overlay_adds_new_fields() {
2040 let mut base = serde_json::json!({"existing": true});
2041 let overlay = serde_json::json!({"new_field": "added", "another": 42});
2042 deep_merge_json(&mut base, overlay);
2043 assert_eq!(base["existing"], true);
2044 assert_eq!(base["new_field"], "added");
2045 assert_eq!(base["another"], 42);
2046 }
2047
2048 #[test]
2049 fn deep_merge_null_overlay_replaces_object() {
2050 let mut base = serde_json::json!({"key": "value"});
2051 deep_merge_json(&mut base, serde_json::json!(null));
2052 assert_eq!(base, serde_json::json!(null));
2053 }
2054
2055 #[test]
2056 fn deep_merge_empty_object_overlay_preserves_base() {
2057 let mut base = serde_json::json!({"a": 1, "b": 2});
2058 deep_merge_json(&mut base, serde_json::json!({}));
2059 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2060 }
2061
2062 #[test]
2065 fn rules_severity_error_warn_off_from_json() {
2066 let json_str = r#"{
2067 "rules": {
2068 "unused-files": "error",
2069 "unused-exports": "warn",
2070 "unused-types": "off"
2071 }
2072 }"#;
2073 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2074 assert_eq!(config.rules.unused_files, Severity::Error);
2075 assert_eq!(config.rules.unused_exports, Severity::Warn);
2076 assert_eq!(config.rules.unused_types, Severity::Off);
2077 }
2078
2079 #[test]
2080 fn rules_omitted_default_to_error() {
2081 let json_str = r#"{
2082 "rules": {
2083 "unused-files": "warn"
2084 }
2085 }"#;
2086 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2087 assert_eq!(config.rules.unused_files, Severity::Warn);
2088 assert_eq!(config.rules.unused_exports, Severity::Error);
2090 assert_eq!(config.rules.unused_types, Severity::Error);
2091 assert_eq!(config.rules.unused_dependencies, Severity::Error);
2092 assert_eq!(config.rules.unresolved_imports, Severity::Error);
2093 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2094 assert_eq!(config.rules.duplicate_exports, Severity::Error);
2095 assert_eq!(config.rules.circular_dependencies, Severity::Error);
2096 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2098 }
2099
2100 #[test]
2103 fn find_and_load_returns_none_when_no_config() {
2104 let dir = test_dir("find-none");
2105 std::fs::create_dir(dir.path().join(".git")).unwrap();
2107
2108 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2109 assert!(result.is_none());
2110 }
2111
2112 #[test]
2113 fn find_and_load_finds_fallowrc_json() {
2114 let dir = test_dir("find-json");
2115 std::fs::create_dir(dir.path().join(".git")).unwrap();
2116 std::fs::write(
2117 dir.path().join(".fallowrc.json"),
2118 r#"{"entry": ["src/main.ts"]}"#,
2119 )
2120 .unwrap();
2121
2122 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2123 assert_eq!(config.entry, vec!["src/main.ts"]);
2124 assert!(path.ends_with(".fallowrc.json"));
2125 }
2126
2127 #[test]
2128 fn find_and_load_finds_fallowrc_jsonc() {
2129 let dir = test_dir("find-jsonc");
2130 std::fs::create_dir(dir.path().join(".git")).unwrap();
2131 std::fs::write(
2132 dir.path().join(".fallowrc.jsonc"),
2133 r#"{
2134 // jsonc with comments, picked up by auto-discovery
2135 "entry": ["src/main.ts"]
2136 }"#,
2137 )
2138 .unwrap();
2139
2140 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2141 assert_eq!(config.entry, vec!["src/main.ts"]);
2142 assert!(path.ends_with(".fallowrc.jsonc"));
2143 }
2144
2145 #[test]
2146 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2147 let dir = test_dir("find-json-vs-jsonc");
2150 std::fs::create_dir(dir.path().join(".git")).unwrap();
2151 std::fs::write(
2152 dir.path().join(".fallowrc.json"),
2153 r#"{"entry": ["from-json.ts"]}"#,
2154 )
2155 .unwrap();
2156 std::fs::write(
2157 dir.path().join(".fallowrc.jsonc"),
2158 r#"{"entry": ["from-jsonc.ts"]}"#,
2159 )
2160 .unwrap();
2161
2162 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2163 assert_eq!(config.entry, vec!["from-json.ts"]);
2164 assert!(path.ends_with(".fallowrc.json"));
2165 }
2166
2167 #[test]
2168 fn find_and_load_prefers_fallowrc_json_over_toml() {
2169 let dir = test_dir("find-priority");
2170 std::fs::create_dir(dir.path().join(".git")).unwrap();
2171 std::fs::write(
2172 dir.path().join(".fallowrc.json"),
2173 r#"{"entry": ["from-json.ts"]}"#,
2174 )
2175 .unwrap();
2176 std::fs::write(
2177 dir.path().join("fallow.toml"),
2178 "entry = [\"from-toml.ts\"]\n",
2179 )
2180 .unwrap();
2181
2182 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2183 assert_eq!(config.entry, vec!["from-json.ts"]);
2184 assert!(path.ends_with(".fallowrc.json"));
2185 }
2186
2187 #[test]
2188 fn find_and_load_finds_fallow_toml() {
2189 let dir = test_dir("find-toml");
2190 std::fs::create_dir(dir.path().join(".git")).unwrap();
2191 std::fs::write(
2192 dir.path().join("fallow.toml"),
2193 "entry = [\"src/index.ts\"]\n",
2194 )
2195 .unwrap();
2196
2197 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2198 assert_eq!(config.entry, vec!["src/index.ts"]);
2199 }
2200
2201 #[test]
2202 fn find_and_load_stops_at_git_dir() {
2203 let dir = test_dir("find-git-stop");
2204 let sub = dir.path().join("sub");
2205 std::fs::create_dir(&sub).unwrap();
2206 std::fs::create_dir(dir.path().join(".git")).unwrap();
2208 let result = FallowConfig::find_and_load(&sub).unwrap();
2212 assert!(result.is_none());
2213 }
2214
2215 #[test]
2216 fn find_and_load_walks_past_package_json_in_monorepo() {
2217 let dir = test_dir("find-monorepo");
2221 std::fs::create_dir(dir.path().join(".git")).unwrap();
2222 std::fs::write(
2223 dir.path().join(".fallowrc.json"),
2224 r#"{"entry": ["src/index.ts"]}"#,
2225 )
2226 .unwrap();
2227
2228 let sub = dir.path().join("packages").join("app");
2229 std::fs::create_dir_all(&sub).unwrap();
2230 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2231
2232 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2233 assert_eq!(config.entry, vec!["src/index.ts"]);
2234 assert_eq!(path, dir.path().join(".fallowrc.json"));
2235 }
2236
2237 #[test]
2238 fn find_and_load_sub_package_config_wins_over_root() {
2239 let dir = test_dir("find-monorepo-override");
2242 std::fs::create_dir(dir.path().join(".git")).unwrap();
2243 std::fs::write(
2244 dir.path().join(".fallowrc.json"),
2245 r#"{"entry": ["src/root.ts"]}"#,
2246 )
2247 .unwrap();
2248
2249 let sub = dir.path().join("packages").join("app");
2250 std::fs::create_dir_all(&sub).unwrap();
2251 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2252 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2253
2254 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2255 assert_eq!(config.entry, vec!["src/sub.ts"]);
2256 assert_eq!(path, sub.join(".fallowrc.json"));
2257 }
2258
2259 #[test]
2260 fn find_and_load_stops_at_git_file_submodule() {
2261 let dir = test_dir("find-git-file");
2266 std::fs::create_dir(dir.path().join(".git")).unwrap();
2267 std::fs::write(
2268 dir.path().join(".fallowrc.json"),
2269 r#"{"entry": ["src/parent.ts"]}"#,
2270 )
2271 .unwrap();
2272
2273 let submodule = dir.path().join("vendor").join("lib");
2274 std::fs::create_dir_all(&submodule).unwrap();
2275 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2277
2278 let result = FallowConfig::find_and_load(&submodule).unwrap();
2279 assert!(
2280 result.is_none(),
2281 "submodule boundary should stop config walk",
2282 );
2283 }
2284
2285 #[test]
2286 fn find_and_load_stops_at_hg_dir() {
2287 let dir = test_dir("find-hg-stop");
2288 let sub = dir.path().join("sub");
2289 std::fs::create_dir(&sub).unwrap();
2290 std::fs::create_dir(dir.path().join(".hg")).unwrap();
2291
2292 let result = FallowConfig::find_and_load(&sub).unwrap();
2293 assert!(result.is_none());
2294 }
2295
2296 #[test]
2297 fn find_and_load_returns_error_for_invalid_config() {
2298 let dir = test_dir("find-invalid");
2299 std::fs::create_dir(dir.path().join(".git")).unwrap();
2300 std::fs::write(
2301 dir.path().join(".fallowrc.json"),
2302 r"{ this is not valid json }",
2303 )
2304 .unwrap();
2305
2306 let result = FallowConfig::find_and_load(dir.path());
2307 assert!(result.is_err());
2308 }
2309
2310 #[test]
2313 fn load_toml_config_file() {
2314 let dir = test_dir("toml-config");
2315 let config_path = dir.path().join("fallow.toml");
2316 std::fs::write(
2317 &config_path,
2318 r#"
2319entry = ["src/index.ts"]
2320ignorePatterns = ["dist/**"]
2321
2322[rules]
2323unused-files = "warn"
2324
2325[duplicates]
2326minTokens = 100
2327"#,
2328 )
2329 .unwrap();
2330
2331 let config = FallowConfig::load(&config_path).unwrap();
2332 assert_eq!(config.entry, vec!["src/index.ts"]);
2333 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2334 assert_eq!(config.rules.unused_files, Severity::Warn);
2335 assert_eq!(config.duplicates.min_tokens, 100);
2336 }
2337
2338 #[test]
2341 fn extends_absolute_path_rejected() {
2342 let dir = test_dir("extends-absolute");
2343
2344 #[cfg(unix)]
2346 let abs_path = "/absolute/path/config.json";
2347 #[cfg(windows)]
2348 let abs_path = "C:\\absolute\\path\\config.json";
2349
2350 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2351 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2352
2353 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2354 assert!(result.is_err());
2355 let err_msg = format!("{}", result.unwrap_err());
2356 assert!(
2357 err_msg.contains("must be relative"),
2358 "Expected 'must be relative' error, got: {err_msg}"
2359 );
2360 }
2361
2362 #[test]
2365 fn resolve_production_mode_disables_dev_deps() {
2366 let config = FallowConfig {
2367 production: true.into(),
2368 ..Default::default()
2369 };
2370 let resolved = config.resolve(
2371 PathBuf::from("/tmp/test"),
2372 OutputFormat::Human,
2373 4,
2374 false,
2375 true,
2376 );
2377 assert!(resolved.production);
2378 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2379 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2380 assert_eq!(resolved.rules.unused_files, Severity::Error);
2382 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2383 }
2384
2385 #[test]
2388 fn include_entry_exports_deserializes_from_camelcase_json() {
2389 let json = r#"{ "includeEntryExports": true }"#;
2390 let config: FallowConfig = serde_json::from_str(json).unwrap();
2391 assert!(config.include_entry_exports);
2392 }
2393
2394 #[test]
2395 fn include_entry_exports_deserializes_from_camelcase_toml() {
2396 let toml_str = "includeEntryExports = true\n";
2397 let config: FallowConfig = toml::from_str(toml_str).unwrap();
2398 assert!(config.include_entry_exports);
2399 }
2400
2401 #[test]
2402 fn include_entry_exports_default_is_false() {
2403 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2404 assert!(!config.include_entry_exports);
2405 }
2406
2407 #[test]
2408 fn include_entry_exports_propagates_through_resolve() {
2409 let config = FallowConfig {
2410 include_entry_exports: true,
2411 ..Default::default()
2412 };
2413 let resolved = config.resolve(
2414 PathBuf::from("/tmp/test"),
2415 OutputFormat::Human,
2416 1,
2417 true,
2418 true,
2419 );
2420 assert!(resolved.include_entry_exports);
2421 }
2422
2423 #[test]
2426 fn config_format_defaults_to_toml_for_unknown() {
2427 assert!(matches!(
2428 ConfigFormat::from_path(Path::new("config.yaml")),
2429 ConfigFormat::Toml
2430 ));
2431 assert!(matches!(
2432 ConfigFormat::from_path(Path::new("config")),
2433 ConfigFormat::Toml
2434 ));
2435 }
2436
2437 #[test]
2440 fn deep_merge_object_over_scalar_replaces() {
2441 let mut base = serde_json::json!("just a string");
2442 let overlay = serde_json::json!({"key": "value"});
2443 deep_merge_json(&mut base, overlay);
2444 assert_eq!(base, serde_json::json!({"key": "value"}));
2445 }
2446
2447 #[test]
2448 fn deep_merge_scalar_over_object_replaces() {
2449 let mut base = serde_json::json!({"key": "value"});
2450 let overlay = serde_json::json!(42);
2451 deep_merge_json(&mut base, overlay);
2452 assert_eq!(base, serde_json::json!(42));
2453 }
2454
2455 #[test]
2458 fn extends_non_string_non_array_ignored() {
2459 let dir = test_dir("extends-numeric");
2460 std::fs::write(
2461 dir.path().join(".fallowrc.json"),
2462 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2463 )
2464 .unwrap();
2465
2466 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2468 assert_eq!(config.entry, vec!["src/index.ts"]);
2469 }
2470
2471 #[test]
2474 fn extends_multiple_bases_later_wins() {
2475 let dir = test_dir("extends-multi-base");
2476
2477 std::fs::write(
2478 dir.path().join("base-a.json"),
2479 r#"{"rules": {"unused-files": "warn"}}"#,
2480 )
2481 .unwrap();
2482 std::fs::write(
2483 dir.path().join("base-b.json"),
2484 r#"{"rules": {"unused-files": "off"}}"#,
2485 )
2486 .unwrap();
2487 std::fs::write(
2488 dir.path().join(".fallowrc.json"),
2489 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2490 )
2491 .unwrap();
2492
2493 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2494 assert_eq!(config.rules.unused_files, Severity::Off);
2496 }
2497
2498 #[test]
2501 fn fallow_config_deserialize_production() {
2502 let json_str = r#"{"production": true}"#;
2503 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2504 assert!(config.production);
2505 }
2506
2507 #[test]
2508 fn fallow_config_production_defaults_false() {
2509 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2510 assert!(!config.production);
2511 }
2512
2513 #[test]
2516 fn package_json_optional_dependency_names() {
2517 let pkg: PackageJson = serde_json::from_str(
2518 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2519 )
2520 .unwrap();
2521 let opt = pkg.optional_dependency_names();
2522 assert_eq!(opt.len(), 2);
2523 assert!(opt.contains(&"fsevents".to_string()));
2524 assert!(opt.contains(&"chokidar".to_string()));
2525 }
2526
2527 #[test]
2528 fn package_json_optional_deps_empty_when_missing() {
2529 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2530 assert!(pkg.optional_dependency_names().is_empty());
2531 }
2532
2533 #[test]
2536 fn find_config_path_returns_fallowrc_json() {
2537 let dir = test_dir("find-path-json");
2538 std::fs::create_dir(dir.path().join(".git")).unwrap();
2539 std::fs::write(
2540 dir.path().join(".fallowrc.json"),
2541 r#"{"entry": ["src/main.ts"]}"#,
2542 )
2543 .unwrap();
2544
2545 let path = FallowConfig::find_config_path(dir.path());
2546 assert!(path.is_some());
2547 assert!(path.unwrap().ends_with(".fallowrc.json"));
2548 }
2549
2550 #[test]
2551 fn find_config_path_returns_fallow_toml() {
2552 let dir = test_dir("find-path-toml");
2553 std::fs::create_dir(dir.path().join(".git")).unwrap();
2554 std::fs::write(
2555 dir.path().join("fallow.toml"),
2556 "entry = [\"src/main.ts\"]\n",
2557 )
2558 .unwrap();
2559
2560 let path = FallowConfig::find_config_path(dir.path());
2561 assert!(path.is_some());
2562 assert!(path.unwrap().ends_with("fallow.toml"));
2563 }
2564
2565 #[test]
2566 fn find_config_path_returns_dot_fallow_toml() {
2567 let dir = test_dir("find-path-dot-toml");
2568 std::fs::create_dir(dir.path().join(".git")).unwrap();
2569 std::fs::write(
2570 dir.path().join(".fallow.toml"),
2571 "entry = [\"src/main.ts\"]\n",
2572 )
2573 .unwrap();
2574
2575 let path = FallowConfig::find_config_path(dir.path());
2576 assert!(path.is_some());
2577 assert!(path.unwrap().ends_with(".fallow.toml"));
2578 }
2579
2580 #[test]
2581 fn find_config_path_prefers_json_over_toml() {
2582 let dir = test_dir("find-path-priority");
2583 std::fs::create_dir(dir.path().join(".git")).unwrap();
2584 std::fs::write(
2585 dir.path().join(".fallowrc.json"),
2586 r#"{"entry": ["json.ts"]}"#,
2587 )
2588 .unwrap();
2589 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2590
2591 let path = FallowConfig::find_config_path(dir.path());
2592 assert!(path.unwrap().ends_with(".fallowrc.json"));
2593 }
2594
2595 #[test]
2596 fn find_config_path_none_when_no_config() {
2597 let dir = test_dir("find-path-none");
2598 std::fs::create_dir(dir.path().join(".git")).unwrap();
2599
2600 let path = FallowConfig::find_config_path(dir.path());
2601 assert!(path.is_none());
2602 }
2603
2604 #[test]
2605 fn find_config_path_walks_past_package_json_in_monorepo() {
2606 let dir = test_dir("find-path-monorepo");
2607 std::fs::create_dir(dir.path().join(".git")).unwrap();
2608 std::fs::write(
2609 dir.path().join(".fallowrc.json"),
2610 r#"{"entry": ["src/index.ts"]}"#,
2611 )
2612 .unwrap();
2613
2614 let sub = dir.path().join("packages").join("app");
2615 std::fs::create_dir_all(&sub).unwrap();
2616 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2617
2618 let path = FallowConfig::find_config_path(&sub).unwrap();
2619 assert_eq!(path, dir.path().join(".fallowrc.json"));
2620 }
2621
2622 #[test]
2625 fn extends_toml_base() {
2626 let dir = test_dir("extends-toml");
2627
2628 std::fs::write(
2629 dir.path().join("base.json"),
2630 r#"{"rules": {"unused-files": "warn"}}"#,
2631 )
2632 .unwrap();
2633 std::fs::write(
2634 dir.path().join("fallow.toml"),
2635 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2636 )
2637 .unwrap();
2638
2639 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2640 assert_eq!(config.rules.unused_files, Severity::Warn);
2641 assert_eq!(config.entry, vec!["src/index.ts"]);
2642 }
2643
2644 #[test]
2647 fn deep_merge_boolean_overlay() {
2648 let mut base = serde_json::json!(true);
2649 deep_merge_json(&mut base, serde_json::json!(false));
2650 assert_eq!(base, serde_json::json!(false));
2651 }
2652
2653 #[test]
2654 fn deep_merge_number_overlay() {
2655 let mut base = serde_json::json!(42);
2656 deep_merge_json(&mut base, serde_json::json!(99));
2657 assert_eq!(base, serde_json::json!(99));
2658 }
2659
2660 #[test]
2661 fn deep_merge_disjoint_objects() {
2662 let mut base = serde_json::json!({"a": 1});
2663 let overlay = serde_json::json!({"b": 2});
2664 deep_merge_json(&mut base, overlay);
2665 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2666 }
2667
2668 #[test]
2671 fn max_extends_depth_is_reasonable() {
2672 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2673 }
2674
2675 #[test]
2678 fn config_names_has_four_entries() {
2679 assert_eq!(CONFIG_NAMES.len(), 4);
2680 for name in CONFIG_NAMES {
2682 assert!(
2683 name.starts_with('.') || name.starts_with("fallow"),
2684 "unexpected config name: {name}"
2685 );
2686 }
2687 }
2688
2689 #[test]
2692 fn package_json_peer_dependency_names() {
2693 let pkg: PackageJson = serde_json::from_str(
2694 r#"{
2695 "dependencies": {"react": "^18"},
2696 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2697 }"#,
2698 )
2699 .unwrap();
2700 let all = pkg.all_dependency_names();
2701 assert!(all.contains(&"react".to_string()));
2702 assert!(all.contains(&"react-dom".to_string()));
2703 assert!(all.contains(&"react-native".to_string()));
2704 }
2705
2706 #[test]
2709 fn package_json_scripts_field() {
2710 let pkg: PackageJson = serde_json::from_str(
2711 r#"{
2712 "scripts": {
2713 "build": "tsc",
2714 "test": "vitest",
2715 "lint": "fallow check"
2716 }
2717 }"#,
2718 )
2719 .unwrap();
2720 let scripts = pkg.scripts.unwrap();
2721 assert_eq!(scripts.len(), 3);
2722 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2723 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2724 }
2725
2726 #[test]
2729 fn extends_toml_chain() {
2730 let dir = test_dir("extends-toml-chain");
2731
2732 std::fs::write(
2733 dir.path().join("base.json"),
2734 r#"{"entry": ["src/base.ts"]}"#,
2735 )
2736 .unwrap();
2737 std::fs::write(
2738 dir.path().join("middle.json"),
2739 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2740 )
2741 .unwrap();
2742 std::fs::write(
2743 dir.path().join("fallow.toml"),
2744 "extends = [\"middle.json\"]\n",
2745 )
2746 .unwrap();
2747
2748 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2749 assert_eq!(config.entry, vec!["src/base.ts"]);
2750 assert_eq!(config.rules.unused_files, Severity::Off);
2751 }
2752
2753 #[test]
2756 fn find_and_load_walks_up_directories() {
2757 let dir = test_dir("find-walk-up");
2758 let sub = dir.path().join("src").join("deep");
2759 std::fs::create_dir_all(&sub).unwrap();
2760 std::fs::write(
2761 dir.path().join(".fallowrc.json"),
2762 r#"{"entry": ["src/main.ts"]}"#,
2763 )
2764 .unwrap();
2765 std::fs::create_dir(dir.path().join(".git")).unwrap();
2767
2768 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2769 assert_eq!(config.entry, vec!["src/main.ts"]);
2770 assert!(path.ends_with(".fallowrc.json"));
2771 }
2772
2773 #[test]
2776 fn json_schema_contains_entry_field() {
2777 let schema = FallowConfig::json_schema();
2778 let obj = schema.as_object().unwrap();
2779 let props = obj.get("properties").and_then(|v| v.as_object());
2780 assert!(props.is_some(), "schema should have properties");
2781 assert!(
2782 props.unwrap().contains_key("entry"),
2783 "schema should contain entry property"
2784 );
2785 }
2786
2787 #[test]
2790 fn fallow_config_json_duplicates_all_fields() {
2791 let json = r#"{
2792 "duplicates": {
2793 "enabled": true,
2794 "mode": "semantic",
2795 "minTokens": 200,
2796 "minLines": 20,
2797 "threshold": 10.5,
2798 "ignore": ["**/*.test.ts"],
2799 "skipLocal": true,
2800 "crossLanguage": true,
2801 "normalization": {
2802 "ignoreIdentifiers": true,
2803 "ignoreStringValues": false
2804 }
2805 }
2806 }"#;
2807 let config: FallowConfig = serde_json::from_str(json).unwrap();
2808 assert!(config.duplicates.enabled);
2809 assert_eq!(
2810 config.duplicates.mode,
2811 crate::config::DetectionMode::Semantic
2812 );
2813 assert_eq!(config.duplicates.min_tokens, 200);
2814 assert_eq!(config.duplicates.min_lines, 20);
2815 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2816 assert!(config.duplicates.skip_local);
2817 assert!(config.duplicates.cross_language);
2818 assert_eq!(
2819 config.duplicates.normalization.ignore_identifiers,
2820 Some(true)
2821 );
2822 assert_eq!(
2823 config.duplicates.normalization.ignore_string_values,
2824 Some(false)
2825 );
2826 }
2827
2828 #[test]
2831 fn normalize_url_basic() {
2832 assert_eq!(
2833 normalize_url_for_dedup("https://example.com/config.json"),
2834 "https://example.com/config.json"
2835 );
2836 }
2837
2838 #[test]
2839 fn normalize_url_trailing_slash() {
2840 assert_eq!(
2841 normalize_url_for_dedup("https://example.com/config/"),
2842 "https://example.com/config"
2843 );
2844 }
2845
2846 #[test]
2847 fn normalize_url_uppercase_scheme_and_host() {
2848 assert_eq!(
2849 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2850 "https://example.com/Config.json"
2851 );
2852 }
2853
2854 #[test]
2855 fn normalize_url_root_path() {
2856 assert_eq!(
2857 normalize_url_for_dedup("https://example.com/"),
2858 "https://example.com"
2859 );
2860 assert_eq!(
2861 normalize_url_for_dedup("https://example.com"),
2862 "https://example.com"
2863 );
2864 }
2865
2866 #[test]
2867 fn normalize_url_preserves_path_case() {
2868 assert_eq!(
2870 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2871 "https://github.com/Org/Repo/Fallow.json"
2872 );
2873 }
2874
2875 #[test]
2876 fn normalize_url_strips_query_string() {
2877 assert_eq!(
2878 normalize_url_for_dedup("https://example.com/config.json?v=1"),
2879 "https://example.com/config.json"
2880 );
2881 }
2882
2883 #[test]
2884 fn normalize_url_strips_fragment() {
2885 assert_eq!(
2886 normalize_url_for_dedup("https://example.com/config.json#section"),
2887 "https://example.com/config.json"
2888 );
2889 }
2890
2891 #[test]
2892 fn normalize_url_strips_query_and_fragment() {
2893 assert_eq!(
2894 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2895 "https://example.com/config.json"
2896 );
2897 }
2898
2899 #[test]
2900 fn normalize_url_default_https_port() {
2901 assert_eq!(
2902 normalize_url_for_dedup("https://example.com:443/config.json"),
2903 "https://example.com/config.json"
2904 );
2905 assert_eq!(
2907 normalize_url_for_dedup("https://example.com:8443/config.json"),
2908 "https://example.com:8443/config.json"
2909 );
2910 }
2911
2912 #[test]
2913 fn extends_http_rejected() {
2914 let dir = test_dir("http-rejected");
2915 std::fs::write(
2916 dir.path().join(".fallowrc.json"),
2917 r#"{"extends": "http://example.com/config.json"}"#,
2918 )
2919 .unwrap();
2920
2921 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2922 assert!(result.is_err());
2923 let err_msg = format!("{}", result.unwrap_err());
2924 assert!(
2925 err_msg.contains("https://"),
2926 "Expected https hint in error, got: {err_msg}"
2927 );
2928 assert!(
2929 err_msg.contains("http://"),
2930 "Expected http:// mention in error, got: {err_msg}"
2931 );
2932 }
2933
2934 #[test]
2935 fn extends_url_circular_detection() {
2936 let mut visited = FxHashSet::default();
2938 let url = "https://example.com/config.json";
2939 let normalized = normalize_url_for_dedup(url);
2940 visited.insert(normalized.clone());
2941
2942 assert!(
2944 !visited.insert(normalized),
2945 "Same URL should be detected as duplicate"
2946 );
2947 }
2948
2949 #[test]
2950 fn extends_url_circular_case_insensitive() {
2951 let mut visited = FxHashSet::default();
2953 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2954
2955 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2956 assert!(
2957 !visited.insert(normalized),
2958 "Case-different URLs should normalize to the same key"
2959 );
2960 }
2961
2962 #[test]
2963 fn extract_extends_array() {
2964 let mut value = serde_json::json!({
2965 "extends": ["a.json", "b.json"],
2966 "entry": ["src/index.ts"]
2967 });
2968 let extends = extract_extends(&mut value);
2969 assert_eq!(extends, vec!["a.json", "b.json"]);
2970 assert!(value.get("extends").is_none());
2972 assert!(value.get("entry").is_some());
2973 }
2974
2975 #[test]
2976 fn extract_extends_string_sugar() {
2977 let mut value = serde_json::json!({
2978 "extends": "base.json",
2979 "entry": ["src/index.ts"]
2980 });
2981 let extends = extract_extends(&mut value);
2982 assert_eq!(extends, vec!["base.json"]);
2983 }
2984
2985 #[test]
2986 fn extract_extends_none() {
2987 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2988 let extends = extract_extends(&mut value);
2989 assert!(extends.is_empty());
2990 }
2991
2992 #[test]
2993 fn url_timeout_default() {
2994 let timeout = url_timeout();
2996 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2999 }
3000
3001 #[test]
3002 fn extends_url_mixed_with_file_and_npm() {
3003 let dir = test_dir("url-mixed");
3006 std::fs::write(
3007 dir.path().join("local.json"),
3008 r#"{"rules": {"unused-files": "warn"}}"#,
3009 )
3010 .unwrap();
3011 std::fs::write(
3012 dir.path().join(".fallowrc.json"),
3013 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3014 )
3015 .unwrap();
3016
3017 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3018 assert!(result.is_err());
3019 let err_msg = format!("{}", result.unwrap_err());
3020 assert!(
3021 err_msg.contains("unreachable.invalid"),
3022 "Expected URL in error message, got: {err_msg}"
3023 );
3024 }
3025
3026 #[test]
3027 fn extends_https_url_unreachable_errors() {
3028 let dir = test_dir("url-unreachable");
3029 std::fs::write(
3030 dir.path().join(".fallowrc.json"),
3031 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3032 )
3033 .unwrap();
3034
3035 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3036 assert!(result.is_err());
3037 let err_msg = format!("{}", result.unwrap_err());
3038 assert!(
3039 err_msg.contains("unreachable.invalid"),
3040 "Expected URL in error, got: {err_msg}"
3041 );
3042 assert!(
3043 err_msg.contains("local path or npm:"),
3044 "Expected remediation hint, got: {err_msg}"
3045 );
3046 }
3047}