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 config_format_defaults_to_toml_for_unknown() {
2389 assert!(matches!(
2390 ConfigFormat::from_path(Path::new("config.yaml")),
2391 ConfigFormat::Toml
2392 ));
2393 assert!(matches!(
2394 ConfigFormat::from_path(Path::new("config")),
2395 ConfigFormat::Toml
2396 ));
2397 }
2398
2399 #[test]
2402 fn deep_merge_object_over_scalar_replaces() {
2403 let mut base = serde_json::json!("just a string");
2404 let overlay = serde_json::json!({"key": "value"});
2405 deep_merge_json(&mut base, overlay);
2406 assert_eq!(base, serde_json::json!({"key": "value"}));
2407 }
2408
2409 #[test]
2410 fn deep_merge_scalar_over_object_replaces() {
2411 let mut base = serde_json::json!({"key": "value"});
2412 let overlay = serde_json::json!(42);
2413 deep_merge_json(&mut base, overlay);
2414 assert_eq!(base, serde_json::json!(42));
2415 }
2416
2417 #[test]
2420 fn extends_non_string_non_array_ignored() {
2421 let dir = test_dir("extends-numeric");
2422 std::fs::write(
2423 dir.path().join(".fallowrc.json"),
2424 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2425 )
2426 .unwrap();
2427
2428 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2430 assert_eq!(config.entry, vec!["src/index.ts"]);
2431 }
2432
2433 #[test]
2436 fn extends_multiple_bases_later_wins() {
2437 let dir = test_dir("extends-multi-base");
2438
2439 std::fs::write(
2440 dir.path().join("base-a.json"),
2441 r#"{"rules": {"unused-files": "warn"}}"#,
2442 )
2443 .unwrap();
2444 std::fs::write(
2445 dir.path().join("base-b.json"),
2446 r#"{"rules": {"unused-files": "off"}}"#,
2447 )
2448 .unwrap();
2449 std::fs::write(
2450 dir.path().join(".fallowrc.json"),
2451 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2452 )
2453 .unwrap();
2454
2455 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2456 assert_eq!(config.rules.unused_files, Severity::Off);
2458 }
2459
2460 #[test]
2463 fn fallow_config_deserialize_production() {
2464 let json_str = r#"{"production": true}"#;
2465 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2466 assert!(config.production);
2467 }
2468
2469 #[test]
2470 fn fallow_config_production_defaults_false() {
2471 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2472 assert!(!config.production);
2473 }
2474
2475 #[test]
2478 fn package_json_optional_dependency_names() {
2479 let pkg: PackageJson = serde_json::from_str(
2480 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2481 )
2482 .unwrap();
2483 let opt = pkg.optional_dependency_names();
2484 assert_eq!(opt.len(), 2);
2485 assert!(opt.contains(&"fsevents".to_string()));
2486 assert!(opt.contains(&"chokidar".to_string()));
2487 }
2488
2489 #[test]
2490 fn package_json_optional_deps_empty_when_missing() {
2491 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2492 assert!(pkg.optional_dependency_names().is_empty());
2493 }
2494
2495 #[test]
2498 fn find_config_path_returns_fallowrc_json() {
2499 let dir = test_dir("find-path-json");
2500 std::fs::create_dir(dir.path().join(".git")).unwrap();
2501 std::fs::write(
2502 dir.path().join(".fallowrc.json"),
2503 r#"{"entry": ["src/main.ts"]}"#,
2504 )
2505 .unwrap();
2506
2507 let path = FallowConfig::find_config_path(dir.path());
2508 assert!(path.is_some());
2509 assert!(path.unwrap().ends_with(".fallowrc.json"));
2510 }
2511
2512 #[test]
2513 fn find_config_path_returns_fallow_toml() {
2514 let dir = test_dir("find-path-toml");
2515 std::fs::create_dir(dir.path().join(".git")).unwrap();
2516 std::fs::write(
2517 dir.path().join("fallow.toml"),
2518 "entry = [\"src/main.ts\"]\n",
2519 )
2520 .unwrap();
2521
2522 let path = FallowConfig::find_config_path(dir.path());
2523 assert!(path.is_some());
2524 assert!(path.unwrap().ends_with("fallow.toml"));
2525 }
2526
2527 #[test]
2528 fn find_config_path_returns_dot_fallow_toml() {
2529 let dir = test_dir("find-path-dot-toml");
2530 std::fs::create_dir(dir.path().join(".git")).unwrap();
2531 std::fs::write(
2532 dir.path().join(".fallow.toml"),
2533 "entry = [\"src/main.ts\"]\n",
2534 )
2535 .unwrap();
2536
2537 let path = FallowConfig::find_config_path(dir.path());
2538 assert!(path.is_some());
2539 assert!(path.unwrap().ends_with(".fallow.toml"));
2540 }
2541
2542 #[test]
2543 fn find_config_path_prefers_json_over_toml() {
2544 let dir = test_dir("find-path-priority");
2545 std::fs::create_dir(dir.path().join(".git")).unwrap();
2546 std::fs::write(
2547 dir.path().join(".fallowrc.json"),
2548 r#"{"entry": ["json.ts"]}"#,
2549 )
2550 .unwrap();
2551 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2552
2553 let path = FallowConfig::find_config_path(dir.path());
2554 assert!(path.unwrap().ends_with(".fallowrc.json"));
2555 }
2556
2557 #[test]
2558 fn find_config_path_none_when_no_config() {
2559 let dir = test_dir("find-path-none");
2560 std::fs::create_dir(dir.path().join(".git")).unwrap();
2561
2562 let path = FallowConfig::find_config_path(dir.path());
2563 assert!(path.is_none());
2564 }
2565
2566 #[test]
2567 fn find_config_path_walks_past_package_json_in_monorepo() {
2568 let dir = test_dir("find-path-monorepo");
2569 std::fs::create_dir(dir.path().join(".git")).unwrap();
2570 std::fs::write(
2571 dir.path().join(".fallowrc.json"),
2572 r#"{"entry": ["src/index.ts"]}"#,
2573 )
2574 .unwrap();
2575
2576 let sub = dir.path().join("packages").join("app");
2577 std::fs::create_dir_all(&sub).unwrap();
2578 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2579
2580 let path = FallowConfig::find_config_path(&sub).unwrap();
2581 assert_eq!(path, dir.path().join(".fallowrc.json"));
2582 }
2583
2584 #[test]
2587 fn extends_toml_base() {
2588 let dir = test_dir("extends-toml");
2589
2590 std::fs::write(
2591 dir.path().join("base.json"),
2592 r#"{"rules": {"unused-files": "warn"}}"#,
2593 )
2594 .unwrap();
2595 std::fs::write(
2596 dir.path().join("fallow.toml"),
2597 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2598 )
2599 .unwrap();
2600
2601 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2602 assert_eq!(config.rules.unused_files, Severity::Warn);
2603 assert_eq!(config.entry, vec!["src/index.ts"]);
2604 }
2605
2606 #[test]
2609 fn deep_merge_boolean_overlay() {
2610 let mut base = serde_json::json!(true);
2611 deep_merge_json(&mut base, serde_json::json!(false));
2612 assert_eq!(base, serde_json::json!(false));
2613 }
2614
2615 #[test]
2616 fn deep_merge_number_overlay() {
2617 let mut base = serde_json::json!(42);
2618 deep_merge_json(&mut base, serde_json::json!(99));
2619 assert_eq!(base, serde_json::json!(99));
2620 }
2621
2622 #[test]
2623 fn deep_merge_disjoint_objects() {
2624 let mut base = serde_json::json!({"a": 1});
2625 let overlay = serde_json::json!({"b": 2});
2626 deep_merge_json(&mut base, overlay);
2627 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2628 }
2629
2630 #[test]
2633 fn max_extends_depth_is_reasonable() {
2634 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2635 }
2636
2637 #[test]
2640 fn config_names_has_four_entries() {
2641 assert_eq!(CONFIG_NAMES.len(), 4);
2642 for name in CONFIG_NAMES {
2644 assert!(
2645 name.starts_with('.') || name.starts_with("fallow"),
2646 "unexpected config name: {name}"
2647 );
2648 }
2649 }
2650
2651 #[test]
2654 fn package_json_peer_dependency_names() {
2655 let pkg: PackageJson = serde_json::from_str(
2656 r#"{
2657 "dependencies": {"react": "^18"},
2658 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2659 }"#,
2660 )
2661 .unwrap();
2662 let all = pkg.all_dependency_names();
2663 assert!(all.contains(&"react".to_string()));
2664 assert!(all.contains(&"react-dom".to_string()));
2665 assert!(all.contains(&"react-native".to_string()));
2666 }
2667
2668 #[test]
2671 fn package_json_scripts_field() {
2672 let pkg: PackageJson = serde_json::from_str(
2673 r#"{
2674 "scripts": {
2675 "build": "tsc",
2676 "test": "vitest",
2677 "lint": "fallow check"
2678 }
2679 }"#,
2680 )
2681 .unwrap();
2682 let scripts = pkg.scripts.unwrap();
2683 assert_eq!(scripts.len(), 3);
2684 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2685 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2686 }
2687
2688 #[test]
2691 fn extends_toml_chain() {
2692 let dir = test_dir("extends-toml-chain");
2693
2694 std::fs::write(
2695 dir.path().join("base.json"),
2696 r#"{"entry": ["src/base.ts"]}"#,
2697 )
2698 .unwrap();
2699 std::fs::write(
2700 dir.path().join("middle.json"),
2701 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2702 )
2703 .unwrap();
2704 std::fs::write(
2705 dir.path().join("fallow.toml"),
2706 "extends = [\"middle.json\"]\n",
2707 )
2708 .unwrap();
2709
2710 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2711 assert_eq!(config.entry, vec!["src/base.ts"]);
2712 assert_eq!(config.rules.unused_files, Severity::Off);
2713 }
2714
2715 #[test]
2718 fn find_and_load_walks_up_directories() {
2719 let dir = test_dir("find-walk-up");
2720 let sub = dir.path().join("src").join("deep");
2721 std::fs::create_dir_all(&sub).unwrap();
2722 std::fs::write(
2723 dir.path().join(".fallowrc.json"),
2724 r#"{"entry": ["src/main.ts"]}"#,
2725 )
2726 .unwrap();
2727 std::fs::create_dir(dir.path().join(".git")).unwrap();
2729
2730 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2731 assert_eq!(config.entry, vec!["src/main.ts"]);
2732 assert!(path.ends_with(".fallowrc.json"));
2733 }
2734
2735 #[test]
2738 fn json_schema_contains_entry_field() {
2739 let schema = FallowConfig::json_schema();
2740 let obj = schema.as_object().unwrap();
2741 let props = obj.get("properties").and_then(|v| v.as_object());
2742 assert!(props.is_some(), "schema should have properties");
2743 assert!(
2744 props.unwrap().contains_key("entry"),
2745 "schema should contain entry property"
2746 );
2747 }
2748
2749 #[test]
2752 fn fallow_config_json_duplicates_all_fields() {
2753 let json = r#"{
2754 "duplicates": {
2755 "enabled": true,
2756 "mode": "semantic",
2757 "minTokens": 200,
2758 "minLines": 20,
2759 "threshold": 10.5,
2760 "ignore": ["**/*.test.ts"],
2761 "skipLocal": true,
2762 "crossLanguage": true,
2763 "normalization": {
2764 "ignoreIdentifiers": true,
2765 "ignoreStringValues": false
2766 }
2767 }
2768 }"#;
2769 let config: FallowConfig = serde_json::from_str(json).unwrap();
2770 assert!(config.duplicates.enabled);
2771 assert_eq!(
2772 config.duplicates.mode,
2773 crate::config::DetectionMode::Semantic
2774 );
2775 assert_eq!(config.duplicates.min_tokens, 200);
2776 assert_eq!(config.duplicates.min_lines, 20);
2777 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2778 assert!(config.duplicates.skip_local);
2779 assert!(config.duplicates.cross_language);
2780 assert_eq!(
2781 config.duplicates.normalization.ignore_identifiers,
2782 Some(true)
2783 );
2784 assert_eq!(
2785 config.duplicates.normalization.ignore_string_values,
2786 Some(false)
2787 );
2788 }
2789
2790 #[test]
2793 fn normalize_url_basic() {
2794 assert_eq!(
2795 normalize_url_for_dedup("https://example.com/config.json"),
2796 "https://example.com/config.json"
2797 );
2798 }
2799
2800 #[test]
2801 fn normalize_url_trailing_slash() {
2802 assert_eq!(
2803 normalize_url_for_dedup("https://example.com/config/"),
2804 "https://example.com/config"
2805 );
2806 }
2807
2808 #[test]
2809 fn normalize_url_uppercase_scheme_and_host() {
2810 assert_eq!(
2811 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2812 "https://example.com/Config.json"
2813 );
2814 }
2815
2816 #[test]
2817 fn normalize_url_root_path() {
2818 assert_eq!(
2819 normalize_url_for_dedup("https://example.com/"),
2820 "https://example.com"
2821 );
2822 assert_eq!(
2823 normalize_url_for_dedup("https://example.com"),
2824 "https://example.com"
2825 );
2826 }
2827
2828 #[test]
2829 fn normalize_url_preserves_path_case() {
2830 assert_eq!(
2832 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2833 "https://github.com/Org/Repo/Fallow.json"
2834 );
2835 }
2836
2837 #[test]
2838 fn normalize_url_strips_query_string() {
2839 assert_eq!(
2840 normalize_url_for_dedup("https://example.com/config.json?v=1"),
2841 "https://example.com/config.json"
2842 );
2843 }
2844
2845 #[test]
2846 fn normalize_url_strips_fragment() {
2847 assert_eq!(
2848 normalize_url_for_dedup("https://example.com/config.json#section"),
2849 "https://example.com/config.json"
2850 );
2851 }
2852
2853 #[test]
2854 fn normalize_url_strips_query_and_fragment() {
2855 assert_eq!(
2856 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2857 "https://example.com/config.json"
2858 );
2859 }
2860
2861 #[test]
2862 fn normalize_url_default_https_port() {
2863 assert_eq!(
2864 normalize_url_for_dedup("https://example.com:443/config.json"),
2865 "https://example.com/config.json"
2866 );
2867 assert_eq!(
2869 normalize_url_for_dedup("https://example.com:8443/config.json"),
2870 "https://example.com:8443/config.json"
2871 );
2872 }
2873
2874 #[test]
2875 fn extends_http_rejected() {
2876 let dir = test_dir("http-rejected");
2877 std::fs::write(
2878 dir.path().join(".fallowrc.json"),
2879 r#"{"extends": "http://example.com/config.json"}"#,
2880 )
2881 .unwrap();
2882
2883 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2884 assert!(result.is_err());
2885 let err_msg = format!("{}", result.unwrap_err());
2886 assert!(
2887 err_msg.contains("https://"),
2888 "Expected https hint in error, got: {err_msg}"
2889 );
2890 assert!(
2891 err_msg.contains("http://"),
2892 "Expected http:// mention in error, got: {err_msg}"
2893 );
2894 }
2895
2896 #[test]
2897 fn extends_url_circular_detection() {
2898 let mut visited = FxHashSet::default();
2900 let url = "https://example.com/config.json";
2901 let normalized = normalize_url_for_dedup(url);
2902 visited.insert(normalized.clone());
2903
2904 assert!(
2906 !visited.insert(normalized),
2907 "Same URL should be detected as duplicate"
2908 );
2909 }
2910
2911 #[test]
2912 fn extends_url_circular_case_insensitive() {
2913 let mut visited = FxHashSet::default();
2915 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2916
2917 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2918 assert!(
2919 !visited.insert(normalized),
2920 "Case-different URLs should normalize to the same key"
2921 );
2922 }
2923
2924 #[test]
2925 fn extract_extends_array() {
2926 let mut value = serde_json::json!({
2927 "extends": ["a.json", "b.json"],
2928 "entry": ["src/index.ts"]
2929 });
2930 let extends = extract_extends(&mut value);
2931 assert_eq!(extends, vec!["a.json", "b.json"]);
2932 assert!(value.get("extends").is_none());
2934 assert!(value.get("entry").is_some());
2935 }
2936
2937 #[test]
2938 fn extract_extends_string_sugar() {
2939 let mut value = serde_json::json!({
2940 "extends": "base.json",
2941 "entry": ["src/index.ts"]
2942 });
2943 let extends = extract_extends(&mut value);
2944 assert_eq!(extends, vec!["base.json"]);
2945 }
2946
2947 #[test]
2948 fn extract_extends_none() {
2949 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2950 let extends = extract_extends(&mut value);
2951 assert!(extends.is_empty());
2952 }
2953
2954 #[test]
2955 fn url_timeout_default() {
2956 let timeout = url_timeout();
2958 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2961 }
2962
2963 #[test]
2964 fn extends_url_mixed_with_file_and_npm() {
2965 let dir = test_dir("url-mixed");
2968 std::fs::write(
2969 dir.path().join("local.json"),
2970 r#"{"rules": {"unused-files": "warn"}}"#,
2971 )
2972 .unwrap();
2973 std::fs::write(
2974 dir.path().join(".fallowrc.json"),
2975 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2976 )
2977 .unwrap();
2978
2979 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2980 assert!(result.is_err());
2981 let err_msg = format!("{}", result.unwrap_err());
2982 assert!(
2983 err_msg.contains("unreachable.invalid"),
2984 "Expected URL in error message, got: {err_msg}"
2985 );
2986 }
2987
2988 #[test]
2989 fn extends_https_url_unreachable_errors() {
2990 let dir = test_dir("url-unreachable");
2991 std::fs::write(
2992 dir.path().join(".fallowrc.json"),
2993 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2994 )
2995 .unwrap();
2996
2997 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2998 assert!(result.is_err());
2999 let err_msg = format!("{}", result.unwrap_err());
3000 assert!(
3001 err_msg.contains("unreachable.invalid"),
3002 "Expected URL in error, got: {err_msg}"
3003 );
3004 assert!(
3005 err_msg.contains("local path or npm:"),
3006 "Expected remediation hint, got: {err_msg}"
3007 );
3008 }
3009}