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