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