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
661pub(super) fn collect_unknown_rule_keys(
676 merged: &serde_json::Value,
677) -> Vec<super::rules::UnknownRuleKey> {
678 use super::rules::find_unknown_rule_keys;
679
680 let mut findings = Vec::new();
681
682 if let Some(rules) = merged.get("rules") {
683 findings.extend(find_unknown_rule_keys(rules, "rules"));
684 }
685
686 if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
687 for (i, entry) in overrides.iter().enumerate() {
688 if let Some(rules) = entry.get("rules") {
689 let context = format!("overrides[{i}].rules");
690 findings.extend(find_unknown_rule_keys(rules, &context));
691 }
692 }
693 }
694
695 findings
696}
697
698thread_local! {
699 #[cfg(test)]
705 static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
706 const { std::cell::RefCell::new(None) };
707}
708
709#[cfg(test)]
713pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
714 body: F,
715) -> (R, Vec<super::rules::UnknownRuleKey>) {
716 UNKNOWN_RULE_CAPTURE.with(|cell| {
717 *cell.borrow_mut() = Some(Vec::new());
718 });
719 let result = body();
720 let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
721 (result, findings)
722}
723
724fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
735 use std::sync::{Mutex, OnceLock};
736
737 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
738 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
739
740 let path_display = config_path.display().to_string();
741
742 for finding in collect_unknown_rule_keys(merged) {
743 let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
744 if let Ok(mut set) = warned.lock()
747 && !set.insert(dedupe_key)
748 {
749 continue;
750 }
751
752 #[cfg(test)]
753 UNKNOWN_RULE_CAPTURE.with(|cell| {
754 if let Some(buf) = cell.borrow_mut().as_mut() {
755 buf.push(finding.clone());
756 }
757 });
758
759 if let Some(suggestion) = finding.suggestion {
760 tracing::warn!(
761 "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
762 the rule will be ignored. A future release will reject unknown rule names.",
763 key = finding.key,
764 context = finding.context,
765 path = path_display,
766 );
767 } else {
768 tracing::warn!(
769 "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
770 A future release will reject unknown rule names.",
771 key = finding.key,
772 context = finding.context,
773 path = path_display,
774 );
775 }
776 }
777}
778
779impl FallowConfig {
780 pub fn load(path: &Path) -> Result<Self, miette::Report> {
802 let mut visited = FxHashSet::default();
803 let merged = resolve_extends(path, &mut visited, 0)?;
804
805 warn_on_unknown_rule_keys(path, &merged);
806
807 let config: Self = serde_json::from_value(merged).map_err(|e| {
808 miette::miette!(
809 "Failed to deserialize config from {}: {}",
810 path.display(),
811 e
812 )
813 })?;
814
815 config.validate_user_globs().map_err(|errors| {
820 let joined = errors
821 .iter()
822 .map(ToString::to_string)
823 .collect::<Vec<_>>()
824 .join("\n - ");
825 miette::miette!("invalid config:\n - {}", joined)
826 })?;
827
828 Ok(config)
829 }
830
831 pub fn validate_user_globs(
856 &self,
857 ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
858 use super::glob_validation::{
859 compile_user_glob, validate_user_globs, validate_user_path, validate_user_paths,
860 };
861
862 let mut errors = Vec::new();
863
864 validate_user_globs(&self.entry, "entry", &mut errors);
865 validate_user_globs(&self.ignore_patterns, "ignorePatterns", &mut errors);
866 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", &mut errors);
867 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", &mut errors);
868 validate_user_globs(&self.health.ignore, "health.ignore", &mut errors);
869
870 for override_entry in &self.overrides {
871 validate_user_globs(&override_entry.files, "overrides[].files", &mut errors);
872 }
873
874 for rule in &self.ignore_exports {
875 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
876 errors.push(e);
877 }
878 }
879
880 for rule in &self.ignore_catalog_references {
881 if let Some(consumer) = &rule.consumer
882 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
883 {
884 errors.push(e);
885 }
886 }
887
888 for zone in &self.boundaries.zones {
889 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", &mut errors);
890 if let Some(root) = &zone.root
891 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
892 {
893 errors.push(e);
894 }
895 validate_user_paths(
896 &zone.auto_discover,
897 "boundaries.zones[].autoDiscover",
898 &mut errors,
899 );
900 }
901
902 for plugin in &self.framework {
910 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
911 errors.append(&mut plugin_errors);
912 }
913 }
914
915 if errors.is_empty() {
916 Ok(())
917 } else {
918 Err(errors)
919 }
920 }
921
922 #[must_use]
925 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
926 let mut dir = start;
927 loop {
928 for name in CONFIG_NAMES {
929 let candidate = dir.join(name);
930 if candidate.exists() {
931 return Some(candidate);
932 }
933 }
934 if is_repo_root(dir) {
935 break;
936 }
937 dir = dir.parent()?;
938 }
939 None
940 }
941
942 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
948 let mut dir = start;
949 loop {
950 for name in CONFIG_NAMES {
951 let candidate = dir.join(name);
952 if candidate.exists() {
953 match Self::load(&candidate) {
954 Ok(config) => return Ok(Some((config, candidate))),
955 Err(e) => {
956 return Err(format!("Failed to parse {}: {e}", candidate.display()));
957 }
958 }
959 }
960 }
961 if is_repo_root(dir) {
965 break;
966 }
967 dir = match dir.parent() {
968 Some(parent) => parent,
969 None => break,
970 };
971 }
972 Ok(None)
973 }
974
975 #[must_use]
977 pub fn json_schema() -> serde_json::Value {
978 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
979 }
980
981 pub fn validate_resolved_boundaries(
1010 &self,
1011 root: &Path,
1012 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1013 use super::boundaries::ZoneValidationError;
1014
1015 let mut boundaries = self.boundaries.clone();
1018 if boundaries.preset.is_some() {
1019 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1023 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1024 .unwrap_or_else(|| "src".to_owned());
1025 boundaries.expand(&source_root);
1026 }
1027 let _logical_groups = boundaries.expand_auto_discover(root);
1028
1029 let mut errors: Vec<ZoneValidationError> = boundaries
1030 .validate_zone_references()
1031 .into_iter()
1032 .map(ZoneValidationError::UnknownZoneReference)
1033 .collect();
1034 errors.extend(
1035 boundaries
1036 .validate_root_prefixes()
1037 .into_iter()
1038 .map(ZoneValidationError::RedundantRootPrefix),
1039 );
1040
1041 if errors.is_empty() {
1042 Ok(())
1043 } else {
1044 Err(errors)
1045 }
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use crate::CacheConfig;
1053 use crate::PackageJson;
1054 use crate::config::format::OutputFormat;
1055 use crate::config::rules::Severity;
1056
1057 fn test_dir(_name: &str) -> tempfile::TempDir {
1059 tempfile::tempdir().expect("create temp dir")
1060 }
1061
1062 #[test]
1063 fn fallow_config_deserialize_minimal() {
1064 let toml_str = r#"
1065entry = ["src/main.ts"]
1066"#;
1067 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1068 assert_eq!(config.entry, vec!["src/main.ts"]);
1069 assert!(config.ignore_patterns.is_empty());
1070 }
1071
1072 #[test]
1073 fn fallow_config_deserialize_ignore_exports() {
1074 let toml_str = r#"
1075[[ignoreExports]]
1076file = "src/types/*.ts"
1077exports = ["*"]
1078
1079[[ignoreExports]]
1080file = "src/constants.ts"
1081exports = ["FOO", "BAR"]
1082"#;
1083 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1084 assert_eq!(config.ignore_exports.len(), 2);
1085 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1086 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1087 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1088 }
1089
1090 #[test]
1091 fn fallow_config_deserialize_ignore_dependencies() {
1092 let toml_str = r#"
1093ignoreDependencies = ["autoprefixer", "postcss"]
1094"#;
1095 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1096 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1097 }
1098
1099 #[test]
1100 fn fallow_config_resolve_default_ignores() {
1101 let config = FallowConfig::default();
1102 let resolved = config.resolve(
1103 PathBuf::from("/tmp/test"),
1104 OutputFormat::Human,
1105 4,
1106 true,
1107 true,
1108 None,
1109 );
1110
1111 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1113 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1114 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1115 assert!(resolved.ignore_patterns.is_match(".git/config"));
1116 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1117 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1118 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1119 }
1120
1121 #[test]
1122 fn fallow_config_resolve_custom_ignores() {
1123 let config = FallowConfig {
1124 entry: vec!["src/**/*.ts".to_string()],
1125 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1126 ..Default::default()
1127 };
1128 let resolved = config.resolve(
1129 PathBuf::from("/tmp/test"),
1130 OutputFormat::Json,
1131 4,
1132 false,
1133 true,
1134 None,
1135 );
1136
1137 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1138 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1139 assert!(matches!(resolved.output, OutputFormat::Json));
1140 assert!(!resolved.no_cache);
1141 }
1142
1143 #[test]
1144 fn fallow_config_resolve_cache_dir() {
1145 let config = FallowConfig::default();
1146 let resolved = config.resolve(
1147 PathBuf::from("/tmp/project"),
1148 OutputFormat::Human,
1149 4,
1150 true,
1151 true,
1152 None,
1153 );
1154 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1155 assert!(resolved.no_cache);
1156 }
1157
1158 #[test]
1159 fn package_json_entry_points_main() {
1160 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1161 let entries = pkg.entry_points();
1162 assert!(entries.contains(&"dist/index.js".to_string()));
1163 }
1164
1165 #[test]
1166 fn package_json_entry_points_module() {
1167 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1168 let entries = pkg.entry_points();
1169 assert!(entries.contains(&"dist/index.mjs".to_string()));
1170 }
1171
1172 #[test]
1173 fn package_json_entry_points_types() {
1174 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1175 let entries = pkg.entry_points();
1176 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1177 }
1178
1179 #[test]
1180 fn package_json_entry_points_bin_string() {
1181 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1182 let entries = pkg.entry_points();
1183 assert!(entries.contains(&"bin/cli.js".to_string()));
1184 }
1185
1186 #[test]
1187 fn package_json_entry_points_bin_object() {
1188 let pkg: PackageJson =
1189 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1190 .unwrap();
1191 let entries = pkg.entry_points();
1192 assert!(entries.contains(&"bin/cli.js".to_string()));
1193 assert!(entries.contains(&"bin/serve.js".to_string()));
1194 }
1195
1196 #[test]
1197 fn package_json_entry_points_exports_string() {
1198 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1199 let entries = pkg.entry_points();
1200 assert!(entries.contains(&"./dist/index.js".to_string()));
1201 }
1202
1203 #[test]
1204 fn package_json_entry_points_exports_object() {
1205 let pkg: PackageJson = serde_json::from_str(
1206 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1207 )
1208 .unwrap();
1209 let entries = pkg.entry_points();
1210 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1211 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1212 }
1213
1214 #[test]
1215 fn package_json_dependency_names() {
1216 let pkg: PackageJson = serde_json::from_str(
1217 r#"{
1218 "dependencies": {"react": "^18", "lodash": "^4"},
1219 "devDependencies": {"typescript": "^5"},
1220 "peerDependencies": {"react-dom": "^18"}
1221 }"#,
1222 )
1223 .unwrap();
1224
1225 let all = pkg.all_dependency_names();
1226 assert!(all.contains(&"react".to_string()));
1227 assert!(all.contains(&"lodash".to_string()));
1228 assert!(all.contains(&"typescript".to_string()));
1229 assert!(all.contains(&"react-dom".to_string()));
1230
1231 let prod = pkg.production_dependency_names();
1232 assert!(prod.contains(&"react".to_string()));
1233 assert!(!prod.contains(&"typescript".to_string()));
1234
1235 let dev = pkg.dev_dependency_names();
1236 assert!(dev.contains(&"typescript".to_string()));
1237 assert!(!dev.contains(&"react".to_string()));
1238 }
1239
1240 #[test]
1241 fn package_json_no_dependencies() {
1242 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1243 assert!(pkg.all_dependency_names().is_empty());
1244 assert!(pkg.production_dependency_names().is_empty());
1245 assert!(pkg.dev_dependency_names().is_empty());
1246 assert!(pkg.entry_points().is_empty());
1247 }
1248
1249 #[test]
1250 fn rules_deserialize_toml_kebab_case() {
1251 let toml_str = r#"
1252[rules]
1253unused-files = "error"
1254unused-exports = "warn"
1255unused-types = "off"
1256"#;
1257 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1258 assert_eq!(config.rules.unused_files, Severity::Error);
1259 assert_eq!(config.rules.unused_exports, Severity::Warn);
1260 assert_eq!(config.rules.unused_types, Severity::Off);
1261 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1263 }
1264
1265 #[test]
1266 fn config_without_rules_defaults_to_error() {
1267 let toml_str = r#"
1268entry = ["src/main.ts"]
1269"#;
1270 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1271 assert_eq!(config.rules.unused_files, Severity::Error);
1272 assert_eq!(config.rules.unused_exports, Severity::Error);
1273 }
1274
1275 #[test]
1276 fn fallow_config_denies_unknown_fields() {
1277 let toml_str = r"
1278unknown_field = true
1279";
1280 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1281 assert!(result.is_err());
1282 }
1283
1284 #[test]
1285 fn fallow_config_deserialize_json() {
1286 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1287 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1288 assert_eq!(config.entry, vec!["src/main.ts"]);
1289 }
1290
1291 #[test]
1292 fn fallow_config_deserialize_jsonc() {
1293 let jsonc_str = r#"{
1294 // This is a comment
1295 "entry": ["src/main.ts"],
1296 "rules": {
1297 "unused-files": "warn"
1298 }
1299 }"#;
1300 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1301 assert_eq!(config.entry, vec!["src/main.ts"]);
1302 assert_eq!(config.rules.unused_files, Severity::Warn);
1303 }
1304
1305 #[test]
1306 fn fallow_config_json_with_schema_field() {
1307 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1308 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1309 assert_eq!(config.entry, vec!["src/main.ts"]);
1310 }
1311
1312 #[test]
1313 fn fallow_config_json_schema_generation() {
1314 let schema = FallowConfig::json_schema();
1315 assert!(schema.is_object());
1316 let obj = schema.as_object().unwrap();
1317 assert!(obj.contains_key("properties"));
1318 }
1319
1320 #[test]
1321 fn config_format_detection() {
1322 assert!(matches!(
1323 ConfigFormat::from_path(Path::new("fallow.toml")),
1324 ConfigFormat::Toml
1325 ));
1326 assert!(matches!(
1327 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1328 ConfigFormat::Json
1329 ));
1330 assert!(matches!(
1331 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1332 ConfigFormat::Json
1333 ));
1334 assert!(matches!(
1335 ConfigFormat::from_path(Path::new(".fallow.toml")),
1336 ConfigFormat::Toml
1337 ));
1338 }
1339
1340 #[test]
1341 fn config_names_priority_order() {
1342 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1343 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1344 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1345 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1346 }
1347
1348 #[test]
1349 fn load_json_config_file() {
1350 let dir = test_dir("json-config");
1351 let config_path = dir.path().join(".fallowrc.json");
1352 std::fs::write(
1353 &config_path,
1354 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1355 )
1356 .unwrap();
1357
1358 let config = FallowConfig::load(&config_path).unwrap();
1359 assert_eq!(config.entry, vec!["src/index.ts"]);
1360 assert_eq!(config.rules.unused_exports, Severity::Warn);
1361 }
1362
1363 #[test]
1364 fn load_jsonc_config_file() {
1365 let dir = test_dir("jsonc-config");
1366 let config_path = dir.path().join(".fallowrc.json");
1367 std::fs::write(
1368 &config_path,
1369 r#"{
1370 // Entry points for analysis
1371 "entry": ["src/index.ts"],
1372 /* Block comment */
1373 "rules": {
1374 "unused-exports": "warn"
1375 }
1376 }"#,
1377 )
1378 .unwrap();
1379
1380 let config = FallowConfig::load(&config_path).unwrap();
1381 assert_eq!(config.entry, vec!["src/index.ts"]);
1382 assert_eq!(config.rules.unused_exports, Severity::Warn);
1383 }
1384
1385 #[test]
1386 fn load_fallowrc_jsonc_extension() {
1387 let dir = test_dir("jsonc-extension");
1388 let config_path = dir.path().join(".fallowrc.jsonc");
1389 std::fs::write(
1390 &config_path,
1391 r#"{
1392 // editors that recognize the .jsonc extension show
1393 // proper JSON-with-comments syntax highlighting
1394 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1395 "entry": ["src/index.ts"]
1396 }"#,
1397 )
1398 .unwrap();
1399
1400 let config = FallowConfig::load(&config_path).unwrap();
1401 assert_eq!(config.entry, vec!["src/index.ts"]);
1402 assert_eq!(
1403 config.ignore_dependencies,
1404 vec!["tailwindcss-react-aria-components"]
1405 );
1406 }
1407
1408 #[test]
1409 fn json_config_ignore_dependencies_camel_case() {
1410 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1411 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1412 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1413 }
1414
1415 #[test]
1416 fn json_config_all_fields() {
1417 let json_str = r#"{
1418 "ignoreDependencies": ["lodash"],
1419 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1420 "rules": {
1421 "unused-files": "off",
1422 "unused-exports": "warn",
1423 "unused-dependencies": "error",
1424 "unused-dev-dependencies": "off",
1425 "unused-types": "warn",
1426 "unused-enum-members": "error",
1427 "unused-class-members": "off",
1428 "unresolved-imports": "warn",
1429 "unlisted-dependencies": "error",
1430 "duplicate-exports": "off"
1431 },
1432 "duplicates": {
1433 "minTokens": 100,
1434 "minLines": 10,
1435 "skipLocal": true
1436 }
1437 }"#;
1438 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1439 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1440 assert_eq!(config.rules.unused_files, Severity::Off);
1441 assert_eq!(config.rules.unused_exports, Severity::Warn);
1442 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1443 assert_eq!(config.duplicates.min_tokens, 100);
1444 assert_eq!(config.duplicates.min_lines, 10);
1445 assert!(config.duplicates.skip_local);
1446 }
1447
1448 #[test]
1451 fn extends_single_base() {
1452 let dir = test_dir("extends-single");
1453
1454 std::fs::write(
1455 dir.path().join("base.json"),
1456 r#"{"rules": {"unused-files": "warn"}}"#,
1457 )
1458 .unwrap();
1459 std::fs::write(
1460 dir.path().join(".fallowrc.json"),
1461 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1462 )
1463 .unwrap();
1464
1465 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1466 assert_eq!(config.rules.unused_files, Severity::Warn);
1467 assert_eq!(config.entry, vec!["src/index.ts"]);
1468 assert_eq!(config.rules.unused_exports, Severity::Error);
1470 }
1471
1472 #[test]
1473 fn extends_overlay_overrides_base() {
1474 let dir = test_dir("extends-overlay");
1475
1476 std::fs::write(
1477 dir.path().join("base.json"),
1478 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1479 )
1480 .unwrap();
1481 std::fs::write(
1482 dir.path().join(".fallowrc.json"),
1483 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1484 )
1485 .unwrap();
1486
1487 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1488 assert_eq!(config.rules.unused_files, Severity::Error);
1490 assert_eq!(config.rules.unused_exports, Severity::Off);
1492 }
1493
1494 #[test]
1495 fn extends_chained() {
1496 let dir = test_dir("extends-chained");
1497
1498 std::fs::write(
1499 dir.path().join("grandparent.json"),
1500 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1501 )
1502 .unwrap();
1503 std::fs::write(
1504 dir.path().join("parent.json"),
1505 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1506 )
1507 .unwrap();
1508 std::fs::write(
1509 dir.path().join(".fallowrc.json"),
1510 r#"{"extends": ["parent.json"]}"#,
1511 )
1512 .unwrap();
1513
1514 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1515 assert_eq!(config.rules.unused_files, Severity::Warn);
1517 assert_eq!(config.rules.unused_exports, Severity::Warn);
1519 }
1520
1521 #[test]
1522 fn extends_circular_detected() {
1523 let dir = test_dir("extends-circular");
1524
1525 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1526 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1527
1528 let result = FallowConfig::load(&dir.path().join("a.json"));
1529 assert!(result.is_err());
1530 let err_msg = format!("{}", result.unwrap_err());
1531 assert!(
1532 err_msg.contains("Circular extends"),
1533 "Expected circular error, got: {err_msg}"
1534 );
1535 }
1536
1537 #[test]
1538 fn extends_missing_file_errors() {
1539 let dir = test_dir("extends-missing");
1540
1541 std::fs::write(
1542 dir.path().join(".fallowrc.json"),
1543 r#"{"extends": ["nonexistent.json"]}"#,
1544 )
1545 .unwrap();
1546
1547 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1548 assert!(result.is_err());
1549 let err_msg = format!("{}", result.unwrap_err());
1550 assert!(
1551 err_msg.contains("not found"),
1552 "Expected not found error, got: {err_msg}"
1553 );
1554 }
1555
1556 #[test]
1559 fn sealed_allows_in_directory_extends() {
1560 let dir = test_dir("sealed-allows-local");
1561 std::fs::write(
1562 dir.path().join("base.json"),
1563 r#"{"ignorePatterns": ["gen/**"]}"#,
1564 )
1565 .unwrap();
1566 std::fs::write(
1567 dir.path().join(".fallowrc.json"),
1568 r#"{"sealed": true, "extends": ["./base.json"]}"#,
1569 )
1570 .unwrap();
1571
1572 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1573 assert!(config.sealed);
1574 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1575 }
1576
1577 #[test]
1578 fn sealed_rejects_extends_escaping_directory() {
1579 let dir = test_dir("sealed-rejects-escape");
1580 let sub = dir.path().join("packages").join("app");
1581 std::fs::create_dir_all(&sub).unwrap();
1582
1583 std::fs::write(
1585 dir.path().join("base.json"),
1586 r#"{"ignorePatterns": ["dist/**"]}"#,
1587 )
1588 .unwrap();
1589 std::fs::write(
1590 sub.join(".fallowrc.json"),
1591 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1592 )
1593 .unwrap();
1594
1595 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1596 assert!(
1597 result.is_err(),
1598 "Expected sealed config to reject escaping extends"
1599 );
1600 let err_msg = format!("{}", result.unwrap_err());
1601 assert!(
1602 err_msg.contains("sealed"),
1603 "Error must mention sealed: {err_msg}"
1604 );
1605 assert!(
1606 err_msg.contains("outside the config's directory"),
1607 "Error must explain the constraint: {err_msg}"
1608 );
1609 }
1610
1611 #[test]
1612 fn sealed_rejects_https_extends() {
1613 let dir = test_dir("sealed-rejects-https");
1614 std::fs::write(
1615 dir.path().join(".fallowrc.json"),
1616 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1617 )
1618 .unwrap();
1619
1620 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1621 assert!(result.is_err());
1622 let err_msg = format!("{}", result.unwrap_err());
1623 assert!(
1624 err_msg.contains("sealed"),
1625 "Error must mention sealed: {err_msg}"
1626 );
1627 assert!(
1628 err_msg.contains("URL extends"),
1629 "Error must mention URL: {err_msg}"
1630 );
1631 }
1632
1633 #[test]
1634 fn sealed_rejects_npm_extends() {
1635 let dir = test_dir("sealed-rejects-npm");
1636 std::fs::write(
1637 dir.path().join(".fallowrc.json"),
1638 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1639 )
1640 .unwrap();
1641
1642 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1643 assert!(result.is_err());
1644 let err_msg = format!("{}", result.unwrap_err());
1645 assert!(
1646 err_msg.contains("sealed"),
1647 "Error must mention sealed: {err_msg}"
1648 );
1649 assert!(
1650 err_msg.contains("npm extends"),
1651 "Error must mention npm: {err_msg}"
1652 );
1653 }
1654
1655 #[test]
1656 fn sealed_default_is_false() {
1657 let dir = test_dir("sealed-default");
1658 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1659 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1660 assert!(!config.sealed);
1661 }
1662
1663 #[test]
1664 fn sealed_false_allows_escaping_extends() {
1665 let dir = test_dir("sealed-false-allows");
1667 let sub = dir.path().join("packages").join("app");
1668 std::fs::create_dir_all(&sub).unwrap();
1669
1670 std::fs::write(
1671 dir.path().join("base.json"),
1672 r#"{"ignorePatterns": ["dist/**"]}"#,
1673 )
1674 .unwrap();
1675 std::fs::write(
1676 sub.join(".fallowrc.json"),
1677 r#"{"extends": ["../../base.json"]}"#,
1678 )
1679 .unwrap();
1680
1681 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1682 assert!(!config.sealed);
1683 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1684 }
1685
1686 #[test]
1687 fn extends_string_sugar() {
1688 let dir = test_dir("extends-string");
1689
1690 std::fs::write(
1691 dir.path().join("base.json"),
1692 r#"{"ignorePatterns": ["gen/**"]}"#,
1693 )
1694 .unwrap();
1695 std::fs::write(
1697 dir.path().join(".fallowrc.json"),
1698 r#"{"extends": "base.json"}"#,
1699 )
1700 .unwrap();
1701
1702 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1703 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1704 }
1705
1706 #[test]
1707 fn extends_deep_merge_preserves_arrays() {
1708 let dir = test_dir("extends-array");
1709
1710 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1711 std::fs::write(
1712 dir.path().join(".fallowrc.json"),
1713 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1714 )
1715 .unwrap();
1716
1717 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1718 assert_eq!(config.entry, vec!["src/b.ts"]);
1720 }
1721
1722 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1726 let pkg_dir = root.join("node_modules").join(name);
1727 std::fs::create_dir_all(&pkg_dir).unwrap();
1728 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1729 }
1730
1731 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1733 let pkg_dir = root.join("node_modules").join(name);
1734 std::fs::create_dir_all(&pkg_dir).unwrap();
1735 std::fs::write(
1736 pkg_dir.join("package.json"),
1737 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1738 )
1739 .unwrap();
1740 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1741 }
1742
1743 #[test]
1744 fn extends_npm_basic_unscoped() {
1745 let dir = test_dir("npm-basic");
1746 create_npm_package(
1747 dir.path(),
1748 "fallow-config-acme",
1749 r#"{"rules": {"unused-files": "warn"}}"#,
1750 );
1751 std::fs::write(
1752 dir.path().join(".fallowrc.json"),
1753 r#"{"extends": "npm:fallow-config-acme"}"#,
1754 )
1755 .unwrap();
1756
1757 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1758 assert_eq!(config.rules.unused_files, Severity::Warn);
1759 }
1760
1761 #[test]
1762 fn extends_npm_scoped_package() {
1763 let dir = test_dir("npm-scoped");
1764 create_npm_package(
1765 dir.path(),
1766 "@company/fallow-config",
1767 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1768 );
1769 std::fs::write(
1770 dir.path().join(".fallowrc.json"),
1771 r#"{"extends": "npm:@company/fallow-config"}"#,
1772 )
1773 .unwrap();
1774
1775 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1776 assert_eq!(config.rules.unused_exports, Severity::Off);
1777 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1778 }
1779
1780 #[test]
1781 fn extends_npm_with_subpath() {
1782 let dir = test_dir("npm-subpath");
1783 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1784 std::fs::create_dir_all(&pkg_dir).unwrap();
1785 std::fs::write(
1786 pkg_dir.join("strict.json"),
1787 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1788 )
1789 .unwrap();
1790
1791 std::fs::write(
1792 dir.path().join(".fallowrc.json"),
1793 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1794 )
1795 .unwrap();
1796
1797 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1798 assert_eq!(config.rules.unused_files, Severity::Error);
1799 assert_eq!(config.rules.unused_exports, Severity::Error);
1800 }
1801
1802 #[test]
1803 fn extends_npm_package_json_main() {
1804 let dir = test_dir("npm-main");
1805 create_npm_package_with_main(
1806 dir.path(),
1807 "fallow-config-acme",
1808 "config.json",
1809 r#"{"rules": {"unused-types": "off"}}"#,
1810 );
1811 std::fs::write(
1812 dir.path().join(".fallowrc.json"),
1813 r#"{"extends": "npm:fallow-config-acme"}"#,
1814 )
1815 .unwrap();
1816
1817 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1818 assert_eq!(config.rules.unused_types, Severity::Off);
1819 }
1820
1821 #[test]
1822 fn extends_npm_package_json_exports_string() {
1823 let dir = test_dir("npm-exports-str");
1824 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1825 std::fs::create_dir_all(&pkg_dir).unwrap();
1826 std::fs::write(
1827 pkg_dir.join("package.json"),
1828 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1829 )
1830 .unwrap();
1831 std::fs::write(
1832 pkg_dir.join("base.json"),
1833 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1834 )
1835 .unwrap();
1836
1837 std::fs::write(
1838 dir.path().join(".fallowrc.json"),
1839 r#"{"extends": "npm:fallow-config-co"}"#,
1840 )
1841 .unwrap();
1842
1843 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1844 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1845 }
1846
1847 #[test]
1848 fn extends_npm_package_json_exports_object() {
1849 let dir = test_dir("npm-exports-obj");
1850 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1851 std::fs::create_dir_all(&pkg_dir).unwrap();
1852 std::fs::write(
1853 pkg_dir.join("package.json"),
1854 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1855 )
1856 .unwrap();
1857 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1858
1859 std::fs::write(
1860 dir.path().join(".fallowrc.json"),
1861 r#"{"extends": "npm:@co/cfg"}"#,
1862 )
1863 .unwrap();
1864
1865 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1866 assert_eq!(config.entry, vec!["src/app.ts"]);
1867 }
1868
1869 #[test]
1870 fn extends_npm_exports_takes_priority_over_main() {
1871 let dir = test_dir("npm-exports-prio");
1872 let pkg_dir = dir.path().join("node_modules/my-config");
1873 std::fs::create_dir_all(&pkg_dir).unwrap();
1874 std::fs::write(
1875 pkg_dir.join("package.json"),
1876 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1877 )
1878 .unwrap();
1879 std::fs::write(
1880 pkg_dir.join("old.json"),
1881 r#"{"rules": {"unused-files": "off"}}"#,
1882 )
1883 .unwrap();
1884 std::fs::write(
1885 pkg_dir.join("new.json"),
1886 r#"{"rules": {"unused-files": "warn"}}"#,
1887 )
1888 .unwrap();
1889
1890 std::fs::write(
1891 dir.path().join(".fallowrc.json"),
1892 r#"{"extends": "npm:my-config"}"#,
1893 )
1894 .unwrap();
1895
1896 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1897 assert_eq!(config.rules.unused_files, Severity::Warn);
1899 }
1900
1901 #[test]
1902 fn extends_npm_walk_up_directories() {
1903 let dir = test_dir("npm-walkup");
1904 create_npm_package(
1906 dir.path(),
1907 "shared-config",
1908 r#"{"rules": {"unused-files": "warn"}}"#,
1909 );
1910 let sub = dir.path().join("packages/app");
1912 std::fs::create_dir_all(&sub).unwrap();
1913 std::fs::write(
1914 sub.join(".fallowrc.json"),
1915 r#"{"extends": "npm:shared-config"}"#,
1916 )
1917 .unwrap();
1918
1919 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1920 assert_eq!(config.rules.unused_files, Severity::Warn);
1921 }
1922
1923 #[test]
1924 fn extends_npm_overlay_overrides_base() {
1925 let dir = test_dir("npm-overlay");
1926 create_npm_package(
1927 dir.path(),
1928 "@company/base",
1929 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1930 );
1931 std::fs::write(
1932 dir.path().join(".fallowrc.json"),
1933 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1934 )
1935 .unwrap();
1936
1937 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1938 assert_eq!(config.rules.unused_files, Severity::Error);
1939 assert_eq!(config.rules.unused_exports, Severity::Off);
1940 assert_eq!(config.entry, vec!["src/app.ts"]);
1941 }
1942
1943 #[test]
1944 fn extends_npm_chained_with_relative() {
1945 let dir = test_dir("npm-chained");
1946 let pkg_dir = dir.path().join("node_modules/my-config");
1948 std::fs::create_dir_all(&pkg_dir).unwrap();
1949 std::fs::write(
1950 pkg_dir.join("base.json"),
1951 r#"{"rules": {"unused-files": "warn"}}"#,
1952 )
1953 .unwrap();
1954 std::fs::write(
1955 pkg_dir.join(".fallowrc.json"),
1956 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1957 )
1958 .unwrap();
1959
1960 std::fs::write(
1961 dir.path().join(".fallowrc.json"),
1962 r#"{"extends": "npm:my-config"}"#,
1963 )
1964 .unwrap();
1965
1966 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1967 assert_eq!(config.rules.unused_files, Severity::Warn);
1968 assert_eq!(config.rules.unused_exports, Severity::Off);
1969 }
1970
1971 #[test]
1972 fn extends_npm_mixed_with_relative_paths() {
1973 let dir = test_dir("npm-mixed");
1974 create_npm_package(
1975 dir.path(),
1976 "shared-base",
1977 r#"{"rules": {"unused-files": "off"}}"#,
1978 );
1979 std::fs::write(
1980 dir.path().join("local-overrides.json"),
1981 r#"{"rules": {"unused-files": "warn"}}"#,
1982 )
1983 .unwrap();
1984 std::fs::write(
1985 dir.path().join(".fallowrc.json"),
1986 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1987 )
1988 .unwrap();
1989
1990 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1991 assert_eq!(config.rules.unused_files, Severity::Warn);
1993 }
1994
1995 #[test]
1996 fn extends_npm_missing_package_errors() {
1997 let dir = test_dir("npm-missing");
1998 std::fs::write(
1999 dir.path().join(".fallowrc.json"),
2000 r#"{"extends": "npm:nonexistent-package"}"#,
2001 )
2002 .unwrap();
2003
2004 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2005 assert!(result.is_err());
2006 let err_msg = format!("{}", result.unwrap_err());
2007 assert!(
2008 err_msg.contains("not found"),
2009 "Expected 'not found' error, got: {err_msg}"
2010 );
2011 assert!(
2012 err_msg.contains("nonexistent-package"),
2013 "Expected package name in error, got: {err_msg}"
2014 );
2015 assert!(
2016 err_msg.contains("install it"),
2017 "Expected install hint in error, got: {err_msg}"
2018 );
2019 }
2020
2021 #[test]
2022 fn extends_npm_no_config_in_package_errors() {
2023 let dir = test_dir("npm-no-config");
2024 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2025 std::fs::create_dir_all(&pkg_dir).unwrap();
2026 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2028
2029 std::fs::write(
2030 dir.path().join(".fallowrc.json"),
2031 r#"{"extends": "npm:empty-pkg"}"#,
2032 )
2033 .unwrap();
2034
2035 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2036 assert!(result.is_err());
2037 let err_msg = format!("{}", result.unwrap_err());
2038 assert!(
2039 err_msg.contains("No fallow config found"),
2040 "Expected 'No fallow config found' error, got: {err_msg}"
2041 );
2042 }
2043
2044 #[test]
2045 fn extends_npm_missing_subpath_errors() {
2046 let dir = test_dir("npm-missing-sub");
2047 let pkg_dir = dir.path().join("node_modules/@co/config");
2048 std::fs::create_dir_all(&pkg_dir).unwrap();
2049
2050 std::fs::write(
2051 dir.path().join(".fallowrc.json"),
2052 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2053 )
2054 .unwrap();
2055
2056 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2057 assert!(result.is_err());
2058 let err_msg = format!("{}", result.unwrap_err());
2059 assert!(
2060 err_msg.contains("nonexistent.json"),
2061 "Expected subpath in error, got: {err_msg}"
2062 );
2063 }
2064
2065 #[test]
2066 fn extends_npm_empty_specifier_errors() {
2067 let dir = test_dir("npm-empty");
2068 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2069
2070 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2071 assert!(result.is_err());
2072 let err_msg = format!("{}", result.unwrap_err());
2073 assert!(
2074 err_msg.contains("Empty npm specifier"),
2075 "Expected 'Empty npm specifier' error, got: {err_msg}"
2076 );
2077 }
2078
2079 #[test]
2080 fn extends_npm_space_after_colon_trimmed() {
2081 let dir = test_dir("npm-space");
2082 create_npm_package(
2083 dir.path(),
2084 "fallow-config-acme",
2085 r#"{"rules": {"unused-files": "warn"}}"#,
2086 );
2087 std::fs::write(
2089 dir.path().join(".fallowrc.json"),
2090 r#"{"extends": "npm: fallow-config-acme"}"#,
2091 )
2092 .unwrap();
2093
2094 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2095 assert_eq!(config.rules.unused_files, Severity::Warn);
2096 }
2097
2098 #[test]
2099 fn extends_npm_exports_node_condition() {
2100 let dir = test_dir("npm-node-cond");
2101 let pkg_dir = dir.path().join("node_modules/node-config");
2102 std::fs::create_dir_all(&pkg_dir).unwrap();
2103 std::fs::write(
2104 pkg_dir.join("package.json"),
2105 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2106 )
2107 .unwrap();
2108 std::fs::write(
2109 pkg_dir.join("node.json"),
2110 r#"{"rules": {"unused-files": "off"}}"#,
2111 )
2112 .unwrap();
2113
2114 std::fs::write(
2115 dir.path().join(".fallowrc.json"),
2116 r#"{"extends": "npm:node-config"}"#,
2117 )
2118 .unwrap();
2119
2120 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2121 assert_eq!(config.rules.unused_files, Severity::Off);
2122 }
2123
2124 #[test]
2127 fn parse_npm_specifier_unscoped() {
2128 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2129 }
2130
2131 #[test]
2132 fn parse_npm_specifier_unscoped_with_subpath() {
2133 assert_eq!(
2134 parse_npm_specifier("my-config/strict.json"),
2135 ("my-config", Some("strict.json"))
2136 );
2137 }
2138
2139 #[test]
2140 fn parse_npm_specifier_scoped() {
2141 assert_eq!(
2142 parse_npm_specifier("@company/fallow-config"),
2143 ("@company/fallow-config", None)
2144 );
2145 }
2146
2147 #[test]
2148 fn parse_npm_specifier_scoped_with_subpath() {
2149 assert_eq!(
2150 parse_npm_specifier("@company/fallow-config/strict.json"),
2151 ("@company/fallow-config", Some("strict.json"))
2152 );
2153 }
2154
2155 #[test]
2156 fn parse_npm_specifier_scoped_with_nested_subpath() {
2157 assert_eq!(
2158 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2159 ("@company/fallow-config", Some("presets/strict.json"))
2160 );
2161 }
2162
2163 #[test]
2166 fn extends_npm_subpath_traversal_rejected() {
2167 let dir = test_dir("npm-traversal-sub");
2168 let pkg_dir = dir.path().join("node_modules/evil-pkg");
2169 std::fs::create_dir_all(&pkg_dir).unwrap();
2170 std::fs::write(
2172 dir.path().join("secret.json"),
2173 r#"{"entry": ["stolen.ts"]}"#,
2174 )
2175 .unwrap();
2176
2177 std::fs::write(
2178 dir.path().join(".fallowrc.json"),
2179 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2180 )
2181 .unwrap();
2182
2183 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2184 assert!(result.is_err());
2185 let err_msg = format!("{}", result.unwrap_err());
2186 assert!(
2187 err_msg.contains("traversal") || err_msg.contains("not found"),
2188 "Expected traversal or not-found error, got: {err_msg}"
2189 );
2190 }
2191
2192 #[test]
2193 fn extends_npm_dotdot_package_name_rejected() {
2194 let dir = test_dir("npm-dotdot-name");
2195 std::fs::write(
2196 dir.path().join(".fallowrc.json"),
2197 r#"{"extends": "npm:../relative"}"#,
2198 )
2199 .unwrap();
2200
2201 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2202 assert!(result.is_err());
2203 let err_msg = format!("{}", result.unwrap_err());
2204 assert!(
2205 err_msg.contains("path traversal"),
2206 "Expected 'path traversal' error, got: {err_msg}"
2207 );
2208 }
2209
2210 #[test]
2211 fn extends_npm_scoped_without_name_rejected() {
2212 let dir = test_dir("npm-scope-only");
2213 std::fs::write(
2214 dir.path().join(".fallowrc.json"),
2215 r#"{"extends": "npm:@scope"}"#,
2216 )
2217 .unwrap();
2218
2219 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2220 assert!(result.is_err());
2221 let err_msg = format!("{}", result.unwrap_err());
2222 assert!(
2223 err_msg.contains("@scope/name"),
2224 "Expected scoped name format error, got: {err_msg}"
2225 );
2226 }
2227
2228 #[test]
2229 fn extends_npm_malformed_package_json_errors() {
2230 let dir = test_dir("npm-bad-pkgjson");
2231 let pkg_dir = dir.path().join("node_modules/bad-pkg");
2232 std::fs::create_dir_all(&pkg_dir).unwrap();
2233 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2234
2235 std::fs::write(
2236 dir.path().join(".fallowrc.json"),
2237 r#"{"extends": "npm:bad-pkg"}"#,
2238 )
2239 .unwrap();
2240
2241 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2242 assert!(result.is_err());
2243 let err_msg = format!("{}", result.unwrap_err());
2244 assert!(
2245 err_msg.contains("Failed to parse"),
2246 "Expected parse error, got: {err_msg}"
2247 );
2248 }
2249
2250 #[test]
2251 fn extends_npm_exports_traversal_rejected() {
2252 let dir = test_dir("npm-exports-escape");
2253 let pkg_dir = dir.path().join("node_modules/evil-exports");
2254 std::fs::create_dir_all(&pkg_dir).unwrap();
2255 std::fs::write(
2256 pkg_dir.join("package.json"),
2257 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2258 )
2259 .unwrap();
2260 std::fs::write(
2262 dir.path().join("secret.json"),
2263 r#"{"entry": ["stolen.ts"]}"#,
2264 )
2265 .unwrap();
2266
2267 std::fs::write(
2268 dir.path().join(".fallowrc.json"),
2269 r#"{"extends": "npm:evil-exports"}"#,
2270 )
2271 .unwrap();
2272
2273 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2274 assert!(result.is_err());
2275 let err_msg = format!("{}", result.unwrap_err());
2276 assert!(
2277 err_msg.contains("traversal"),
2278 "Expected traversal error, got: {err_msg}"
2279 );
2280 }
2281
2282 #[test]
2285 fn deep_merge_scalar_overlay_replaces_base() {
2286 let mut base = serde_json::json!("hello");
2287 deep_merge_json(&mut base, serde_json::json!("world"));
2288 assert_eq!(base, serde_json::json!("world"));
2289 }
2290
2291 #[test]
2292 fn deep_merge_array_overlay_replaces_base() {
2293 let mut base = serde_json::json!(["a", "b"]);
2294 deep_merge_json(&mut base, serde_json::json!(["c"]));
2295 assert_eq!(base, serde_json::json!(["c"]));
2296 }
2297
2298 #[test]
2299 fn deep_merge_nested_object_merge() {
2300 let mut base = serde_json::json!({
2301 "level1": {
2302 "level2": {
2303 "a": 1,
2304 "b": 2
2305 }
2306 }
2307 });
2308 let overlay = serde_json::json!({
2309 "level1": {
2310 "level2": {
2311 "b": 99,
2312 "c": 3
2313 }
2314 }
2315 });
2316 deep_merge_json(&mut base, overlay);
2317 assert_eq!(base["level1"]["level2"]["a"], 1);
2318 assert_eq!(base["level1"]["level2"]["b"], 99);
2319 assert_eq!(base["level1"]["level2"]["c"], 3);
2320 }
2321
2322 #[test]
2323 fn deep_merge_overlay_adds_new_fields() {
2324 let mut base = serde_json::json!({"existing": true});
2325 let overlay = serde_json::json!({"new_field": "added", "another": 42});
2326 deep_merge_json(&mut base, overlay);
2327 assert_eq!(base["existing"], true);
2328 assert_eq!(base["new_field"], "added");
2329 assert_eq!(base["another"], 42);
2330 }
2331
2332 #[test]
2333 fn deep_merge_null_overlay_replaces_object() {
2334 let mut base = serde_json::json!({"key": "value"});
2335 deep_merge_json(&mut base, serde_json::json!(null));
2336 assert_eq!(base, serde_json::json!(null));
2337 }
2338
2339 #[test]
2340 fn deep_merge_empty_object_overlay_preserves_base() {
2341 let mut base = serde_json::json!({"a": 1, "b": 2});
2342 deep_merge_json(&mut base, serde_json::json!({}));
2343 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2344 }
2345
2346 #[test]
2349 fn rules_severity_error_warn_off_from_json() {
2350 let json_str = r#"{
2351 "rules": {
2352 "unused-files": "error",
2353 "unused-exports": "warn",
2354 "unused-types": "off"
2355 }
2356 }"#;
2357 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2358 assert_eq!(config.rules.unused_files, Severity::Error);
2359 assert_eq!(config.rules.unused_exports, Severity::Warn);
2360 assert_eq!(config.rules.unused_types, Severity::Off);
2361 }
2362
2363 #[test]
2364 fn rules_omitted_default_to_error() {
2365 let json_str = r#"{
2366 "rules": {
2367 "unused-files": "warn"
2368 }
2369 }"#;
2370 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2371 assert_eq!(config.rules.unused_files, Severity::Warn);
2372 assert_eq!(config.rules.unused_exports, Severity::Error);
2374 assert_eq!(config.rules.unused_types, Severity::Error);
2375 assert_eq!(config.rules.unused_dependencies, Severity::Error);
2376 assert_eq!(config.rules.unresolved_imports, Severity::Error);
2377 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2378 assert_eq!(config.rules.duplicate_exports, Severity::Error);
2379 assert_eq!(config.rules.circular_dependencies, Severity::Error);
2380 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2382 }
2383
2384 #[test]
2387 fn find_and_load_returns_none_when_no_config() {
2388 let dir = test_dir("find-none");
2389 std::fs::create_dir(dir.path().join(".git")).unwrap();
2391
2392 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2393 assert!(result.is_none());
2394 }
2395
2396 #[test]
2397 fn find_and_load_finds_fallowrc_json() {
2398 let dir = test_dir("find-json");
2399 std::fs::create_dir(dir.path().join(".git")).unwrap();
2400 std::fs::write(
2401 dir.path().join(".fallowrc.json"),
2402 r#"{"entry": ["src/main.ts"]}"#,
2403 )
2404 .unwrap();
2405
2406 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2407 assert_eq!(config.entry, vec!["src/main.ts"]);
2408 assert!(path.ends_with(".fallowrc.json"));
2409 }
2410
2411 #[test]
2412 fn find_and_load_finds_fallowrc_jsonc() {
2413 let dir = test_dir("find-jsonc");
2414 std::fs::create_dir(dir.path().join(".git")).unwrap();
2415 std::fs::write(
2416 dir.path().join(".fallowrc.jsonc"),
2417 r#"{
2418 // jsonc with comments, picked up by auto-discovery
2419 "entry": ["src/main.ts"]
2420 }"#,
2421 )
2422 .unwrap();
2423
2424 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2425 assert_eq!(config.entry, vec!["src/main.ts"]);
2426 assert!(path.ends_with(".fallowrc.jsonc"));
2427 }
2428
2429 #[test]
2430 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2431 let dir = test_dir("find-json-vs-jsonc");
2434 std::fs::create_dir(dir.path().join(".git")).unwrap();
2435 std::fs::write(
2436 dir.path().join(".fallowrc.json"),
2437 r#"{"entry": ["from-json.ts"]}"#,
2438 )
2439 .unwrap();
2440 std::fs::write(
2441 dir.path().join(".fallowrc.jsonc"),
2442 r#"{"entry": ["from-jsonc.ts"]}"#,
2443 )
2444 .unwrap();
2445
2446 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2447 assert_eq!(config.entry, vec!["from-json.ts"]);
2448 assert!(path.ends_with(".fallowrc.json"));
2449 }
2450
2451 #[test]
2452 fn find_and_load_prefers_fallowrc_json_over_toml() {
2453 let dir = test_dir("find-priority");
2454 std::fs::create_dir(dir.path().join(".git")).unwrap();
2455 std::fs::write(
2456 dir.path().join(".fallowrc.json"),
2457 r#"{"entry": ["from-json.ts"]}"#,
2458 )
2459 .unwrap();
2460 std::fs::write(
2461 dir.path().join("fallow.toml"),
2462 "entry = [\"from-toml.ts\"]\n",
2463 )
2464 .unwrap();
2465
2466 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2467 assert_eq!(config.entry, vec!["from-json.ts"]);
2468 assert!(path.ends_with(".fallowrc.json"));
2469 }
2470
2471 #[test]
2472 fn find_and_load_finds_fallow_toml() {
2473 let dir = test_dir("find-toml");
2474 std::fs::create_dir(dir.path().join(".git")).unwrap();
2475 std::fs::write(
2476 dir.path().join("fallow.toml"),
2477 "entry = [\"src/index.ts\"]\n",
2478 )
2479 .unwrap();
2480
2481 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2482 assert_eq!(config.entry, vec!["src/index.ts"]);
2483 }
2484
2485 #[test]
2486 fn find_and_load_stops_at_git_dir() {
2487 let dir = test_dir("find-git-stop");
2488 let sub = dir.path().join("sub");
2489 std::fs::create_dir(&sub).unwrap();
2490 std::fs::create_dir(dir.path().join(".git")).unwrap();
2492 let result = FallowConfig::find_and_load(&sub).unwrap();
2496 assert!(result.is_none());
2497 }
2498
2499 #[test]
2500 fn find_and_load_walks_past_package_json_in_monorepo() {
2501 let dir = test_dir("find-monorepo");
2505 std::fs::create_dir(dir.path().join(".git")).unwrap();
2506 std::fs::write(
2507 dir.path().join(".fallowrc.json"),
2508 r#"{"entry": ["src/index.ts"]}"#,
2509 )
2510 .unwrap();
2511
2512 let sub = dir.path().join("packages").join("app");
2513 std::fs::create_dir_all(&sub).unwrap();
2514 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2515
2516 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2517 assert_eq!(config.entry, vec!["src/index.ts"]);
2518 assert_eq!(path, dir.path().join(".fallowrc.json"));
2519 }
2520
2521 #[test]
2522 fn find_and_load_sub_package_config_wins_over_root() {
2523 let dir = test_dir("find-monorepo-override");
2526 std::fs::create_dir(dir.path().join(".git")).unwrap();
2527 std::fs::write(
2528 dir.path().join(".fallowrc.json"),
2529 r#"{"entry": ["src/root.ts"]}"#,
2530 )
2531 .unwrap();
2532
2533 let sub = dir.path().join("packages").join("app");
2534 std::fs::create_dir_all(&sub).unwrap();
2535 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2536 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2537
2538 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2539 assert_eq!(config.entry, vec!["src/sub.ts"]);
2540 assert_eq!(path, sub.join(".fallowrc.json"));
2541 }
2542
2543 #[test]
2544 fn find_and_load_stops_at_git_file_submodule() {
2545 let dir = test_dir("find-git-file");
2550 std::fs::create_dir(dir.path().join(".git")).unwrap();
2551 std::fs::write(
2552 dir.path().join(".fallowrc.json"),
2553 r#"{"entry": ["src/parent.ts"]}"#,
2554 )
2555 .unwrap();
2556
2557 let submodule = dir.path().join("vendor").join("lib");
2558 std::fs::create_dir_all(&submodule).unwrap();
2559 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2561
2562 let result = FallowConfig::find_and_load(&submodule).unwrap();
2563 assert!(
2564 result.is_none(),
2565 "submodule boundary should stop config walk",
2566 );
2567 }
2568
2569 #[test]
2570 fn find_and_load_stops_at_hg_dir() {
2571 let dir = test_dir("find-hg-stop");
2572 let sub = dir.path().join("sub");
2573 std::fs::create_dir(&sub).unwrap();
2574 std::fs::create_dir(dir.path().join(".hg")).unwrap();
2575
2576 let result = FallowConfig::find_and_load(&sub).unwrap();
2577 assert!(result.is_none());
2578 }
2579
2580 #[test]
2581 fn find_and_load_returns_error_for_invalid_config() {
2582 let dir = test_dir("find-invalid");
2583 std::fs::create_dir(dir.path().join(".git")).unwrap();
2584 std::fs::write(
2585 dir.path().join(".fallowrc.json"),
2586 r"{ this is not valid json }",
2587 )
2588 .unwrap();
2589
2590 let result = FallowConfig::find_and_load(dir.path());
2591 assert!(result.is_err());
2592 }
2593
2594 #[test]
2597 fn load_toml_config_file() {
2598 let dir = test_dir("toml-config");
2599 let config_path = dir.path().join("fallow.toml");
2600 std::fs::write(
2601 &config_path,
2602 r#"
2603entry = ["src/index.ts"]
2604ignorePatterns = ["dist/**"]
2605
2606[rules]
2607unused-files = "warn"
2608
2609[duplicates]
2610minTokens = 100
2611"#,
2612 )
2613 .unwrap();
2614
2615 let config = FallowConfig::load(&config_path).unwrap();
2616 assert_eq!(config.entry, vec!["src/index.ts"]);
2617 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2618 assert_eq!(config.rules.unused_files, Severity::Warn);
2619 assert_eq!(config.duplicates.min_tokens, 100);
2620 }
2621
2622 #[test]
2625 fn extends_absolute_path_rejected() {
2626 let dir = test_dir("extends-absolute");
2627
2628 #[cfg(unix)]
2630 let abs_path = "/absolute/path/config.json";
2631 #[cfg(windows)]
2632 let abs_path = "C:\\absolute\\path\\config.json";
2633
2634 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2635 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2636
2637 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2638 assert!(result.is_err());
2639 let err_msg = format!("{}", result.unwrap_err());
2640 assert!(
2641 err_msg.contains("must be relative"),
2642 "Expected 'must be relative' error, got: {err_msg}"
2643 );
2644 }
2645
2646 #[test]
2649 fn resolve_production_mode_disables_dev_deps() {
2650 let config = FallowConfig {
2651 production: true.into(),
2652 ..Default::default()
2653 };
2654 let resolved = config.resolve(
2655 PathBuf::from("/tmp/test"),
2656 OutputFormat::Human,
2657 4,
2658 false,
2659 true,
2660 None,
2661 );
2662 assert!(resolved.production);
2663 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2664 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2665 assert_eq!(resolved.rules.unused_files, Severity::Error);
2667 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2668 }
2669
2670 #[test]
2673 fn include_entry_exports_deserializes_from_camelcase_json() {
2674 let json = r#"{ "includeEntryExports": true }"#;
2675 let config: FallowConfig = serde_json::from_str(json).unwrap();
2676 assert!(config.include_entry_exports);
2677 }
2678
2679 #[test]
2680 fn include_entry_exports_deserializes_from_camelcase_toml() {
2681 let toml_str = "includeEntryExports = true\n";
2682 let config: FallowConfig = toml::from_str(toml_str).unwrap();
2683 assert!(config.include_entry_exports);
2684 }
2685
2686 #[test]
2687 fn include_entry_exports_default_is_false() {
2688 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2689 assert!(!config.include_entry_exports);
2690 }
2691
2692 #[test]
2693 fn include_entry_exports_propagates_through_resolve() {
2694 let config = FallowConfig {
2695 include_entry_exports: true,
2696 cache: CacheConfig::default(),
2697 ..Default::default()
2698 };
2699 let resolved = config.resolve(
2700 PathBuf::from("/tmp/test"),
2701 OutputFormat::Human,
2702 1,
2703 true,
2704 true,
2705 None,
2706 );
2707 assert!(resolved.include_entry_exports);
2708 }
2709
2710 #[test]
2713 fn config_format_defaults_to_toml_for_unknown() {
2714 assert!(matches!(
2715 ConfigFormat::from_path(Path::new("config.yaml")),
2716 ConfigFormat::Toml
2717 ));
2718 assert!(matches!(
2719 ConfigFormat::from_path(Path::new("config")),
2720 ConfigFormat::Toml
2721 ));
2722 }
2723
2724 #[test]
2727 fn deep_merge_object_over_scalar_replaces() {
2728 let mut base = serde_json::json!("just a string");
2729 let overlay = serde_json::json!({"key": "value"});
2730 deep_merge_json(&mut base, overlay);
2731 assert_eq!(base, serde_json::json!({"key": "value"}));
2732 }
2733
2734 #[test]
2735 fn deep_merge_scalar_over_object_replaces() {
2736 let mut base = serde_json::json!({"key": "value"});
2737 let overlay = serde_json::json!(42);
2738 deep_merge_json(&mut base, overlay);
2739 assert_eq!(base, serde_json::json!(42));
2740 }
2741
2742 #[test]
2745 fn extends_non_string_non_array_ignored() {
2746 let dir = test_dir("extends-numeric");
2747 std::fs::write(
2748 dir.path().join(".fallowrc.json"),
2749 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2750 )
2751 .unwrap();
2752
2753 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2755 assert_eq!(config.entry, vec!["src/index.ts"]);
2756 }
2757
2758 #[test]
2761 fn extends_multiple_bases_later_wins() {
2762 let dir = test_dir("extends-multi-base");
2763
2764 std::fs::write(
2765 dir.path().join("base-a.json"),
2766 r#"{"rules": {"unused-files": "warn"}}"#,
2767 )
2768 .unwrap();
2769 std::fs::write(
2770 dir.path().join("base-b.json"),
2771 r#"{"rules": {"unused-files": "off"}}"#,
2772 )
2773 .unwrap();
2774 std::fs::write(
2775 dir.path().join(".fallowrc.json"),
2776 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2777 )
2778 .unwrap();
2779
2780 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2781 assert_eq!(config.rules.unused_files, Severity::Off);
2783 }
2784
2785 #[test]
2788 fn fallow_config_deserialize_production() {
2789 let json_str = r#"{"production": true}"#;
2790 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2791 assert!(config.production);
2792 }
2793
2794 #[test]
2795 fn fallow_config_production_defaults_false() {
2796 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2797 assert!(!config.production);
2798 }
2799
2800 #[test]
2803 fn package_json_optional_dependency_names() {
2804 let pkg: PackageJson = serde_json::from_str(
2805 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2806 )
2807 .unwrap();
2808 let opt = pkg.optional_dependency_names();
2809 assert_eq!(opt.len(), 2);
2810 assert!(opt.contains(&"fsevents".to_string()));
2811 assert!(opt.contains(&"chokidar".to_string()));
2812 }
2813
2814 #[test]
2815 fn package_json_optional_deps_empty_when_missing() {
2816 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2817 assert!(pkg.optional_dependency_names().is_empty());
2818 }
2819
2820 #[test]
2823 fn find_config_path_returns_fallowrc_json() {
2824 let dir = test_dir("find-path-json");
2825 std::fs::create_dir(dir.path().join(".git")).unwrap();
2826 std::fs::write(
2827 dir.path().join(".fallowrc.json"),
2828 r#"{"entry": ["src/main.ts"]}"#,
2829 )
2830 .unwrap();
2831
2832 let path = FallowConfig::find_config_path(dir.path());
2833 assert!(path.is_some());
2834 assert!(path.unwrap().ends_with(".fallowrc.json"));
2835 }
2836
2837 #[test]
2838 fn find_config_path_returns_fallow_toml() {
2839 let dir = test_dir("find-path-toml");
2840 std::fs::create_dir(dir.path().join(".git")).unwrap();
2841 std::fs::write(
2842 dir.path().join("fallow.toml"),
2843 "entry = [\"src/main.ts\"]\n",
2844 )
2845 .unwrap();
2846
2847 let path = FallowConfig::find_config_path(dir.path());
2848 assert!(path.is_some());
2849 assert!(path.unwrap().ends_with("fallow.toml"));
2850 }
2851
2852 #[test]
2853 fn find_config_path_returns_dot_fallow_toml() {
2854 let dir = test_dir("find-path-dot-toml");
2855 std::fs::create_dir(dir.path().join(".git")).unwrap();
2856 std::fs::write(
2857 dir.path().join(".fallow.toml"),
2858 "entry = [\"src/main.ts\"]\n",
2859 )
2860 .unwrap();
2861
2862 let path = FallowConfig::find_config_path(dir.path());
2863 assert!(path.is_some());
2864 assert!(path.unwrap().ends_with(".fallow.toml"));
2865 }
2866
2867 #[test]
2868 fn find_config_path_prefers_json_over_toml() {
2869 let dir = test_dir("find-path-priority");
2870 std::fs::create_dir(dir.path().join(".git")).unwrap();
2871 std::fs::write(
2872 dir.path().join(".fallowrc.json"),
2873 r#"{"entry": ["json.ts"]}"#,
2874 )
2875 .unwrap();
2876 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2877
2878 let path = FallowConfig::find_config_path(dir.path());
2879 assert!(path.unwrap().ends_with(".fallowrc.json"));
2880 }
2881
2882 #[test]
2883 fn find_config_path_none_when_no_config() {
2884 let dir = test_dir("find-path-none");
2885 std::fs::create_dir(dir.path().join(".git")).unwrap();
2886
2887 let path = FallowConfig::find_config_path(dir.path());
2888 assert!(path.is_none());
2889 }
2890
2891 #[test]
2892 fn find_config_path_walks_past_package_json_in_monorepo() {
2893 let dir = test_dir("find-path-monorepo");
2894 std::fs::create_dir(dir.path().join(".git")).unwrap();
2895 std::fs::write(
2896 dir.path().join(".fallowrc.json"),
2897 r#"{"entry": ["src/index.ts"]}"#,
2898 )
2899 .unwrap();
2900
2901 let sub = dir.path().join("packages").join("app");
2902 std::fs::create_dir_all(&sub).unwrap();
2903 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2904
2905 let path = FallowConfig::find_config_path(&sub).unwrap();
2906 assert_eq!(path, dir.path().join(".fallowrc.json"));
2907 }
2908
2909 #[test]
2912 fn extends_toml_base() {
2913 let dir = test_dir("extends-toml");
2914
2915 std::fs::write(
2916 dir.path().join("base.json"),
2917 r#"{"rules": {"unused-files": "warn"}}"#,
2918 )
2919 .unwrap();
2920 std::fs::write(
2921 dir.path().join("fallow.toml"),
2922 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2923 )
2924 .unwrap();
2925
2926 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2927 assert_eq!(config.rules.unused_files, Severity::Warn);
2928 assert_eq!(config.entry, vec!["src/index.ts"]);
2929 }
2930
2931 #[test]
2934 fn deep_merge_boolean_overlay() {
2935 let mut base = serde_json::json!(true);
2936 deep_merge_json(&mut base, serde_json::json!(false));
2937 assert_eq!(base, serde_json::json!(false));
2938 }
2939
2940 #[test]
2941 fn deep_merge_number_overlay() {
2942 let mut base = serde_json::json!(42);
2943 deep_merge_json(&mut base, serde_json::json!(99));
2944 assert_eq!(base, serde_json::json!(99));
2945 }
2946
2947 #[test]
2948 fn deep_merge_disjoint_objects() {
2949 let mut base = serde_json::json!({"a": 1});
2950 let overlay = serde_json::json!({"b": 2});
2951 deep_merge_json(&mut base, overlay);
2952 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2953 }
2954
2955 #[test]
2958 fn max_extends_depth_is_reasonable() {
2959 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2960 }
2961
2962 #[test]
2965 fn config_names_has_four_entries() {
2966 assert_eq!(CONFIG_NAMES.len(), 4);
2967 for name in CONFIG_NAMES {
2969 assert!(
2970 name.starts_with('.') || name.starts_with("fallow"),
2971 "unexpected config name: {name}"
2972 );
2973 }
2974 }
2975
2976 #[test]
2979 fn package_json_peer_dependency_names() {
2980 let pkg: PackageJson = serde_json::from_str(
2981 r#"{
2982 "dependencies": {"react": "^18"},
2983 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2984 }"#,
2985 )
2986 .unwrap();
2987 let all = pkg.all_dependency_names();
2988 assert!(all.contains(&"react".to_string()));
2989 assert!(all.contains(&"react-dom".to_string()));
2990 assert!(all.contains(&"react-native".to_string()));
2991 }
2992
2993 #[test]
2996 fn package_json_scripts_field() {
2997 let pkg: PackageJson = serde_json::from_str(
2998 r#"{
2999 "scripts": {
3000 "build": "tsc",
3001 "test": "vitest",
3002 "lint": "fallow check"
3003 }
3004 }"#,
3005 )
3006 .unwrap();
3007 let scripts = pkg.scripts.unwrap();
3008 assert_eq!(scripts.len(), 3);
3009 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3010 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3011 }
3012
3013 #[test]
3016 fn extends_toml_chain() {
3017 let dir = test_dir("extends-toml-chain");
3018
3019 std::fs::write(
3020 dir.path().join("base.json"),
3021 r#"{"entry": ["src/base.ts"]}"#,
3022 )
3023 .unwrap();
3024 std::fs::write(
3025 dir.path().join("middle.json"),
3026 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3027 )
3028 .unwrap();
3029 std::fs::write(
3030 dir.path().join("fallow.toml"),
3031 "extends = [\"middle.json\"]\n",
3032 )
3033 .unwrap();
3034
3035 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3036 assert_eq!(config.entry, vec!["src/base.ts"]);
3037 assert_eq!(config.rules.unused_files, Severity::Off);
3038 }
3039
3040 #[test]
3043 fn find_and_load_walks_up_directories() {
3044 let dir = test_dir("find-walk-up");
3045 let sub = dir.path().join("src").join("deep");
3046 std::fs::create_dir_all(&sub).unwrap();
3047 std::fs::write(
3048 dir.path().join(".fallowrc.json"),
3049 r#"{"entry": ["src/main.ts"]}"#,
3050 )
3051 .unwrap();
3052 std::fs::create_dir(dir.path().join(".git")).unwrap();
3054
3055 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3056 assert_eq!(config.entry, vec!["src/main.ts"]);
3057 assert!(path.ends_with(".fallowrc.json"));
3058 }
3059
3060 #[test]
3063 fn json_schema_contains_entry_field() {
3064 let schema = FallowConfig::json_schema();
3065 let obj = schema.as_object().unwrap();
3066 let props = obj.get("properties").and_then(|v| v.as_object());
3067 assert!(props.is_some(), "schema should have properties");
3068 assert!(
3069 props.unwrap().contains_key("entry"),
3070 "schema should contain entry property"
3071 );
3072 }
3073
3074 #[test]
3077 fn fallow_config_json_duplicates_all_fields() {
3078 let json = r#"{
3079 "duplicates": {
3080 "enabled": true,
3081 "mode": "semantic",
3082 "minTokens": 200,
3083 "minLines": 20,
3084 "threshold": 10.5,
3085 "ignore": ["**/*.test.ts"],
3086 "skipLocal": true,
3087 "crossLanguage": true,
3088 "normalization": {
3089 "ignoreIdentifiers": true,
3090 "ignoreStringValues": false
3091 }
3092 }
3093 }"#;
3094 let config: FallowConfig = serde_json::from_str(json).unwrap();
3095 assert!(config.duplicates.enabled);
3096 assert_eq!(
3097 config.duplicates.mode,
3098 crate::config::DetectionMode::Semantic
3099 );
3100 assert_eq!(config.duplicates.min_tokens, 200);
3101 assert_eq!(config.duplicates.min_lines, 20);
3102 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3103 assert!(config.duplicates.skip_local);
3104 assert!(config.duplicates.cross_language);
3105 assert_eq!(
3106 config.duplicates.normalization.ignore_identifiers,
3107 Some(true)
3108 );
3109 assert_eq!(
3110 config.duplicates.normalization.ignore_string_values,
3111 Some(false)
3112 );
3113 }
3114
3115 #[test]
3118 fn normalize_url_basic() {
3119 assert_eq!(
3120 normalize_url_for_dedup("https://example.com/config.json"),
3121 "https://example.com/config.json"
3122 );
3123 }
3124
3125 #[test]
3126 fn normalize_url_trailing_slash() {
3127 assert_eq!(
3128 normalize_url_for_dedup("https://example.com/config/"),
3129 "https://example.com/config"
3130 );
3131 }
3132
3133 #[test]
3134 fn normalize_url_uppercase_scheme_and_host() {
3135 assert_eq!(
3136 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3137 "https://example.com/Config.json"
3138 );
3139 }
3140
3141 #[test]
3142 fn normalize_url_root_path() {
3143 assert_eq!(
3144 normalize_url_for_dedup("https://example.com/"),
3145 "https://example.com"
3146 );
3147 assert_eq!(
3148 normalize_url_for_dedup("https://example.com"),
3149 "https://example.com"
3150 );
3151 }
3152
3153 #[test]
3154 fn normalize_url_preserves_path_case() {
3155 assert_eq!(
3157 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3158 "https://github.com/Org/Repo/Fallow.json"
3159 );
3160 }
3161
3162 #[test]
3163 fn normalize_url_strips_query_string() {
3164 assert_eq!(
3165 normalize_url_for_dedup("https://example.com/config.json?v=1"),
3166 "https://example.com/config.json"
3167 );
3168 }
3169
3170 #[test]
3171 fn normalize_url_strips_fragment() {
3172 assert_eq!(
3173 normalize_url_for_dedup("https://example.com/config.json#section"),
3174 "https://example.com/config.json"
3175 );
3176 }
3177
3178 #[test]
3179 fn normalize_url_strips_query_and_fragment() {
3180 assert_eq!(
3181 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3182 "https://example.com/config.json"
3183 );
3184 }
3185
3186 #[test]
3187 fn normalize_url_default_https_port() {
3188 assert_eq!(
3189 normalize_url_for_dedup("https://example.com:443/config.json"),
3190 "https://example.com/config.json"
3191 );
3192 assert_eq!(
3194 normalize_url_for_dedup("https://example.com:8443/config.json"),
3195 "https://example.com:8443/config.json"
3196 );
3197 }
3198
3199 #[test]
3200 fn extends_http_rejected() {
3201 let dir = test_dir("http-rejected");
3202 std::fs::write(
3203 dir.path().join(".fallowrc.json"),
3204 r#"{"extends": "http://example.com/config.json"}"#,
3205 )
3206 .unwrap();
3207
3208 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3209 assert!(result.is_err());
3210 let err_msg = format!("{}", result.unwrap_err());
3211 assert!(
3212 err_msg.contains("https://"),
3213 "Expected https hint in error, got: {err_msg}"
3214 );
3215 assert!(
3216 err_msg.contains("http://"),
3217 "Expected http:// mention in error, got: {err_msg}"
3218 );
3219 }
3220
3221 #[test]
3222 fn extends_url_circular_detection() {
3223 let mut visited = FxHashSet::default();
3225 let url = "https://example.com/config.json";
3226 let normalized = normalize_url_for_dedup(url);
3227 visited.insert(normalized.clone());
3228
3229 assert!(
3231 !visited.insert(normalized),
3232 "Same URL should be detected as duplicate"
3233 );
3234 }
3235
3236 #[test]
3237 fn extends_url_circular_case_insensitive() {
3238 let mut visited = FxHashSet::default();
3240 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3241
3242 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3243 assert!(
3244 !visited.insert(normalized),
3245 "Case-different URLs should normalize to the same key"
3246 );
3247 }
3248
3249 #[test]
3250 fn extract_extends_array() {
3251 let mut value = serde_json::json!({
3252 "extends": ["a.json", "b.json"],
3253 "entry": ["src/index.ts"]
3254 });
3255 let extends = extract_extends(&mut value);
3256 assert_eq!(extends, vec!["a.json", "b.json"]);
3257 assert!(value.get("extends").is_none());
3259 assert!(value.get("entry").is_some());
3260 }
3261
3262 #[test]
3263 fn extract_extends_string_sugar() {
3264 let mut value = serde_json::json!({
3265 "extends": "base.json",
3266 "entry": ["src/index.ts"]
3267 });
3268 let extends = extract_extends(&mut value);
3269 assert_eq!(extends, vec!["base.json"]);
3270 }
3271
3272 #[test]
3273 fn extract_extends_none() {
3274 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3275 let extends = extract_extends(&mut value);
3276 assert!(extends.is_empty());
3277 }
3278
3279 #[test]
3280 fn url_timeout_default() {
3281 let timeout = url_timeout();
3283 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3286 }
3287
3288 #[test]
3289 fn extends_url_mixed_with_file_and_npm() {
3290 let dir = test_dir("url-mixed");
3293 std::fs::write(
3294 dir.path().join("local.json"),
3295 r#"{"rules": {"unused-files": "warn"}}"#,
3296 )
3297 .unwrap();
3298 std::fs::write(
3299 dir.path().join(".fallowrc.json"),
3300 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3301 )
3302 .unwrap();
3303
3304 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3305 assert!(result.is_err());
3306 let err_msg = format!("{}", result.unwrap_err());
3307 assert!(
3308 err_msg.contains("unreachable.invalid"),
3309 "Expected URL in error message, got: {err_msg}"
3310 );
3311 }
3312
3313 #[test]
3314 fn extends_https_url_unreachable_errors() {
3315 let dir = test_dir("url-unreachable");
3316 std::fs::write(
3317 dir.path().join(".fallowrc.json"),
3318 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3319 )
3320 .unwrap();
3321
3322 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3323 assert!(result.is_err());
3324 let err_msg = format!("{}", result.unwrap_err());
3325 assert!(
3326 err_msg.contains("unreachable.invalid"),
3327 "Expected URL in error, got: {err_msg}"
3328 );
3329 assert!(
3330 err_msg.contains("local path or npm:"),
3331 "Expected remediation hint, got: {err_msg}"
3332 );
3333 }
3334
3335 #[test]
3338 fn collect_unknown_rule_keys_flags_top_level_typo() {
3339 let merged = serde_json::json!({
3340 "rules": {
3341 "unsued-files": "warn",
3342 "unused-exports": "off"
3343 }
3344 });
3345 let findings = collect_unknown_rule_keys(&merged);
3346 assert_eq!(findings.len(), 1);
3347 assert_eq!(findings[0].context, "rules");
3348 assert_eq!(findings[0].key, "unsued-files");
3349 assert_eq!(findings[0].suggestion, Some("unused-files"));
3350 }
3351
3352 #[test]
3353 fn collect_unknown_rule_keys_flags_overrides_typo() {
3354 let merged = serde_json::json!({
3355 "overrides": [
3356 {
3357 "files": ["src/**/*.ts"],
3358 "rules": {
3359 "unsued-files": "warn"
3360 }
3361 },
3362 {
3363 "files": ["tests/**/*.ts"],
3364 "rules": {
3365 "circular-dependnecy": "off"
3366 }
3367 }
3368 ]
3369 });
3370 let findings = collect_unknown_rule_keys(&merged);
3371 assert_eq!(findings.len(), 2);
3372 assert_eq!(findings[0].context, "overrides[0].rules");
3373 assert_eq!(findings[1].context, "overrides[1].rules");
3374 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3375 }
3376
3377 #[test]
3378 fn collect_unknown_rule_keys_empty_for_valid_config() {
3379 let merged = serde_json::json!({
3380 "rules": {
3381 "unused-files": "warn",
3382 "unused-file": "off",
3383 "circular-dependency": "off",
3384 "boundary-violations": "warn"
3385 },
3386 "overrides": [
3387 {
3388 "files": ["src/**"],
3389 "rules": {
3390 "unused-exports": "warn"
3391 }
3392 }
3393 ]
3394 });
3395 let findings = collect_unknown_rule_keys(&merged);
3396 assert!(
3397 findings.is_empty(),
3398 "valid rule names and aliases must not be flagged: {findings:?}"
3399 );
3400 }
3401
3402 #[test]
3403 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3404 let merged = serde_json::json!({
3405 "entry": ["src/main.ts"]
3406 });
3407 let findings = collect_unknown_rule_keys(&merged);
3408 assert!(findings.is_empty());
3409 }
3410
3411 #[test]
3412 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3413 let dir = test_dir("wiring");
3425 let path = dir.path().join(".fallowrc.json");
3426 let typo = format!(
3427 "wiring-probe-{}-{}",
3428 std::process::id(),
3429 std::time::SystemTime::now()
3430 .duration_since(std::time::UNIX_EPOCH)
3431 .map_or(0, |d| d.as_nanos())
3432 );
3433 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3434
3435 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3436
3437 assert!(
3438 config_res.is_ok(),
3439 "load should succeed in phase 1: {:?}",
3440 config_res.err()
3441 );
3442 assert_eq!(
3443 captured.len(),
3444 1,
3445 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3446 );
3447 assert_eq!(captured[0].key, typo);
3448 assert_eq!(captured[0].context, "rules");
3449 }
3450
3451 #[test]
3452 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3453 let dir = test_dir("misspelled-rule");
3457 std::fs::write(
3458 dir.path().join(".fallowrc.json"),
3459 r#"{"rules": {"unsued-files": "warn"}}"#,
3460 )
3461 .unwrap();
3462
3463 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3464 .expect("load should succeed in phase 1");
3465
3466 assert_eq!(config.rules.unused_files, Severity::Error);
3468 }
3469
3470 #[test]
3473 fn validate_resolved_boundaries_passes_on_valid_config() {
3474 let dir = test_dir("boundaries-valid");
3475 let config = FallowConfig {
3476 boundaries: crate::BoundaryConfig {
3477 preset: None,
3478 zones: vec![
3479 crate::BoundaryZone {
3480 name: "ui".to_string(),
3481 patterns: vec!["src/components/**".to_string()],
3482 auto_discover: vec![],
3483 root: None,
3484 },
3485 crate::BoundaryZone {
3486 name: "db".to_string(),
3487 patterns: vec!["src/db/**".to_string()],
3488 auto_discover: vec![],
3489 root: None,
3490 },
3491 ],
3492 rules: vec![crate::BoundaryRule {
3493 from: "ui".to_string(),
3494 allow: vec!["db".to_string()],
3495 allow_type_only: vec![],
3496 }],
3497 },
3498 ..FallowConfig::default()
3499 };
3500 config
3501 .validate_resolved_boundaries(dir.path())
3502 .expect("valid config should pass");
3503 }
3504
3505 #[test]
3506 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3507 let dir = test_dir("boundaries-unknown-zones");
3508 let config = FallowConfig {
3509 boundaries: crate::BoundaryConfig {
3510 preset: None,
3511 zones: vec![crate::BoundaryZone {
3512 name: "ui".to_string(),
3513 patterns: vec!["src/ui/**".to_string()],
3514 auto_discover: vec![],
3515 root: None,
3516 }],
3517 rules: vec![
3518 crate::BoundaryRule {
3519 from: "typo-from".to_string(),
3520 allow: vec!["typo-allow".to_string()],
3521 allow_type_only: vec!["typo-type-only".to_string()],
3522 },
3523 crate::BoundaryRule {
3524 from: "ui".to_string(),
3525 allow: vec!["another-typo".to_string()],
3526 allow_type_only: vec![],
3527 },
3528 ],
3529 },
3530 ..FallowConfig::default()
3531 };
3532
3533 let errors = config
3534 .validate_resolved_boundaries(dir.path())
3535 .expect_err("invalid zone refs should fail");
3536
3537 assert_eq!(errors.len(), 4, "got: {errors:?}");
3538
3539 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3543 assert!(
3544 rendered
3545 .iter()
3546 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3547 );
3548 assert!(
3549 rendered
3550 .iter()
3551 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3552 );
3553 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3554 && m.contains("rules[0]")
3555 && m.contains("allowTypeOnly")));
3556 assert!(
3557 rendered.iter().any(|m| m.contains("another-typo")
3558 && m.contains("rules[1]")
3559 && m.contains("allow"))
3560 );
3561 }
3562
3563 #[test]
3564 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3565 let dir = test_dir("boundaries-redundant-prefix");
3566 let config = FallowConfig {
3567 boundaries: crate::BoundaryConfig {
3568 preset: None,
3569 zones: vec![crate::BoundaryZone {
3570 name: "ui".to_string(),
3571 patterns: vec!["packages/app/src/**".to_string()],
3572 auto_discover: vec![],
3573 root: Some("packages/app/".to_string()),
3574 }],
3575 rules: vec![],
3576 },
3577 ..FallowConfig::default()
3578 };
3579
3580 let errors = config
3581 .validate_resolved_boundaries(dir.path())
3582 .expect_err("redundant root prefix should fail");
3583 assert_eq!(errors.len(), 1, "got: {errors:?}");
3584 let rendered = errors[0].to_string();
3586 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3587 assert!(rendered.contains("zone 'ui'"));
3588 }
3589
3590 #[test]
3591 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3592 let dir = test_dir("boundaries-mixed-errors");
3595 let config = FallowConfig {
3596 boundaries: crate::BoundaryConfig {
3597 preset: None,
3598 zones: vec![crate::BoundaryZone {
3599 name: "ui".to_string(),
3600 patterns: vec!["packages/app/src/**".to_string()],
3601 auto_discover: vec![],
3602 root: Some("packages/app/".to_string()),
3603 }],
3604 rules: vec![crate::BoundaryRule {
3605 from: "ui".to_string(),
3606 allow: vec!["typo-zone".to_string()],
3607 allow_type_only: vec![],
3608 }],
3609 },
3610 ..FallowConfig::default()
3611 };
3612 let errors = config
3613 .validate_resolved_boundaries(dir.path())
3614 .expect_err("mixed errors should fail");
3615 assert_eq!(errors.len(), 2, "got: {errors:?}");
3616 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3617 assert!(
3618 rendered
3619 .iter()
3620 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3621 );
3622 assert!(
3623 rendered
3624 .iter()
3625 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3626 );
3627 }
3628
3629 #[test]
3630 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3631 let dir = test_dir("boundaries-bulletproof");
3637 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3639 let config = FallowConfig {
3640 boundaries: crate::BoundaryConfig {
3641 preset: Some(crate::BoundaryPreset::Bulletproof),
3642 zones: vec![],
3643 rules: vec![],
3644 },
3645 ..FallowConfig::default()
3646 };
3647 config
3648 .validate_resolved_boundaries(dir.path())
3649 .expect("Bulletproof with discoverable features should pass");
3650 }
3651}