1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use fallow_types::path_util::is_absolute_path_any_platform;
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9pub(super) const CONFIG_NAMES: &[&str] = &[
16 ".fallowrc.json",
17 ".fallowrc.jsonc",
18 "fallow.toml",
19 ".fallow.toml",
20];
21
22pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
23
24const NPM_PREFIX: &str = "npm:";
26
27const HTTPS_PREFIX: &str = "https://";
29
30const HTTP_PREFIX: &str = "http://";
32
33const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
35
36pub(super) enum ConfigFormat {
38 Toml,
39 Json,
40}
41
42impl ConfigFormat {
43 pub(super) fn from_path(path: &Path) -> Self {
44 match path.extension().and_then(|e| e.to_str()) {
45 Some("json" | "jsonc") => Self::Json,
46 _ => Self::Toml,
47 }
48 }
49}
50
51pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
54 match (base, overlay) {
55 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
56 for (key, value) in overlay_map {
57 if let Some(base_value) = base_map.get_mut(&key) {
58 deep_merge_json(base_value, value);
59 } else {
60 base_map.insert(key, value);
61 }
62 }
63 }
64 (base, overlay) => {
65 *base = overlay;
66 }
67 }
68}
69
70pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
71 let content = std::fs::read_to_string(path)
72 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
73 let content = content.trim_start_matches('\u{FEFF}');
77
78 match ConfigFormat::from_path(path) {
79 ConfigFormat::Toml => {
80 let toml_value: toml::Value = toml::from_str(content).map_err(|e| {
81 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
82 })?;
83 serde_json::to_value(toml_value).map_err(|e| {
84 miette::miette!(
85 "Failed to convert TOML to JSON for {}: {}",
86 path.display(),
87 e
88 )
89 })
90 }
91 ConfigFormat::Json => crate::jsonc::parse_to_value(content)
92 .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e)),
93 }
94}
95
96fn is_repo_root(dir: &Path) -> bool {
104 dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
105}
106
107fn resolve_confined(
112 base_dir: &Path,
113 resolved: &Path,
114 context: &str,
115 source_config: &Path,
116) -> Result<PathBuf, miette::Report> {
117 let canonical_base = dunce::canonicalize(base_dir)
118 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
119 let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
120 miette::miette!(
121 "Config file not found: {} ({}, referenced from {}): {}",
122 resolved.display(),
123 context,
124 source_config.display(),
125 e
126 )
127 })?;
128 if !canonical_file.starts_with(&canonical_base) {
129 return Err(miette::miette!(
130 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
131 resolved.display(),
132 base_dir.display(),
133 context,
134 source_config.display()
135 ));
136 }
137 Ok(canonical_file)
138}
139
140fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
142 if name.starts_with('@') && !name.contains('/') {
143 return Err(miette::miette!(
144 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
145 name,
146 source_config.display()
147 ));
148 }
149 if name.split('/').any(|c| c == ".." || c == ".") {
150 return Err(miette::miette!(
151 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
152 name,
153 source_config.display()
154 ));
155 }
156 Ok(())
157}
158
159fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
166 if specifier.starts_with('@') {
167 let mut slashes = 0;
170 for (i, ch) in specifier.char_indices() {
171 if ch == '/' {
172 slashes += 1;
173 if slashes == 2 {
174 return (&specifier[..i], Some(&specifier[i + 1..]));
175 }
176 }
177 }
178 (specifier, None)
180 } else if let Some(slash) = specifier.find('/') {
181 (&specifier[..slash], Some(&specifier[slash + 1..]))
182 } else {
183 (specifier, None)
184 }
185}
186
187fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
194 let exports = pkg.get("exports")?;
195 match exports {
196 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
197 serde_json::Value::Object(map) => {
198 let dot_export = map.get(".")?;
199 match dot_export {
200 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
201 serde_json::Value::Object(conditions) => {
202 for key in ["default", "node", "import", "require"] {
203 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
204 return Some(package_dir.join(s.as_str()));
205 }
206 }
207 None
208 }
209 _ => None,
210 }
211 }
212 _ => None,
215 }
216}
217
218fn find_config_in_npm_package(
228 package_dir: &Path,
229 source_config: &Path,
230) -> Result<PathBuf, miette::Report> {
231 let pkg_json_path = package_dir.join("package.json");
232 if pkg_json_path.exists() {
233 let content = std::fs::read_to_string(&pkg_json_path)
234 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
235 let pkg: serde_json::Value = serde_json::from_str(&content)
236 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
237 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
238 && config_path.exists()
239 {
240 return resolve_confined(
241 package_dir,
242 &config_path,
243 "package.json exports",
244 source_config,
245 );
246 }
247 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
248 let main_path = package_dir.join(main);
249 if main_path.exists() {
250 return resolve_confined(
251 package_dir,
252 &main_path,
253 "package.json main",
254 source_config,
255 );
256 }
257 }
258 }
259
260 for config_name in CONFIG_NAMES {
261 let config_path = package_dir.join(config_name);
262 if config_path.exists() {
263 return resolve_confined(
264 package_dir,
265 &config_path,
266 "config name fallback",
267 source_config,
268 );
269 }
270 }
271
272 Err(miette::miette!(
273 "No fallow config found in npm package at {}. \
274 Expected package.json with main/exports pointing to a config file, \
275 or one of: {}",
276 package_dir.display(),
277 CONFIG_NAMES.join(", ")
278 ))
279}
280
281fn resolve_npm_package(
287 config_dir: &Path,
288 specifier: &str,
289 source_config: &Path,
290) -> Result<PathBuf, miette::Report> {
291 let specifier = specifier.trim();
292 if specifier.is_empty() {
293 return Err(miette::miette!(
294 "Empty npm specifier in extends (in {})",
295 source_config.display()
296 ));
297 }
298
299 let (package_name, subpath) = parse_npm_specifier(specifier);
300 validate_npm_package_name(package_name, source_config)?;
301
302 let mut dir = Some(config_dir);
303 while let Some(d) = dir {
304 let candidate = d.join("node_modules").join(package_name);
305 if candidate.is_dir() {
306 return if let Some(sub) = subpath {
307 let file = candidate.join(sub);
308 if file.exists() {
309 resolve_confined(
310 &candidate,
311 &file,
312 &format!("subpath '{sub}'"),
313 source_config,
314 )
315 } else {
316 Err(miette::miette!(
317 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
318 file.display(),
319 sub,
320 candidate.display(),
321 source_config.display()
322 ))
323 }
324 } else {
325 find_config_in_npm_package(&candidate, source_config)
326 };
327 }
328 dir = d.parent();
329 }
330
331 Err(miette::miette!(
332 "npm package '{}' not found. \
333 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
334 If this package should be available, install it and ensure it is listed in your project's dependencies",
335 package_name,
336 package_name,
337 config_dir.display(),
338 source_config.display()
339 ))
340}
341
342fn normalize_url_for_dedup(url: &str) -> String {
349 let Some((scheme, rest)) = url.split_once("://") else {
351 return url.to_string();
352 };
353 let scheme = scheme.to_ascii_lowercase();
354
355 let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
357 let authority = authority.to_ascii_lowercase();
358
359 let authority = authority.strip_suffix(":443").unwrap_or(&authority);
361
362 let path = path.split_once('#').map_or(path, |(p, _)| p);
364 let path = path.split_once('?').map_or(path, |(p, _)| p);
365 let path = path.strip_suffix('/').unwrap_or(path);
366
367 if path.is_empty() {
368 format!("{scheme}://{authority}")
369 } else {
370 format!("{scheme}://{authority}/{path}")
371 }
372}
373
374fn url_timeout() -> Duration {
379 std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
380 .ok()
381 .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
382 .map_or(
383 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
384 Duration::from_secs,
385 )
386}
387
388const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
391
392fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
397 let timeout = url_timeout();
398 let agent = ureq::Agent::config_builder()
399 .timeout_global(Some(timeout))
400 .https_only(true)
401 .build()
402 .new_agent();
403
404 let mut response = agent.get(url).call().map_err(|e| {
405 miette::miette!(
406 "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
407 If this URL is unavailable, use a local path or npm: specifier instead"
408 )
409 })?;
410
411 let body = response
412 .body_mut()
413 .with_config()
414 .limit(MAX_URL_CONFIG_BYTES)
415 .read_to_string()
416 .map_err(|e| {
417 miette::miette!(
418 "Failed to read response body from {url} (referenced from {source}): {e}"
419 )
420 })?;
421
422 crate::jsonc::parse_to_value(&body).map_err(|e| {
423 miette::miette!(
424 "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
425 Only JSON/JSONC is supported for URL-sourced configs"
426 )
427 })
428}
429
430fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
432 value
433 .as_object_mut()
434 .and_then(|obj| obj.remove("extends"))
435 .and_then(|v| match v {
436 serde_json::Value::Array(arr) => Some(
437 arr.into_iter()
438 .filter_map(|v| v.as_str().map(String::from))
439 .collect::<Vec<_>>(),
440 ),
441 serde_json::Value::String(s) => Some(vec![s]),
442 _ => None,
443 })
444 .unwrap_or_default()
445}
446
447fn resolve_url_extends(
452 url: &str,
453 visited: &mut FxHashSet<String>,
454 depth: usize,
455) -> Result<serde_json::Value, miette::Report> {
456 if depth >= MAX_EXTENDS_DEPTH {
457 return Err(miette::miette!(
458 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
459 ));
460 }
461
462 let normalized = normalize_url_for_dedup(url);
463 if !visited.insert(normalized) {
464 return Err(miette::miette!(
465 "Circular extends detected: {url} was already visited in the extends chain"
466 ));
467 }
468
469 let mut value = fetch_url_config(url, url)?;
470 let extends = extract_extends(&mut value);
471
472 if extends.is_empty() {
473 return Ok(value);
474 }
475
476 let mut merged = serde_json::Value::Object(serde_json::Map::new());
477
478 for entry in &extends {
479 let base = if entry.starts_with(HTTPS_PREFIX) {
480 resolve_url_extends(entry, visited, depth + 1)?
481 } else if entry.starts_with(HTTP_PREFIX) {
482 return Err(miette::miette!(
483 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
484 Change the URL to use https:// instead",
485 entry,
486 url
487 ));
488 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
489 let cwd = std::env::current_dir().map_err(|e| {
493 miette::miette!(
494 "Cannot resolve npm: specifier from URL-sourced config: \
495 failed to determine current directory: {e}"
496 )
497 })?;
498 tracing::warn!(
499 "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
500 current working directory for node_modules lookup"
501 );
502 let path_placeholder = PathBuf::from(url);
503 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
504 resolve_extends_file(&npm_path, visited, depth + 1)?
505 } else {
506 return Err(miette::miette!(
507 "Relative paths in 'extends' are not supported when the base config was \
508 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
509 instead. Got: '{entry}'"
510 ));
511 };
512 deep_merge_json(&mut merged, base);
513 }
514
515 deep_merge_json(&mut merged, value);
516 Ok(merged)
517}
518
519fn resolve_extends_file(
525 path: &Path,
526 visited: &mut FxHashSet<String>,
527 depth: usize,
528) -> Result<serde_json::Value, miette::Report> {
529 if depth >= MAX_EXTENDS_DEPTH {
530 return Err(miette::miette!(
531 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
532 path.display()
533 ));
534 }
535
536 let canonical = dunce::canonicalize(path).map_err(|e| {
537 miette::miette!(
538 "Config file not found or unresolvable: {}: {}",
539 path.display(),
540 e
541 )
542 })?;
543
544 if !visited.insert(canonical.to_string_lossy().into_owned()) {
545 return Err(miette::miette!(
546 "Circular extends detected: {} was already visited in the extends chain",
547 path.display()
548 ));
549 }
550
551 let mut value = parse_config_to_value(path)?;
552 let extends = extract_extends(&mut value);
553
554 if extends.is_empty() {
555 return Ok(value);
556 }
557
558 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
559 let sealed = value
560 .get("sealed")
561 .and_then(serde_json::Value::as_bool)
562 .unwrap_or(false);
563 let sealed_dir_canonical = if sealed {
566 Some(dunce::canonicalize(config_dir).map_err(|e| {
567 miette::miette!(
568 "Sealed config directory '{}' could not be canonicalized: {e}",
569 config_dir.display()
570 )
571 })?)
572 } else {
573 None
574 };
575 let mut merged = serde_json::Value::Object(serde_json::Map::new());
576
577 for extend_path_str in &extends {
578 let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
579 if sealed {
580 return Err(miette::miette!(
581 "'sealed: true' config at {} rejects URL extends '{}'. \
582 Sealed configs only allow file-relative extends within \
583 the config's directory",
584 path.display(),
585 extend_path_str
586 ));
587 }
588 resolve_url_extends(extend_path_str, visited, depth + 1)?
589 } else if extend_path_str.starts_with(HTTP_PREFIX) {
590 return Err(miette::miette!(
591 "URL extends must use https://, got http:// URL '{}' (in {}). \
592 Change the URL to use https:// instead",
593 extend_path_str,
594 path.display()
595 ));
596 } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
597 if sealed {
598 return Err(miette::miette!(
599 "'sealed: true' config at {} rejects npm extends '{}'. \
600 Sealed configs only allow file-relative extends within \
601 the config's directory",
602 path.display(),
603 extend_path_str
604 ));
605 }
606 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
607 resolve_extends_file(&npm_path, visited, depth + 1)?
608 } else {
609 if is_absolute_path_any_platform(Path::new(extend_path_str)) {
610 return Err(miette::miette!(
611 "extends paths must be relative, got absolute path: {} (in {})",
612 extend_path_str,
613 path.display()
614 ));
615 }
616 let p = config_dir.join(extend_path_str);
617 if !p.exists() {
618 return Err(miette::miette!(
619 "Extended config file not found: {} (referenced from {})",
620 p.display(),
621 path.display()
622 ));
623 }
624 if let Some(dir_canonical) = &sealed_dir_canonical {
625 let p_canonical = dunce::canonicalize(&p).map_err(|e| {
626 miette::miette!(
627 "Sealed config extends path '{}' could not be canonicalized: {e}",
628 p.display()
629 )
630 })?;
631 if !p_canonical.starts_with(dir_canonical) {
632 return Err(miette::miette!(
633 "'sealed: true' config at {} rejects extends '{}' which resolves \
634 outside the config's directory ({}). Sealed configs only allow \
635 extends within the config's directory",
636 path.display(),
637 extend_path_str,
638 p_canonical.display()
639 ));
640 }
641 }
642 resolve_extends_file(&p, visited, depth + 1)?
643 };
644 deep_merge_json(&mut merged, base);
645 }
646
647 deep_merge_json(&mut merged, value);
648 Ok(merged)
649}
650
651pub(super) fn resolve_extends(
655 path: &Path,
656 visited: &mut FxHashSet<String>,
657 depth: usize,
658) -> Result<serde_json::Value, miette::Report> {
659 resolve_extends_file(path, visited, depth)
660}
661
662pub(super) fn collect_unknown_rule_keys(
677 merged: &serde_json::Value,
678) -> Vec<super::rules::UnknownRuleKey> {
679 use super::rules::find_unknown_rule_keys;
680
681 let mut findings = Vec::new();
682
683 if let Some(rules) = merged.get("rules") {
684 findings.extend(find_unknown_rule_keys(rules, "rules"));
685 }
686
687 if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
688 for (i, entry) in overrides.iter().enumerate() {
689 if let Some(rules) = entry.get("rules") {
690 let context = format!("overrides[{i}].rules");
691 findings.extend(find_unknown_rule_keys(rules, &context));
692 }
693 }
694 }
695
696 findings
697}
698
699thread_local! {
700 #[cfg(test)]
706 static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
707 const { std::cell::RefCell::new(None) };
708}
709
710#[cfg(test)]
714pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
715 body: F,
716) -> (R, Vec<super::rules::UnknownRuleKey>) {
717 UNKNOWN_RULE_CAPTURE.with(|cell| {
718 *cell.borrow_mut() = Some(Vec::new());
719 });
720 let result = body();
721 let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
722 (result, findings)
723}
724
725fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
736 use std::sync::{Mutex, OnceLock};
737
738 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
739 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
740
741 let path_display = config_path.display().to_string();
742
743 for finding in collect_unknown_rule_keys(merged) {
744 let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
745 if let Ok(mut set) = warned.lock()
748 && !set.insert(dedupe_key)
749 {
750 continue;
751 }
752
753 #[cfg(test)]
754 UNKNOWN_RULE_CAPTURE.with(|cell| {
755 if let Some(buf) = cell.borrow_mut().as_mut() {
756 buf.push(finding.clone());
757 }
758 });
759
760 if let Some(suggestion) = finding.suggestion {
761 tracing::warn!(
762 "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
763 the rule will be ignored. A future release will reject unknown rule names.",
764 key = finding.key,
765 context = finding.context,
766 path = path_display,
767 );
768 } else {
769 tracing::warn!(
770 "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
771 A future release will reject unknown rule names.",
772 key = finding.key,
773 context = finding.context,
774 path = path_display,
775 );
776 }
777 }
778}
779
780fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
787 CONFIG_NAMES
788 .iter()
789 .skip(chosen_index + 1)
790 .filter(|name| dir.join(name).exists())
791 .copied()
792 .collect()
793}
794
795#[cfg(test)]
798type CoexistWarning = (String, Vec<String>);
799
800thread_local! {
801 #[cfg(test)]
808 static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
809 const { std::cell::RefCell::new(None) };
810}
811
812#[cfg(test)]
816pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
817 body: F,
818) -> (R, Vec<CoexistWarning>) {
819 COEXIST_CAPTURE.with(|cell| {
820 *cell.borrow_mut() = Some(Vec::new());
821 });
822 let result = body();
823 let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
824 (result, findings)
825}
826
827fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
841 use std::sync::{Mutex, OnceLock};
842
843 if shadowed.is_empty() {
844 return;
845 }
846
847 let chosen_name = chosen_path.file_name().map_or_else(
848 || chosen_path.display().to_string(),
849 |n| n.to_string_lossy().into_owned(),
850 );
851 let dir = chosen_path.parent().unwrap_or(chosen_path);
852
853 #[cfg(test)]
856 COEXIST_CAPTURE.with(|cell| {
857 if let Some(buf) = cell.borrow_mut().as_mut() {
858 buf.push((
859 chosen_name.clone(),
860 shadowed.iter().map(|s| (*s).to_owned()).collect(),
861 ));
862 }
863 });
864
865 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
866 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
867 let dedupe_key = std::fs::canonicalize(dir)
868 .unwrap_or_else(|_| dir.to_path_buf())
869 .display()
870 .to_string();
871 if let Ok(mut set) = warned.lock()
874 && !set.insert(dedupe_key)
875 {
876 return;
877 }
878
879 tracing::warn!(
880 "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
881 fallow uses the first match in precedence order \
882 (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
883 remove the unused file(s) to silence this warning.",
884 dir = dir.display(),
885 chosen = chosen_name,
886 shadowed = shadowed.join(", "),
887 );
888}
889
890impl FallowConfig {
891 pub fn load(path: &Path) -> Result<Self, miette::Report> {
913 let mut visited = FxHashSet::default();
914 let merged = resolve_extends(path, &mut visited, 0)?;
915
916 warn_on_unknown_rule_keys(path, &merged);
917
918 let config: Self = serde_json::from_value(merged).map_err(|e| {
919 miette::miette!(
920 "Failed to deserialize config from {}: {}",
921 path.display(),
922 e
923 )
924 })?;
925
926 config.validate_user_globs().map_err(|errors| {
931 let joined = errors
932 .iter()
933 .map(ToString::to_string)
934 .collect::<Vec<_>>()
935 .join("\n - ");
936 miette::miette!("invalid config:\n - {}", joined)
937 })?;
938
939 Ok(config)
940 }
941
942 pub fn validate_user_globs(
971 &self,
972 ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
973 use super::glob_validation::{
974 compile_user_glob, validate_user_globs, validate_user_path, validate_user_paths,
975 validate_user_specifier_globs,
976 };
977
978 let mut errors = Vec::new();
979
980 validate_user_globs(&self.entry, "entry", &mut errors);
981 validate_user_globs(&self.ignore_patterns, "ignorePatterns", &mut errors);
982 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", &mut errors);
983 validate_user_specifier_globs(
984 &self.ignore_unresolved_imports,
985 "ignoreUnresolvedImports",
986 &mut errors,
987 );
988 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", &mut errors);
989 validate_user_globs(&self.health.ignore, "health.ignore", &mut errors);
990
991 for override_entry in &self.overrides {
992 validate_user_globs(&override_entry.files, "overrides[].files", &mut errors);
993 }
994
995 for rule in &self.ignore_exports {
996 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
997 errors.push(e);
998 }
999 }
1000
1001 for rule in &self.ignore_catalog_references {
1002 if let Some(consumer) = &rule.consumer
1003 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1004 {
1005 errors.push(e);
1006 }
1007 }
1008
1009 for zone in &self.boundaries.zones {
1010 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", &mut errors);
1011 if let Some(root) = &zone.root
1012 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1013 {
1014 errors.push(e);
1015 }
1016 validate_user_paths(
1017 &zone.auto_discover,
1018 "boundaries.zones[].autoDiscover",
1019 &mut errors,
1020 );
1021 }
1022
1023 for plugin in &self.framework {
1031 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
1032 errors.append(&mut plugin_errors);
1033 }
1034 }
1035
1036 if errors.is_empty() {
1037 Ok(())
1038 } else {
1039 Err(errors)
1040 }
1041 }
1042
1043 #[must_use]
1046 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1047 let mut dir = start;
1048 loop {
1049 for name in CONFIG_NAMES {
1050 let candidate = dir.join(name);
1051 if candidate.exists() {
1052 return Some(candidate);
1053 }
1054 }
1055 if is_repo_root(dir) {
1056 break;
1057 }
1058 dir = dir.parent()?;
1059 }
1060 None
1061 }
1062
1063 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1069 let mut dir = start;
1070 loop {
1071 for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1072 let candidate = dir.join(name);
1073 if candidate.exists() {
1074 warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1075 match Self::load(&candidate) {
1076 Ok(config) => return Ok(Some((config, candidate))),
1077 Err(e) => {
1078 return Err(format!("Failed to parse {}: {e}", candidate.display()));
1079 }
1080 }
1081 }
1082 }
1083 if is_repo_root(dir) {
1087 break;
1088 }
1089 dir = match dir.parent() {
1090 Some(parent) => parent,
1091 None => break,
1092 };
1093 }
1094 Ok(None)
1095 }
1096
1097 #[must_use]
1099 pub fn json_schema() -> serde_json::Value {
1100 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1101 }
1102
1103 pub fn validate_resolved_boundaries(
1132 &self,
1133 root: &Path,
1134 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1135 use super::boundaries::ZoneValidationError;
1136
1137 let mut boundaries = self.boundaries.clone();
1140 if boundaries.preset.is_some() {
1141 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1145 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1146 .unwrap_or_else(|| "src".to_owned());
1147 boundaries.expand(&source_root);
1148 }
1149 let _logical_groups = boundaries.expand_auto_discover(root);
1150
1151 let mut errors: Vec<ZoneValidationError> = boundaries
1152 .validate_zone_references()
1153 .into_iter()
1154 .map(ZoneValidationError::UnknownZoneReference)
1155 .collect();
1156 errors.extend(
1157 boundaries
1158 .validate_root_prefixes()
1159 .into_iter()
1160 .map(ZoneValidationError::RedundantRootPrefix),
1161 );
1162
1163 if errors.is_empty() {
1164 Ok(())
1165 } else {
1166 Err(errors)
1167 }
1168 }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174 use crate::CacheConfig;
1175 use crate::PackageJson;
1176 use crate::config::format::OutputFormat;
1177 use crate::config::rules::Severity;
1178
1179 fn test_dir(_name: &str) -> tempfile::TempDir {
1181 tempfile::tempdir().expect("create temp dir")
1182 }
1183
1184 #[test]
1185 fn fallow_config_deserialize_minimal() {
1186 let toml_str = r#"
1187entry = ["src/main.ts"]
1188"#;
1189 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1190 assert_eq!(config.entry, vec!["src/main.ts"]);
1191 assert!(config.ignore_patterns.is_empty());
1192 }
1193
1194 #[test]
1195 fn fallow_config_deserialize_ignore_exports() {
1196 let toml_str = r#"
1197[[ignoreExports]]
1198file = "src/types/*.ts"
1199exports = ["*"]
1200
1201[[ignoreExports]]
1202file = "src/constants.ts"
1203exports = ["FOO", "BAR"]
1204"#;
1205 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1206 assert_eq!(config.ignore_exports.len(), 2);
1207 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1208 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1209 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1210 }
1211
1212 #[test]
1213 fn fallow_config_deserialize_ignore_dependencies() {
1214 let toml_str = r#"
1215ignoreDependencies = ["autoprefixer", "postcss"]
1216"#;
1217 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1218 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1219 }
1220
1221 #[test]
1222 fn fallow_config_deserialize_ignore_unresolved_imports() {
1223 let toml_str = r#"
1224ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1225"#;
1226 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1227 assert_eq!(
1228 config.ignore_unresolved_imports,
1229 vec!["@example/icons", "@example/icons/**", "../generated/**"]
1230 );
1231 }
1232
1233 #[test]
1234 fn fallow_config_resolve_default_ignores() {
1235 let config = FallowConfig::default();
1236 let resolved = config.resolve(
1237 PathBuf::from("/tmp/test"),
1238 OutputFormat::Human,
1239 4,
1240 true,
1241 true,
1242 None,
1243 );
1244
1245 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1247 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1248 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1249 assert!(resolved.ignore_patterns.is_match(".git/config"));
1250 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1251 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1252 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1253 }
1254
1255 #[test]
1256 fn fallow_config_resolve_custom_ignores() {
1257 let config = FallowConfig {
1258 entry: vec!["src/**/*.ts".to_string()],
1259 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1260 ..Default::default()
1261 };
1262 let resolved = config.resolve(
1263 PathBuf::from("/tmp/test"),
1264 OutputFormat::Json,
1265 4,
1266 false,
1267 true,
1268 None,
1269 );
1270
1271 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1272 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1273 assert!(matches!(resolved.output, OutputFormat::Json));
1274 assert!(!resolved.no_cache);
1275 }
1276
1277 #[test]
1278 fn fallow_config_resolve_cache_dir() {
1279 let config = FallowConfig::default();
1280 let resolved = config.resolve(
1281 PathBuf::from("/tmp/project"),
1282 OutputFormat::Human,
1283 4,
1284 true,
1285 true,
1286 None,
1287 );
1288 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1289 assert!(resolved.no_cache);
1290 }
1291
1292 #[test]
1293 fn package_json_entry_points_main() {
1294 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1295 let entries = pkg.entry_points();
1296 assert!(entries.contains(&"dist/index.js".to_string()));
1297 }
1298
1299 #[test]
1300 fn package_json_entry_points_module() {
1301 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1302 let entries = pkg.entry_points();
1303 assert!(entries.contains(&"dist/index.mjs".to_string()));
1304 }
1305
1306 #[test]
1307 fn package_json_entry_points_types() {
1308 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1309 let entries = pkg.entry_points();
1310 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1311 }
1312
1313 #[test]
1314 fn package_json_entry_points_bin_string() {
1315 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1316 let entries = pkg.entry_points();
1317 assert!(entries.contains(&"bin/cli.js".to_string()));
1318 }
1319
1320 #[test]
1321 fn package_json_entry_points_bin_object() {
1322 let pkg: PackageJson =
1323 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1324 .unwrap();
1325 let entries = pkg.entry_points();
1326 assert!(entries.contains(&"bin/cli.js".to_string()));
1327 assert!(entries.contains(&"bin/serve.js".to_string()));
1328 }
1329
1330 #[test]
1331 fn package_json_entry_points_exports_string() {
1332 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1333 let entries = pkg.entry_points();
1334 assert!(entries.contains(&"./dist/index.js".to_string()));
1335 }
1336
1337 #[test]
1338 fn package_json_entry_points_exports_object() {
1339 let pkg: PackageJson = serde_json::from_str(
1340 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1341 )
1342 .unwrap();
1343 let entries = pkg.entry_points();
1344 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1345 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1346 }
1347
1348 #[test]
1349 fn package_json_dependency_names() {
1350 let pkg: PackageJson = serde_json::from_str(
1351 r#"{
1352 "dependencies": {"react": "^18", "lodash": "^4"},
1353 "devDependencies": {"typescript": "^5"},
1354 "peerDependencies": {"react-dom": "^18"}
1355 }"#,
1356 )
1357 .unwrap();
1358
1359 let all = pkg.all_dependency_names();
1360 assert!(all.contains(&"react".to_string()));
1361 assert!(all.contains(&"lodash".to_string()));
1362 assert!(all.contains(&"typescript".to_string()));
1363 assert!(all.contains(&"react-dom".to_string()));
1364
1365 let prod = pkg.production_dependency_names();
1366 assert!(prod.contains(&"react".to_string()));
1367 assert!(!prod.contains(&"typescript".to_string()));
1368
1369 let dev = pkg.dev_dependency_names();
1370 assert!(dev.contains(&"typescript".to_string()));
1371 assert!(!dev.contains(&"react".to_string()));
1372 }
1373
1374 #[test]
1375 fn package_json_no_dependencies() {
1376 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1377 assert!(pkg.all_dependency_names().is_empty());
1378 assert!(pkg.production_dependency_names().is_empty());
1379 assert!(pkg.dev_dependency_names().is_empty());
1380 assert!(pkg.entry_points().is_empty());
1381 }
1382
1383 #[test]
1384 fn rules_deserialize_toml_kebab_case() {
1385 let toml_str = r#"
1386[rules]
1387unused-files = "error"
1388unused-exports = "warn"
1389unused-types = "off"
1390"#;
1391 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1392 assert_eq!(config.rules.unused_files, Severity::Error);
1393 assert_eq!(config.rules.unused_exports, Severity::Warn);
1394 assert_eq!(config.rules.unused_types, Severity::Off);
1395 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1397 }
1398
1399 #[test]
1400 fn config_without_rules_defaults_to_error() {
1401 let toml_str = r#"
1402entry = ["src/main.ts"]
1403"#;
1404 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1405 assert_eq!(config.rules.unused_files, Severity::Error);
1406 assert_eq!(config.rules.unused_exports, Severity::Error);
1407 }
1408
1409 #[test]
1410 fn fallow_config_denies_unknown_fields() {
1411 let toml_str = r"
1412unknown_field = true
1413";
1414 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1415 assert!(result.is_err());
1416 }
1417
1418 #[test]
1419 fn fallow_config_deserialize_json() {
1420 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1421 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1422 assert_eq!(config.entry, vec!["src/main.ts"]);
1423 }
1424
1425 #[test]
1426 fn fallow_config_deserialize_jsonc() {
1427 let jsonc_str = r#"{
1428 // This is a comment
1429 "entry": ["src/main.ts"],
1430 "rules": {
1431 "unused-files": "warn"
1432 }
1433 }"#;
1434 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1435 assert_eq!(config.entry, vec!["src/main.ts"]);
1436 assert_eq!(config.rules.unused_files, Severity::Warn);
1437 }
1438
1439 #[test]
1440 fn fallow_config_json_with_schema_field() {
1441 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1442 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1443 assert_eq!(config.entry, vec!["src/main.ts"]);
1444 }
1445
1446 #[test]
1447 fn fallow_config_json_schema_generation() {
1448 let schema = FallowConfig::json_schema();
1449 assert!(schema.is_object());
1450 let obj = schema.as_object().unwrap();
1451 assert!(obj.contains_key("properties"));
1452 }
1453
1454 #[test]
1455 fn config_format_detection() {
1456 assert!(matches!(
1457 ConfigFormat::from_path(Path::new("fallow.toml")),
1458 ConfigFormat::Toml
1459 ));
1460 assert!(matches!(
1461 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1462 ConfigFormat::Json
1463 ));
1464 assert!(matches!(
1465 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1466 ConfigFormat::Json
1467 ));
1468 assert!(matches!(
1469 ConfigFormat::from_path(Path::new(".fallow.toml")),
1470 ConfigFormat::Toml
1471 ));
1472 }
1473
1474 #[test]
1475 fn config_names_priority_order() {
1476 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1477 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1478 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1479 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1480 }
1481
1482 #[test]
1483 fn load_json_config_file() {
1484 let dir = test_dir("json-config");
1485 let config_path = dir.path().join(".fallowrc.json");
1486 std::fs::write(
1487 &config_path,
1488 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1489 )
1490 .unwrap();
1491
1492 let config = FallowConfig::load(&config_path).unwrap();
1493 assert_eq!(config.entry, vec!["src/index.ts"]);
1494 assert_eq!(config.rules.unused_exports, Severity::Warn);
1495 }
1496
1497 #[test]
1498 fn load_jsonc_config_file() {
1499 let dir = test_dir("jsonc-config");
1500 let config_path = dir.path().join(".fallowrc.json");
1501 std::fs::write(
1502 &config_path,
1503 r#"{
1504 // Entry points for analysis
1505 "entry": ["src/index.ts"],
1506 /* Block comment */
1507 "rules": {
1508 "unused-exports": "warn"
1509 }
1510 }"#,
1511 )
1512 .unwrap();
1513
1514 let config = FallowConfig::load(&config_path).unwrap();
1515 assert_eq!(config.entry, vec!["src/index.ts"]);
1516 assert_eq!(config.rules.unused_exports, Severity::Warn);
1517 }
1518
1519 #[test]
1520 fn load_fallowrc_jsonc_extension() {
1521 let dir = test_dir("jsonc-extension");
1522 let config_path = dir.path().join(".fallowrc.jsonc");
1523 std::fs::write(
1524 &config_path,
1525 r#"{
1526 // editors that recognize the .jsonc extension show
1527 // proper JSON-with-comments syntax highlighting
1528 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1529 "entry": ["src/index.ts"]
1530 }"#,
1531 )
1532 .unwrap();
1533
1534 let config = FallowConfig::load(&config_path).unwrap();
1535 assert_eq!(config.entry, vec!["src/index.ts"]);
1536 assert_eq!(
1537 config.ignore_dependencies,
1538 vec!["tailwindcss-react-aria-components"]
1539 );
1540 }
1541
1542 #[test]
1543 fn json_config_ignore_dependencies_camel_case() {
1544 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1545 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1546 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1547 }
1548
1549 #[test]
1550 fn json_config_ignore_unresolved_imports_camel_case() {
1551 let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1552 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1553 assert_eq!(
1554 config.ignore_unresolved_imports,
1555 vec!["@example/icons", "@example/icons/**"]
1556 );
1557 }
1558
1559 #[test]
1560 fn json_config_all_fields() {
1561 let json_str = r#"{
1562 "ignoreDependencies": ["lodash"],
1563 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1564 "rules": {
1565 "unused-files": "off",
1566 "unused-exports": "warn",
1567 "unused-dependencies": "error",
1568 "unused-dev-dependencies": "off",
1569 "unused-types": "warn",
1570 "unused-enum-members": "error",
1571 "unused-class-members": "off",
1572 "unresolved-imports": "warn",
1573 "unlisted-dependencies": "error",
1574 "duplicate-exports": "off"
1575 },
1576 "duplicates": {
1577 "minTokens": 100,
1578 "minLines": 10,
1579 "skipLocal": true
1580 }
1581 }"#;
1582 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1583 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1584 assert_eq!(config.rules.unused_files, Severity::Off);
1585 assert_eq!(config.rules.unused_exports, Severity::Warn);
1586 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1587 assert_eq!(config.duplicates.min_tokens, 100);
1588 assert_eq!(config.duplicates.min_lines, 10);
1589 assert!(config.duplicates.skip_local);
1590 }
1591
1592 #[test]
1595 fn extends_single_base() {
1596 let dir = test_dir("extends-single");
1597
1598 std::fs::write(
1599 dir.path().join("base.json"),
1600 r#"{"rules": {"unused-files": "warn"}}"#,
1601 )
1602 .unwrap();
1603 std::fs::write(
1604 dir.path().join(".fallowrc.json"),
1605 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1606 )
1607 .unwrap();
1608
1609 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1610 assert_eq!(config.rules.unused_files, Severity::Warn);
1611 assert_eq!(config.entry, vec!["src/index.ts"]);
1612 assert_eq!(config.rules.unused_exports, Severity::Error);
1614 }
1615
1616 #[test]
1617 fn extends_overlay_overrides_base() {
1618 let dir = test_dir("extends-overlay");
1619
1620 std::fs::write(
1621 dir.path().join("base.json"),
1622 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1623 )
1624 .unwrap();
1625 std::fs::write(
1626 dir.path().join(".fallowrc.json"),
1627 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1628 )
1629 .unwrap();
1630
1631 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1632 assert_eq!(config.rules.unused_files, Severity::Error);
1634 assert_eq!(config.rules.unused_exports, Severity::Off);
1636 }
1637
1638 #[test]
1639 fn extends_chained() {
1640 let dir = test_dir("extends-chained");
1641
1642 std::fs::write(
1643 dir.path().join("grandparent.json"),
1644 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1645 )
1646 .unwrap();
1647 std::fs::write(
1648 dir.path().join("parent.json"),
1649 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1650 )
1651 .unwrap();
1652 std::fs::write(
1653 dir.path().join(".fallowrc.json"),
1654 r#"{"extends": ["parent.json"]}"#,
1655 )
1656 .unwrap();
1657
1658 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1659 assert_eq!(config.rules.unused_files, Severity::Warn);
1661 assert_eq!(config.rules.unused_exports, Severity::Warn);
1663 }
1664
1665 #[test]
1666 fn extends_circular_detected() {
1667 let dir = test_dir("extends-circular");
1668
1669 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1670 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1671
1672 let result = FallowConfig::load(&dir.path().join("a.json"));
1673 assert!(result.is_err());
1674 let err_msg = format!("{}", result.unwrap_err());
1675 assert!(
1676 err_msg.contains("Circular extends"),
1677 "Expected circular error, got: {err_msg}"
1678 );
1679 }
1680
1681 #[test]
1682 fn extends_missing_file_errors() {
1683 let dir = test_dir("extends-missing");
1684
1685 std::fs::write(
1686 dir.path().join(".fallowrc.json"),
1687 r#"{"extends": ["nonexistent.json"]}"#,
1688 )
1689 .unwrap();
1690
1691 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1692 assert!(result.is_err());
1693 let err_msg = format!("{}", result.unwrap_err());
1694 assert!(
1695 err_msg.contains("not found"),
1696 "Expected not found error, got: {err_msg}"
1697 );
1698 }
1699
1700 #[test]
1703 fn sealed_allows_in_directory_extends() {
1704 let dir = test_dir("sealed-allows-local");
1705 std::fs::write(
1706 dir.path().join("base.json"),
1707 r#"{"ignorePatterns": ["gen/**"]}"#,
1708 )
1709 .unwrap();
1710 std::fs::write(
1711 dir.path().join(".fallowrc.json"),
1712 r#"{"sealed": true, "extends": ["./base.json"]}"#,
1713 )
1714 .unwrap();
1715
1716 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1717 assert!(config.sealed);
1718 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1719 }
1720
1721 #[test]
1722 fn sealed_rejects_extends_escaping_directory() {
1723 let dir = test_dir("sealed-rejects-escape");
1724 let sub = dir.path().join("packages").join("app");
1725 std::fs::create_dir_all(&sub).unwrap();
1726
1727 std::fs::write(
1729 dir.path().join("base.json"),
1730 r#"{"ignorePatterns": ["dist/**"]}"#,
1731 )
1732 .unwrap();
1733 std::fs::write(
1734 sub.join(".fallowrc.json"),
1735 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1736 )
1737 .unwrap();
1738
1739 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1740 assert!(
1741 result.is_err(),
1742 "Expected sealed config to reject escaping extends"
1743 );
1744 let err_msg = format!("{}", result.unwrap_err());
1745 assert!(
1746 err_msg.contains("sealed"),
1747 "Error must mention sealed: {err_msg}"
1748 );
1749 assert!(
1750 err_msg.contains("outside the config's directory"),
1751 "Error must explain the constraint: {err_msg}"
1752 );
1753 }
1754
1755 #[test]
1756 fn sealed_rejects_https_extends() {
1757 let dir = test_dir("sealed-rejects-https");
1758 std::fs::write(
1759 dir.path().join(".fallowrc.json"),
1760 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1761 )
1762 .unwrap();
1763
1764 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1765 assert!(result.is_err());
1766 let err_msg = format!("{}", result.unwrap_err());
1767 assert!(
1768 err_msg.contains("sealed"),
1769 "Error must mention sealed: {err_msg}"
1770 );
1771 assert!(
1772 err_msg.contains("URL extends"),
1773 "Error must mention URL: {err_msg}"
1774 );
1775 }
1776
1777 #[test]
1778 fn sealed_rejects_npm_extends() {
1779 let dir = test_dir("sealed-rejects-npm");
1780 std::fs::write(
1781 dir.path().join(".fallowrc.json"),
1782 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1783 )
1784 .unwrap();
1785
1786 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1787 assert!(result.is_err());
1788 let err_msg = format!("{}", result.unwrap_err());
1789 assert!(
1790 err_msg.contains("sealed"),
1791 "Error must mention sealed: {err_msg}"
1792 );
1793 assert!(
1794 err_msg.contains("npm extends"),
1795 "Error must mention npm: {err_msg}"
1796 );
1797 }
1798
1799 #[test]
1800 fn sealed_default_is_false() {
1801 let dir = test_dir("sealed-default");
1802 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1803 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1804 assert!(!config.sealed);
1805 }
1806
1807 #[test]
1808 fn sealed_false_allows_escaping_extends() {
1809 let dir = test_dir("sealed-false-allows");
1811 let sub = dir.path().join("packages").join("app");
1812 std::fs::create_dir_all(&sub).unwrap();
1813
1814 std::fs::write(
1815 dir.path().join("base.json"),
1816 r#"{"ignorePatterns": ["dist/**"]}"#,
1817 )
1818 .unwrap();
1819 std::fs::write(
1820 sub.join(".fallowrc.json"),
1821 r#"{"extends": ["../../base.json"]}"#,
1822 )
1823 .unwrap();
1824
1825 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1826 assert!(!config.sealed);
1827 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1828 }
1829
1830 #[test]
1831 fn extends_string_sugar() {
1832 let dir = test_dir("extends-string");
1833
1834 std::fs::write(
1835 dir.path().join("base.json"),
1836 r#"{"ignorePatterns": ["gen/**"]}"#,
1837 )
1838 .unwrap();
1839 std::fs::write(
1841 dir.path().join(".fallowrc.json"),
1842 r#"{"extends": "base.json"}"#,
1843 )
1844 .unwrap();
1845
1846 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1847 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1848 }
1849
1850 #[test]
1851 fn extends_deep_merge_preserves_arrays() {
1852 let dir = test_dir("extends-array");
1853
1854 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1855 std::fs::write(
1856 dir.path().join(".fallowrc.json"),
1857 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1858 )
1859 .unwrap();
1860
1861 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1862 assert_eq!(config.entry, vec!["src/b.ts"]);
1864 }
1865
1866 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1870 let pkg_dir = root.join("node_modules").join(name);
1871 std::fs::create_dir_all(&pkg_dir).unwrap();
1872 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1873 }
1874
1875 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1877 let pkg_dir = root.join("node_modules").join(name);
1878 std::fs::create_dir_all(&pkg_dir).unwrap();
1879 std::fs::write(
1880 pkg_dir.join("package.json"),
1881 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1882 )
1883 .unwrap();
1884 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1885 }
1886
1887 #[test]
1888 fn extends_npm_basic_unscoped() {
1889 let dir = test_dir("npm-basic");
1890 create_npm_package(
1891 dir.path(),
1892 "fallow-config-acme",
1893 r#"{"rules": {"unused-files": "warn"}}"#,
1894 );
1895 std::fs::write(
1896 dir.path().join(".fallowrc.json"),
1897 r#"{"extends": "npm:fallow-config-acme"}"#,
1898 )
1899 .unwrap();
1900
1901 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1902 assert_eq!(config.rules.unused_files, Severity::Warn);
1903 }
1904
1905 #[test]
1906 fn extends_npm_scoped_package() {
1907 let dir = test_dir("npm-scoped");
1908 create_npm_package(
1909 dir.path(),
1910 "@company/fallow-config",
1911 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1912 );
1913 std::fs::write(
1914 dir.path().join(".fallowrc.json"),
1915 r#"{"extends": "npm:@company/fallow-config"}"#,
1916 )
1917 .unwrap();
1918
1919 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1920 assert_eq!(config.rules.unused_exports, Severity::Off);
1921 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1922 }
1923
1924 #[test]
1925 fn extends_npm_with_subpath() {
1926 let dir = test_dir("npm-subpath");
1927 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1928 std::fs::create_dir_all(&pkg_dir).unwrap();
1929 std::fs::write(
1930 pkg_dir.join("strict.json"),
1931 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1932 )
1933 .unwrap();
1934
1935 std::fs::write(
1936 dir.path().join(".fallowrc.json"),
1937 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1938 )
1939 .unwrap();
1940
1941 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1942 assert_eq!(config.rules.unused_files, Severity::Error);
1943 assert_eq!(config.rules.unused_exports, Severity::Error);
1944 }
1945
1946 #[test]
1947 fn extends_npm_package_json_main() {
1948 let dir = test_dir("npm-main");
1949 create_npm_package_with_main(
1950 dir.path(),
1951 "fallow-config-acme",
1952 "config.json",
1953 r#"{"rules": {"unused-types": "off"}}"#,
1954 );
1955 std::fs::write(
1956 dir.path().join(".fallowrc.json"),
1957 r#"{"extends": "npm:fallow-config-acme"}"#,
1958 )
1959 .unwrap();
1960
1961 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1962 assert_eq!(config.rules.unused_types, Severity::Off);
1963 }
1964
1965 #[test]
1966 fn extends_npm_package_json_exports_string() {
1967 let dir = test_dir("npm-exports-str");
1968 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1969 std::fs::create_dir_all(&pkg_dir).unwrap();
1970 std::fs::write(
1971 pkg_dir.join("package.json"),
1972 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1973 )
1974 .unwrap();
1975 std::fs::write(
1976 pkg_dir.join("base.json"),
1977 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1978 )
1979 .unwrap();
1980
1981 std::fs::write(
1982 dir.path().join(".fallowrc.json"),
1983 r#"{"extends": "npm:fallow-config-co"}"#,
1984 )
1985 .unwrap();
1986
1987 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1988 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1989 }
1990
1991 #[test]
1992 fn extends_npm_package_json_exports_object() {
1993 let dir = test_dir("npm-exports-obj");
1994 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1995 std::fs::create_dir_all(&pkg_dir).unwrap();
1996 std::fs::write(
1997 pkg_dir.join("package.json"),
1998 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1999 )
2000 .unwrap();
2001 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2002
2003 std::fs::write(
2004 dir.path().join(".fallowrc.json"),
2005 r#"{"extends": "npm:@co/cfg"}"#,
2006 )
2007 .unwrap();
2008
2009 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2010 assert_eq!(config.entry, vec!["src/app.ts"]);
2011 }
2012
2013 #[test]
2014 fn extends_npm_exports_takes_priority_over_main() {
2015 let dir = test_dir("npm-exports-prio");
2016 let pkg_dir = dir.path().join("node_modules/my-config");
2017 std::fs::create_dir_all(&pkg_dir).unwrap();
2018 std::fs::write(
2019 pkg_dir.join("package.json"),
2020 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2021 )
2022 .unwrap();
2023 std::fs::write(
2024 pkg_dir.join("old.json"),
2025 r#"{"rules": {"unused-files": "off"}}"#,
2026 )
2027 .unwrap();
2028 std::fs::write(
2029 pkg_dir.join("new.json"),
2030 r#"{"rules": {"unused-files": "warn"}}"#,
2031 )
2032 .unwrap();
2033
2034 std::fs::write(
2035 dir.path().join(".fallowrc.json"),
2036 r#"{"extends": "npm:my-config"}"#,
2037 )
2038 .unwrap();
2039
2040 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2041 assert_eq!(config.rules.unused_files, Severity::Warn);
2043 }
2044
2045 #[test]
2046 fn extends_npm_walk_up_directories() {
2047 let dir = test_dir("npm-walkup");
2048 create_npm_package(
2050 dir.path(),
2051 "shared-config",
2052 r#"{"rules": {"unused-files": "warn"}}"#,
2053 );
2054 let sub = dir.path().join("packages/app");
2056 std::fs::create_dir_all(&sub).unwrap();
2057 std::fs::write(
2058 sub.join(".fallowrc.json"),
2059 r#"{"extends": "npm:shared-config"}"#,
2060 )
2061 .unwrap();
2062
2063 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2064 assert_eq!(config.rules.unused_files, Severity::Warn);
2065 }
2066
2067 #[test]
2068 fn extends_npm_overlay_overrides_base() {
2069 let dir = test_dir("npm-overlay");
2070 create_npm_package(
2071 dir.path(),
2072 "@company/base",
2073 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2074 );
2075 std::fs::write(
2076 dir.path().join(".fallowrc.json"),
2077 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2078 )
2079 .unwrap();
2080
2081 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2082 assert_eq!(config.rules.unused_files, Severity::Error);
2083 assert_eq!(config.rules.unused_exports, Severity::Off);
2084 assert_eq!(config.entry, vec!["src/app.ts"]);
2085 }
2086
2087 #[test]
2088 fn extends_npm_chained_with_relative() {
2089 let dir = test_dir("npm-chained");
2090 let pkg_dir = dir.path().join("node_modules/my-config");
2092 std::fs::create_dir_all(&pkg_dir).unwrap();
2093 std::fs::write(
2094 pkg_dir.join("base.json"),
2095 r#"{"rules": {"unused-files": "warn"}}"#,
2096 )
2097 .unwrap();
2098 std::fs::write(
2099 pkg_dir.join(".fallowrc.json"),
2100 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2101 )
2102 .unwrap();
2103
2104 std::fs::write(
2105 dir.path().join(".fallowrc.json"),
2106 r#"{"extends": "npm:my-config"}"#,
2107 )
2108 .unwrap();
2109
2110 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2111 assert_eq!(config.rules.unused_files, Severity::Warn);
2112 assert_eq!(config.rules.unused_exports, Severity::Off);
2113 }
2114
2115 #[test]
2116 fn extends_npm_mixed_with_relative_paths() {
2117 let dir = test_dir("npm-mixed");
2118 create_npm_package(
2119 dir.path(),
2120 "shared-base",
2121 r#"{"rules": {"unused-files": "off"}}"#,
2122 );
2123 std::fs::write(
2124 dir.path().join("local-overrides.json"),
2125 r#"{"rules": {"unused-files": "warn"}}"#,
2126 )
2127 .unwrap();
2128 std::fs::write(
2129 dir.path().join(".fallowrc.json"),
2130 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2131 )
2132 .unwrap();
2133
2134 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2135 assert_eq!(config.rules.unused_files, Severity::Warn);
2137 }
2138
2139 #[test]
2140 fn extends_npm_missing_package_errors() {
2141 let dir = test_dir("npm-missing");
2142 std::fs::write(
2143 dir.path().join(".fallowrc.json"),
2144 r#"{"extends": "npm:nonexistent-package"}"#,
2145 )
2146 .unwrap();
2147
2148 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2149 assert!(result.is_err());
2150 let err_msg = format!("{}", result.unwrap_err());
2151 assert!(
2152 err_msg.contains("not found"),
2153 "Expected 'not found' error, got: {err_msg}"
2154 );
2155 assert!(
2156 err_msg.contains("nonexistent-package"),
2157 "Expected package name in error, got: {err_msg}"
2158 );
2159 assert!(
2160 err_msg.contains("install it"),
2161 "Expected install hint in error, got: {err_msg}"
2162 );
2163 }
2164
2165 #[test]
2166 fn extends_npm_no_config_in_package_errors() {
2167 let dir = test_dir("npm-no-config");
2168 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2169 std::fs::create_dir_all(&pkg_dir).unwrap();
2170 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2172
2173 std::fs::write(
2174 dir.path().join(".fallowrc.json"),
2175 r#"{"extends": "npm:empty-pkg"}"#,
2176 )
2177 .unwrap();
2178
2179 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2180 assert!(result.is_err());
2181 let err_msg = format!("{}", result.unwrap_err());
2182 assert!(
2183 err_msg.contains("No fallow config found"),
2184 "Expected 'No fallow config found' error, got: {err_msg}"
2185 );
2186 }
2187
2188 #[test]
2189 fn extends_npm_missing_subpath_errors() {
2190 let dir = test_dir("npm-missing-sub");
2191 let pkg_dir = dir.path().join("node_modules/@co/config");
2192 std::fs::create_dir_all(&pkg_dir).unwrap();
2193
2194 std::fs::write(
2195 dir.path().join(".fallowrc.json"),
2196 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2197 )
2198 .unwrap();
2199
2200 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2201 assert!(result.is_err());
2202 let err_msg = format!("{}", result.unwrap_err());
2203 assert!(
2204 err_msg.contains("nonexistent.json"),
2205 "Expected subpath in error, got: {err_msg}"
2206 );
2207 }
2208
2209 #[test]
2210 fn extends_npm_empty_specifier_errors() {
2211 let dir = test_dir("npm-empty");
2212 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2213
2214 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2215 assert!(result.is_err());
2216 let err_msg = format!("{}", result.unwrap_err());
2217 assert!(
2218 err_msg.contains("Empty npm specifier"),
2219 "Expected 'Empty npm specifier' error, got: {err_msg}"
2220 );
2221 }
2222
2223 #[test]
2224 fn extends_npm_space_after_colon_trimmed() {
2225 let dir = test_dir("npm-space");
2226 create_npm_package(
2227 dir.path(),
2228 "fallow-config-acme",
2229 r#"{"rules": {"unused-files": "warn"}}"#,
2230 );
2231 std::fs::write(
2233 dir.path().join(".fallowrc.json"),
2234 r#"{"extends": "npm: fallow-config-acme"}"#,
2235 )
2236 .unwrap();
2237
2238 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2239 assert_eq!(config.rules.unused_files, Severity::Warn);
2240 }
2241
2242 #[test]
2243 fn extends_npm_exports_node_condition() {
2244 let dir = test_dir("npm-node-cond");
2245 let pkg_dir = dir.path().join("node_modules/node-config");
2246 std::fs::create_dir_all(&pkg_dir).unwrap();
2247 std::fs::write(
2248 pkg_dir.join("package.json"),
2249 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2250 )
2251 .unwrap();
2252 std::fs::write(
2253 pkg_dir.join("node.json"),
2254 r#"{"rules": {"unused-files": "off"}}"#,
2255 )
2256 .unwrap();
2257
2258 std::fs::write(
2259 dir.path().join(".fallowrc.json"),
2260 r#"{"extends": "npm:node-config"}"#,
2261 )
2262 .unwrap();
2263
2264 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2265 assert_eq!(config.rules.unused_files, Severity::Off);
2266 }
2267
2268 #[test]
2271 fn parse_npm_specifier_unscoped() {
2272 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2273 }
2274
2275 #[test]
2276 fn parse_npm_specifier_unscoped_with_subpath() {
2277 assert_eq!(
2278 parse_npm_specifier("my-config/strict.json"),
2279 ("my-config", Some("strict.json"))
2280 );
2281 }
2282
2283 #[test]
2284 fn parse_npm_specifier_scoped() {
2285 assert_eq!(
2286 parse_npm_specifier("@company/fallow-config"),
2287 ("@company/fallow-config", None)
2288 );
2289 }
2290
2291 #[test]
2292 fn parse_npm_specifier_scoped_with_subpath() {
2293 assert_eq!(
2294 parse_npm_specifier("@company/fallow-config/strict.json"),
2295 ("@company/fallow-config", Some("strict.json"))
2296 );
2297 }
2298
2299 #[test]
2300 fn parse_npm_specifier_scoped_with_nested_subpath() {
2301 assert_eq!(
2302 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2303 ("@company/fallow-config", Some("presets/strict.json"))
2304 );
2305 }
2306
2307 #[test]
2310 fn extends_npm_subpath_traversal_rejected() {
2311 let dir = test_dir("npm-traversal-sub");
2312 let pkg_dir = dir.path().join("node_modules/evil-pkg");
2313 std::fs::create_dir_all(&pkg_dir).unwrap();
2314 std::fs::write(
2316 dir.path().join("secret.json"),
2317 r#"{"entry": ["stolen.ts"]}"#,
2318 )
2319 .unwrap();
2320
2321 std::fs::write(
2322 dir.path().join(".fallowrc.json"),
2323 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2324 )
2325 .unwrap();
2326
2327 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2328 assert!(result.is_err());
2329 let err_msg = format!("{}", result.unwrap_err());
2330 assert!(
2331 err_msg.contains("traversal") || err_msg.contains("not found"),
2332 "Expected traversal or not-found error, got: {err_msg}"
2333 );
2334 }
2335
2336 #[test]
2337 fn extends_npm_dotdot_package_name_rejected() {
2338 let dir = test_dir("npm-dotdot-name");
2339 std::fs::write(
2340 dir.path().join(".fallowrc.json"),
2341 r#"{"extends": "npm:../relative"}"#,
2342 )
2343 .unwrap();
2344
2345 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2346 assert!(result.is_err());
2347 let err_msg = format!("{}", result.unwrap_err());
2348 assert!(
2349 err_msg.contains("path traversal"),
2350 "Expected 'path traversal' error, got: {err_msg}"
2351 );
2352 }
2353
2354 #[test]
2355 fn extends_npm_scoped_without_name_rejected() {
2356 let dir = test_dir("npm-scope-only");
2357 std::fs::write(
2358 dir.path().join(".fallowrc.json"),
2359 r#"{"extends": "npm:@scope"}"#,
2360 )
2361 .unwrap();
2362
2363 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2364 assert!(result.is_err());
2365 let err_msg = format!("{}", result.unwrap_err());
2366 assert!(
2367 err_msg.contains("@scope/name"),
2368 "Expected scoped name format error, got: {err_msg}"
2369 );
2370 }
2371
2372 #[test]
2373 fn extends_npm_malformed_package_json_errors() {
2374 let dir = test_dir("npm-bad-pkgjson");
2375 let pkg_dir = dir.path().join("node_modules/bad-pkg");
2376 std::fs::create_dir_all(&pkg_dir).unwrap();
2377 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2378
2379 std::fs::write(
2380 dir.path().join(".fallowrc.json"),
2381 r#"{"extends": "npm:bad-pkg"}"#,
2382 )
2383 .unwrap();
2384
2385 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2386 assert!(result.is_err());
2387 let err_msg = format!("{}", result.unwrap_err());
2388 assert!(
2389 err_msg.contains("Failed to parse"),
2390 "Expected parse error, got: {err_msg}"
2391 );
2392 }
2393
2394 #[test]
2395 fn extends_npm_exports_traversal_rejected() {
2396 let dir = test_dir("npm-exports-escape");
2397 let pkg_dir = dir.path().join("node_modules/evil-exports");
2398 std::fs::create_dir_all(&pkg_dir).unwrap();
2399 std::fs::write(
2400 pkg_dir.join("package.json"),
2401 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2402 )
2403 .unwrap();
2404 std::fs::write(
2406 dir.path().join("secret.json"),
2407 r#"{"entry": ["stolen.ts"]}"#,
2408 )
2409 .unwrap();
2410
2411 std::fs::write(
2412 dir.path().join(".fallowrc.json"),
2413 r#"{"extends": "npm:evil-exports"}"#,
2414 )
2415 .unwrap();
2416
2417 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2418 assert!(result.is_err());
2419 let err_msg = format!("{}", result.unwrap_err());
2420 assert!(
2421 err_msg.contains("traversal"),
2422 "Expected traversal error, got: {err_msg}"
2423 );
2424 }
2425
2426 #[test]
2429 fn deep_merge_scalar_overlay_replaces_base() {
2430 let mut base = serde_json::json!("hello");
2431 deep_merge_json(&mut base, serde_json::json!("world"));
2432 assert_eq!(base, serde_json::json!("world"));
2433 }
2434
2435 #[test]
2436 fn deep_merge_array_overlay_replaces_base() {
2437 let mut base = serde_json::json!(["a", "b"]);
2438 deep_merge_json(&mut base, serde_json::json!(["c"]));
2439 assert_eq!(base, serde_json::json!(["c"]));
2440 }
2441
2442 #[test]
2443 fn deep_merge_nested_object_merge() {
2444 let mut base = serde_json::json!({
2445 "level1": {
2446 "level2": {
2447 "a": 1,
2448 "b": 2
2449 }
2450 }
2451 });
2452 let overlay = serde_json::json!({
2453 "level1": {
2454 "level2": {
2455 "b": 99,
2456 "c": 3
2457 }
2458 }
2459 });
2460 deep_merge_json(&mut base, overlay);
2461 assert_eq!(base["level1"]["level2"]["a"], 1);
2462 assert_eq!(base["level1"]["level2"]["b"], 99);
2463 assert_eq!(base["level1"]["level2"]["c"], 3);
2464 }
2465
2466 #[test]
2467 fn deep_merge_overlay_adds_new_fields() {
2468 let mut base = serde_json::json!({"existing": true});
2469 let overlay = serde_json::json!({"new_field": "added", "another": 42});
2470 deep_merge_json(&mut base, overlay);
2471 assert_eq!(base["existing"], true);
2472 assert_eq!(base["new_field"], "added");
2473 assert_eq!(base["another"], 42);
2474 }
2475
2476 #[test]
2477 fn deep_merge_null_overlay_replaces_object() {
2478 let mut base = serde_json::json!({"key": "value"});
2479 deep_merge_json(&mut base, serde_json::json!(null));
2480 assert_eq!(base, serde_json::json!(null));
2481 }
2482
2483 #[test]
2484 fn deep_merge_empty_object_overlay_preserves_base() {
2485 let mut base = serde_json::json!({"a": 1, "b": 2});
2486 deep_merge_json(&mut base, serde_json::json!({}));
2487 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2488 }
2489
2490 #[test]
2493 fn rules_severity_error_warn_off_from_json() {
2494 let json_str = r#"{
2495 "rules": {
2496 "unused-files": "error",
2497 "unused-exports": "warn",
2498 "unused-types": "off"
2499 }
2500 }"#;
2501 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2502 assert_eq!(config.rules.unused_files, Severity::Error);
2503 assert_eq!(config.rules.unused_exports, Severity::Warn);
2504 assert_eq!(config.rules.unused_types, Severity::Off);
2505 }
2506
2507 #[test]
2508 fn rules_omitted_default_to_error() {
2509 let json_str = r#"{
2510 "rules": {
2511 "unused-files": "warn"
2512 }
2513 }"#;
2514 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2515 assert_eq!(config.rules.unused_files, Severity::Warn);
2516 assert_eq!(config.rules.unused_exports, Severity::Error);
2518 assert_eq!(config.rules.unused_types, Severity::Error);
2519 assert_eq!(config.rules.unused_dependencies, Severity::Error);
2520 assert_eq!(config.rules.unresolved_imports, Severity::Error);
2521 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2522 assert_eq!(config.rules.duplicate_exports, Severity::Error);
2523 assert_eq!(config.rules.circular_dependencies, Severity::Error);
2524 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2526 }
2527
2528 #[test]
2531 fn find_and_load_returns_none_when_no_config() {
2532 let dir = test_dir("find-none");
2533 std::fs::create_dir(dir.path().join(".git")).unwrap();
2535
2536 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2537 assert!(result.is_none());
2538 }
2539
2540 #[test]
2541 fn find_and_load_finds_fallowrc_json() {
2542 let dir = test_dir("find-json");
2543 std::fs::create_dir(dir.path().join(".git")).unwrap();
2544 std::fs::write(
2545 dir.path().join(".fallowrc.json"),
2546 r#"{"entry": ["src/main.ts"]}"#,
2547 )
2548 .unwrap();
2549
2550 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2551 assert_eq!(config.entry, vec!["src/main.ts"]);
2552 assert!(path.ends_with(".fallowrc.json"));
2553 }
2554
2555 #[test]
2556 fn find_and_load_finds_fallowrc_jsonc() {
2557 let dir = test_dir("find-jsonc");
2558 std::fs::create_dir(dir.path().join(".git")).unwrap();
2559 std::fs::write(
2560 dir.path().join(".fallowrc.jsonc"),
2561 r#"{
2562 // jsonc with comments, picked up by auto-discovery
2563 "entry": ["src/main.ts"]
2564 }"#,
2565 )
2566 .unwrap();
2567
2568 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2569 assert_eq!(config.entry, vec!["src/main.ts"]);
2570 assert!(path.ends_with(".fallowrc.jsonc"));
2571 }
2572
2573 #[test]
2574 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2575 let dir = test_dir("find-json-vs-jsonc");
2578 std::fs::create_dir(dir.path().join(".git")).unwrap();
2579 std::fs::write(
2580 dir.path().join(".fallowrc.json"),
2581 r#"{"entry": ["from-json.ts"]}"#,
2582 )
2583 .unwrap();
2584 std::fs::write(
2585 dir.path().join(".fallowrc.jsonc"),
2586 r#"{"entry": ["from-jsonc.ts"]}"#,
2587 )
2588 .unwrap();
2589
2590 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2591 assert_eq!(config.entry, vec!["from-json.ts"]);
2592 assert!(path.ends_with(".fallowrc.json"));
2593 }
2594
2595 #[test]
2596 fn find_and_load_prefers_fallowrc_json_over_toml() {
2597 let dir = test_dir("find-priority");
2598 std::fs::create_dir(dir.path().join(".git")).unwrap();
2599 std::fs::write(
2600 dir.path().join(".fallowrc.json"),
2601 r#"{"entry": ["from-json.ts"]}"#,
2602 )
2603 .unwrap();
2604 std::fs::write(
2605 dir.path().join("fallow.toml"),
2606 "entry = [\"from-toml.ts\"]\n",
2607 )
2608 .unwrap();
2609
2610 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2611 assert_eq!(config.entry, vec!["from-json.ts"]);
2612 assert!(path.ends_with(".fallowrc.json"));
2613 }
2614
2615 #[test]
2618 fn shadowed_config_names_empty_when_single_config() {
2619 let dir = test_dir("shadow-single");
2620 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2621 assert!(shadowed_config_names(dir.path(), 0).is_empty());
2623 }
2624
2625 #[test]
2626 fn shadowed_config_names_reports_lower_precedence_toml() {
2627 let dir = test_dir("shadow-json-toml");
2628 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2629 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2630 assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2631 }
2632
2633 #[test]
2634 fn shadowed_config_names_reports_jsonc_sibling() {
2635 let dir = test_dir("shadow-json-jsonc");
2636 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2637 std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2638 assert_eq!(
2639 shadowed_config_names(dir.path(), 0),
2640 vec![".fallowrc.jsonc"]
2641 );
2642 }
2643
2644 #[test]
2645 fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2646 let dir = test_dir("shadow-all-four");
2647 for name in CONFIG_NAMES {
2648 std::fs::write(dir.path().join(name), "").unwrap();
2649 }
2650 assert_eq!(
2653 shadowed_config_names(dir.path(), 0),
2654 vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2655 );
2656 }
2657
2658 #[test]
2659 fn shadowed_config_names_scoped_to_indices_after_winner() {
2660 let dir = test_dir("shadow-toml-dottoml");
2663 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2664 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2665 assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2666 }
2667
2668 #[test]
2669 fn find_and_load_warns_when_configs_coexist() {
2670 let dir = test_dir("coexist-warn");
2671 std::fs::create_dir(dir.path().join(".git")).unwrap();
2672 std::fs::write(
2673 dir.path().join(".fallowrc.json"),
2674 r#"{"entry": ["from-json.ts"]}"#,
2675 )
2676 .unwrap();
2677 std::fs::write(
2678 dir.path().join("fallow.toml"),
2679 "entry = [\"from-toml.ts\"]\n",
2680 )
2681 .unwrap();
2682
2683 let (result, captured) =
2684 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2685
2686 let (config, path) = result.unwrap().unwrap();
2688 assert_eq!(config.entry, vec!["from-json.ts"]);
2689 assert!(path.ends_with(".fallowrc.json"));
2690
2691 assert_eq!(captured.len(), 1);
2693 let (chosen, shadowed) = &captured[0];
2694 assert_eq!(chosen, ".fallowrc.json");
2695 assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2696 }
2697
2698 #[test]
2699 fn find_and_load_does_not_warn_for_single_config() {
2700 let dir = test_dir("coexist-none");
2701 std::fs::create_dir(dir.path().join(".git")).unwrap();
2702 std::fs::write(
2703 dir.path().join(".fallowrc.json"),
2704 r#"{"entry": ["only.ts"]}"#,
2705 )
2706 .unwrap();
2707
2708 let (result, captured) =
2709 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2710 assert!(result.unwrap().is_some());
2711 assert!(captured.is_empty());
2712 }
2713
2714 #[test]
2715 fn find_and_load_warns_per_directory_independently() {
2716 let make = |name: &str| {
2723 let dir = test_dir(name);
2724 std::fs::create_dir(dir.path().join(".git")).unwrap();
2725 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2726 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2727 dir
2728 };
2729 let first = make("coexist-dir-a");
2730 let second = make("coexist-dir-b");
2731
2732 let ((), captured) = capture_coexisting_config_warnings(|| {
2733 FallowConfig::find_and_load(first.path()).unwrap();
2734 FallowConfig::find_and_load(second.path()).unwrap();
2735 });
2736
2737 assert_eq!(captured.len(), 2);
2738 assert!(captured.iter().all(|(chosen, shadowed)| {
2739 chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2740 }));
2741 }
2742
2743 #[test]
2744 fn explicit_load_does_not_warn_about_coexisting_configs() {
2745 let dir = test_dir("coexist-explicit");
2749 std::fs::write(
2750 dir.path().join(".fallowrc.json"),
2751 r#"{"entry": ["chosen.ts"]}"#,
2752 )
2753 .unwrap();
2754 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2755
2756 let chosen = dir.path().join("fallow.toml");
2757 let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2758 assert!(result.is_ok());
2759 assert!(captured.is_empty());
2760 }
2761
2762 #[test]
2763 fn find_and_load_finds_fallow_toml() {
2764 let dir = test_dir("find-toml");
2765 std::fs::create_dir(dir.path().join(".git")).unwrap();
2766 std::fs::write(
2767 dir.path().join("fallow.toml"),
2768 "entry = [\"src/index.ts\"]\n",
2769 )
2770 .unwrap();
2771
2772 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2773 assert_eq!(config.entry, vec!["src/index.ts"]);
2774 }
2775
2776 #[test]
2777 fn find_and_load_stops_at_git_dir() {
2778 let dir = test_dir("find-git-stop");
2779 let sub = dir.path().join("sub");
2780 std::fs::create_dir(&sub).unwrap();
2781 std::fs::create_dir(dir.path().join(".git")).unwrap();
2783 let result = FallowConfig::find_and_load(&sub).unwrap();
2787 assert!(result.is_none());
2788 }
2789
2790 #[test]
2791 fn find_and_load_walks_past_package_json_in_monorepo() {
2792 let dir = test_dir("find-monorepo");
2796 std::fs::create_dir(dir.path().join(".git")).unwrap();
2797 std::fs::write(
2798 dir.path().join(".fallowrc.json"),
2799 r#"{"entry": ["src/index.ts"]}"#,
2800 )
2801 .unwrap();
2802
2803 let sub = dir.path().join("packages").join("app");
2804 std::fs::create_dir_all(&sub).unwrap();
2805 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2806
2807 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2808 assert_eq!(config.entry, vec!["src/index.ts"]);
2809 assert_eq!(path, dir.path().join(".fallowrc.json"));
2810 }
2811
2812 #[test]
2813 fn find_and_load_sub_package_config_wins_over_root() {
2814 let dir = test_dir("find-monorepo-override");
2817 std::fs::create_dir(dir.path().join(".git")).unwrap();
2818 std::fs::write(
2819 dir.path().join(".fallowrc.json"),
2820 r#"{"entry": ["src/root.ts"]}"#,
2821 )
2822 .unwrap();
2823
2824 let sub = dir.path().join("packages").join("app");
2825 std::fs::create_dir_all(&sub).unwrap();
2826 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2827 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2828
2829 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2830 assert_eq!(config.entry, vec!["src/sub.ts"]);
2831 assert_eq!(path, sub.join(".fallowrc.json"));
2832 }
2833
2834 #[test]
2835 fn find_and_load_stops_at_git_file_submodule() {
2836 let dir = test_dir("find-git-file");
2841 std::fs::create_dir(dir.path().join(".git")).unwrap();
2842 std::fs::write(
2843 dir.path().join(".fallowrc.json"),
2844 r#"{"entry": ["src/parent.ts"]}"#,
2845 )
2846 .unwrap();
2847
2848 let submodule = dir.path().join("vendor").join("lib");
2849 std::fs::create_dir_all(&submodule).unwrap();
2850 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2852
2853 let result = FallowConfig::find_and_load(&submodule).unwrap();
2854 assert!(
2855 result.is_none(),
2856 "submodule boundary should stop config walk",
2857 );
2858 }
2859
2860 #[test]
2861 fn find_and_load_stops_at_hg_dir() {
2862 let dir = test_dir("find-hg-stop");
2863 let sub = dir.path().join("sub");
2864 std::fs::create_dir(&sub).unwrap();
2865 std::fs::create_dir(dir.path().join(".hg")).unwrap();
2866
2867 let result = FallowConfig::find_and_load(&sub).unwrap();
2868 assert!(result.is_none());
2869 }
2870
2871 #[test]
2872 fn find_and_load_returns_error_for_invalid_config() {
2873 let dir = test_dir("find-invalid");
2874 std::fs::create_dir(dir.path().join(".git")).unwrap();
2875 std::fs::write(
2876 dir.path().join(".fallowrc.json"),
2877 r"{ this is not valid json }",
2878 )
2879 .unwrap();
2880
2881 let result = FallowConfig::find_and_load(dir.path());
2882 assert!(result.is_err());
2883 }
2884
2885 #[test]
2888 fn load_toml_config_file() {
2889 let dir = test_dir("toml-config");
2890 let config_path = dir.path().join("fallow.toml");
2891 std::fs::write(
2892 &config_path,
2893 r#"
2894entry = ["src/index.ts"]
2895ignorePatterns = ["dist/**"]
2896
2897[rules]
2898unused-files = "warn"
2899
2900[duplicates]
2901minTokens = 100
2902"#,
2903 )
2904 .unwrap();
2905
2906 let config = FallowConfig::load(&config_path).unwrap();
2907 assert_eq!(config.entry, vec!["src/index.ts"]);
2908 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2909 assert_eq!(config.rules.unused_files, Severity::Warn);
2910 assert_eq!(config.duplicates.min_tokens, 100);
2911 }
2912
2913 #[test]
2916 fn extends_absolute_path_rejected() {
2917 let dir = test_dir("extends-absolute");
2918
2919 #[cfg(unix)]
2921 let abs_path = "/absolute/path/config.json";
2922 #[cfg(windows)]
2923 let abs_path = "C:\\absolute\\path\\config.json";
2924
2925 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2926 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2927
2928 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2929 assert!(result.is_err());
2930 let err_msg = format!("{}", result.unwrap_err());
2931 assert!(
2932 err_msg.contains("must be relative"),
2933 "Expected 'must be relative' error, got: {err_msg}"
2934 );
2935 }
2936
2937 #[test]
2938 fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2939 let dir = test_dir("extends-windows-absolute");
2940
2941 std::fs::write(
2942 dir.path().join(".fallowrc.json"),
2943 r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2944 )
2945 .unwrap();
2946
2947 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2948 assert!(result.is_err());
2949 let err_msg = format!("{}", result.unwrap_err());
2950 assert!(
2951 err_msg.contains("must be relative"),
2952 "Expected 'must be relative' error, got: {err_msg}"
2953 );
2954 }
2955
2956 #[cfg(windows)]
2957 #[test]
2958 fn extends_posix_rooted_absolute_path_rejected_on_windows() {
2959 let dir = test_dir("extends-posix-rooted-absolute");
2960
2961 std::fs::write(
2962 dir.path().join(".fallowrc.json"),
2963 r#"{"extends": ["/absolute/path/config.json"]}"#,
2964 )
2965 .unwrap();
2966
2967 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2968 assert!(result.is_err());
2969 let err_msg = format!("{}", result.unwrap_err());
2970 assert!(
2971 err_msg.contains("must be relative"),
2972 "Expected 'must be relative' error, got: {err_msg}"
2973 );
2974 }
2975
2976 #[test]
2979 fn resolve_production_mode_disables_dev_deps() {
2980 let config = FallowConfig {
2981 production: true.into(),
2982 ..Default::default()
2983 };
2984 let resolved = config.resolve(
2985 PathBuf::from("/tmp/test"),
2986 OutputFormat::Human,
2987 4,
2988 false,
2989 true,
2990 None,
2991 );
2992 assert!(resolved.production);
2993 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2994 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2995 assert_eq!(resolved.rules.unused_files, Severity::Error);
2997 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2998 }
2999
3000 #[test]
3003 fn include_entry_exports_deserializes_from_camelcase_json() {
3004 let json = r#"{ "includeEntryExports": true }"#;
3005 let config: FallowConfig = serde_json::from_str(json).unwrap();
3006 assert!(config.include_entry_exports);
3007 }
3008
3009 #[test]
3010 fn include_entry_exports_deserializes_from_camelcase_toml() {
3011 let toml_str = "includeEntryExports = true\n";
3012 let config: FallowConfig = toml::from_str(toml_str).unwrap();
3013 assert!(config.include_entry_exports);
3014 }
3015
3016 #[test]
3017 fn include_entry_exports_default_is_false() {
3018 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3019 assert!(!config.include_entry_exports);
3020 }
3021
3022 #[test]
3023 fn include_entry_exports_propagates_through_resolve() {
3024 let config = FallowConfig {
3025 include_entry_exports: true,
3026 auto_imports: false,
3027 cache: CacheConfig::default(),
3028 ..Default::default()
3029 };
3030 let resolved = config.resolve(
3031 PathBuf::from("/tmp/test"),
3032 OutputFormat::Human,
3033 1,
3034 true,
3035 true,
3036 None,
3037 );
3038 assert!(resolved.include_entry_exports);
3039 }
3040
3041 #[test]
3044 fn config_format_defaults_to_toml_for_unknown() {
3045 assert!(matches!(
3046 ConfigFormat::from_path(Path::new("config.yaml")),
3047 ConfigFormat::Toml
3048 ));
3049 assert!(matches!(
3050 ConfigFormat::from_path(Path::new("config")),
3051 ConfigFormat::Toml
3052 ));
3053 }
3054
3055 #[test]
3058 fn deep_merge_object_over_scalar_replaces() {
3059 let mut base = serde_json::json!("just a string");
3060 let overlay = serde_json::json!({"key": "value"});
3061 deep_merge_json(&mut base, overlay);
3062 assert_eq!(base, serde_json::json!({"key": "value"}));
3063 }
3064
3065 #[test]
3066 fn deep_merge_scalar_over_object_replaces() {
3067 let mut base = serde_json::json!({"key": "value"});
3068 let overlay = serde_json::json!(42);
3069 deep_merge_json(&mut base, overlay);
3070 assert_eq!(base, serde_json::json!(42));
3071 }
3072
3073 #[test]
3076 fn extends_non_string_non_array_ignored() {
3077 let dir = test_dir("extends-numeric");
3078 std::fs::write(
3079 dir.path().join(".fallowrc.json"),
3080 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3081 )
3082 .unwrap();
3083
3084 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3086 assert_eq!(config.entry, vec!["src/index.ts"]);
3087 }
3088
3089 #[test]
3092 fn extends_multiple_bases_later_wins() {
3093 let dir = test_dir("extends-multi-base");
3094
3095 std::fs::write(
3096 dir.path().join("base-a.json"),
3097 r#"{"rules": {"unused-files": "warn"}}"#,
3098 )
3099 .unwrap();
3100 std::fs::write(
3101 dir.path().join("base-b.json"),
3102 r#"{"rules": {"unused-files": "off"}}"#,
3103 )
3104 .unwrap();
3105 std::fs::write(
3106 dir.path().join(".fallowrc.json"),
3107 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3108 )
3109 .unwrap();
3110
3111 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3112 assert_eq!(config.rules.unused_files, Severity::Off);
3114 }
3115
3116 #[test]
3119 fn fallow_config_deserialize_production() {
3120 let json_str = r#"{"production": true}"#;
3121 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3122 assert!(config.production);
3123 }
3124
3125 #[test]
3126 fn fallow_config_production_defaults_false() {
3127 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3128 assert!(!config.production);
3129 }
3130
3131 #[test]
3134 fn package_json_optional_dependency_names() {
3135 let pkg: PackageJson = serde_json::from_str(
3136 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3137 )
3138 .unwrap();
3139 let opt = pkg.optional_dependency_names();
3140 assert_eq!(opt.len(), 2);
3141 assert!(opt.contains(&"fsevents".to_string()));
3142 assert!(opt.contains(&"chokidar".to_string()));
3143 }
3144
3145 #[test]
3146 fn package_json_optional_deps_empty_when_missing() {
3147 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3148 assert!(pkg.optional_dependency_names().is_empty());
3149 }
3150
3151 #[test]
3154 fn find_config_path_returns_fallowrc_json() {
3155 let dir = test_dir("find-path-json");
3156 std::fs::create_dir(dir.path().join(".git")).unwrap();
3157 std::fs::write(
3158 dir.path().join(".fallowrc.json"),
3159 r#"{"entry": ["src/main.ts"]}"#,
3160 )
3161 .unwrap();
3162
3163 let path = FallowConfig::find_config_path(dir.path());
3164 assert!(path.is_some());
3165 assert!(path.unwrap().ends_with(".fallowrc.json"));
3166 }
3167
3168 #[test]
3169 fn find_config_path_returns_fallow_toml() {
3170 let dir = test_dir("find-path-toml");
3171 std::fs::create_dir(dir.path().join(".git")).unwrap();
3172 std::fs::write(
3173 dir.path().join("fallow.toml"),
3174 "entry = [\"src/main.ts\"]\n",
3175 )
3176 .unwrap();
3177
3178 let path = FallowConfig::find_config_path(dir.path());
3179 assert!(path.is_some());
3180 assert!(path.unwrap().ends_with("fallow.toml"));
3181 }
3182
3183 #[test]
3184 fn find_config_path_returns_dot_fallow_toml() {
3185 let dir = test_dir("find-path-dot-toml");
3186 std::fs::create_dir(dir.path().join(".git")).unwrap();
3187 std::fs::write(
3188 dir.path().join(".fallow.toml"),
3189 "entry = [\"src/main.ts\"]\n",
3190 )
3191 .unwrap();
3192
3193 let path = FallowConfig::find_config_path(dir.path());
3194 assert!(path.is_some());
3195 assert!(path.unwrap().ends_with(".fallow.toml"));
3196 }
3197
3198 #[test]
3199 fn find_config_path_prefers_json_over_toml() {
3200 let dir = test_dir("find-path-priority");
3201 std::fs::create_dir(dir.path().join(".git")).unwrap();
3202 std::fs::write(
3203 dir.path().join(".fallowrc.json"),
3204 r#"{"entry": ["json.ts"]}"#,
3205 )
3206 .unwrap();
3207 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3208
3209 let path = FallowConfig::find_config_path(dir.path());
3210 assert!(path.unwrap().ends_with(".fallowrc.json"));
3211 }
3212
3213 #[test]
3214 fn find_config_path_none_when_no_config() {
3215 let dir = test_dir("find-path-none");
3216 std::fs::create_dir(dir.path().join(".git")).unwrap();
3217
3218 let path = FallowConfig::find_config_path(dir.path());
3219 assert!(path.is_none());
3220 }
3221
3222 #[test]
3223 fn find_config_path_walks_past_package_json_in_monorepo() {
3224 let dir = test_dir("find-path-monorepo");
3225 std::fs::create_dir(dir.path().join(".git")).unwrap();
3226 std::fs::write(
3227 dir.path().join(".fallowrc.json"),
3228 r#"{"entry": ["src/index.ts"]}"#,
3229 )
3230 .unwrap();
3231
3232 let sub = dir.path().join("packages").join("app");
3233 std::fs::create_dir_all(&sub).unwrap();
3234 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3235
3236 let path = FallowConfig::find_config_path(&sub).unwrap();
3237 assert_eq!(path, dir.path().join(".fallowrc.json"));
3238 }
3239
3240 #[test]
3243 fn extends_toml_base() {
3244 let dir = test_dir("extends-toml");
3245
3246 std::fs::write(
3247 dir.path().join("base.json"),
3248 r#"{"rules": {"unused-files": "warn"}}"#,
3249 )
3250 .unwrap();
3251 std::fs::write(
3252 dir.path().join("fallow.toml"),
3253 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3254 )
3255 .unwrap();
3256
3257 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3258 assert_eq!(config.rules.unused_files, Severity::Warn);
3259 assert_eq!(config.entry, vec!["src/index.ts"]);
3260 }
3261
3262 #[test]
3265 fn deep_merge_boolean_overlay() {
3266 let mut base = serde_json::json!(true);
3267 deep_merge_json(&mut base, serde_json::json!(false));
3268 assert_eq!(base, serde_json::json!(false));
3269 }
3270
3271 #[test]
3272 fn deep_merge_number_overlay() {
3273 let mut base = serde_json::json!(42);
3274 deep_merge_json(&mut base, serde_json::json!(99));
3275 assert_eq!(base, serde_json::json!(99));
3276 }
3277
3278 #[test]
3279 fn deep_merge_disjoint_objects() {
3280 let mut base = serde_json::json!({"a": 1});
3281 let overlay = serde_json::json!({"b": 2});
3282 deep_merge_json(&mut base, overlay);
3283 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3284 }
3285
3286 #[test]
3289 fn max_extends_depth_is_reasonable() {
3290 assert_eq!(MAX_EXTENDS_DEPTH, 10);
3291 }
3292
3293 #[test]
3296 fn config_names_has_four_entries() {
3297 assert_eq!(CONFIG_NAMES.len(), 4);
3298 for name in CONFIG_NAMES {
3300 assert!(
3301 name.starts_with('.') || name.starts_with("fallow"),
3302 "unexpected config name: {name}"
3303 );
3304 }
3305 }
3306
3307 #[test]
3310 fn package_json_peer_dependency_names() {
3311 let pkg: PackageJson = serde_json::from_str(
3312 r#"{
3313 "dependencies": {"react": "^18"},
3314 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3315 }"#,
3316 )
3317 .unwrap();
3318 let all = pkg.all_dependency_names();
3319 assert!(all.contains(&"react".to_string()));
3320 assert!(all.contains(&"react-dom".to_string()));
3321 assert!(all.contains(&"react-native".to_string()));
3322 }
3323
3324 #[test]
3327 fn package_json_scripts_field() {
3328 let pkg: PackageJson = serde_json::from_str(
3329 r#"{
3330 "scripts": {
3331 "build": "tsc",
3332 "test": "vitest",
3333 "lint": "fallow check"
3334 }
3335 }"#,
3336 )
3337 .unwrap();
3338 let scripts = pkg.scripts.unwrap();
3339 assert_eq!(scripts.len(), 3);
3340 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3341 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3342 }
3343
3344 #[test]
3347 fn extends_toml_chain() {
3348 let dir = test_dir("extends-toml-chain");
3349
3350 std::fs::write(
3351 dir.path().join("base.json"),
3352 r#"{"entry": ["src/base.ts"]}"#,
3353 )
3354 .unwrap();
3355 std::fs::write(
3356 dir.path().join("middle.json"),
3357 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3358 )
3359 .unwrap();
3360 std::fs::write(
3361 dir.path().join("fallow.toml"),
3362 "extends = [\"middle.json\"]\n",
3363 )
3364 .unwrap();
3365
3366 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3367 assert_eq!(config.entry, vec!["src/base.ts"]);
3368 assert_eq!(config.rules.unused_files, Severity::Off);
3369 }
3370
3371 #[test]
3374 fn find_and_load_walks_up_directories() {
3375 let dir = test_dir("find-walk-up");
3376 let sub = dir.path().join("src").join("deep");
3377 std::fs::create_dir_all(&sub).unwrap();
3378 std::fs::write(
3379 dir.path().join(".fallowrc.json"),
3380 r#"{"entry": ["src/main.ts"]}"#,
3381 )
3382 .unwrap();
3383 std::fs::create_dir(dir.path().join(".git")).unwrap();
3385
3386 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3387 assert_eq!(config.entry, vec!["src/main.ts"]);
3388 assert!(path.ends_with(".fallowrc.json"));
3389 }
3390
3391 #[test]
3394 fn json_schema_contains_entry_field() {
3395 let schema = FallowConfig::json_schema();
3396 let obj = schema.as_object().unwrap();
3397 let props = obj.get("properties").and_then(|v| v.as_object());
3398 assert!(props.is_some(), "schema should have properties");
3399 assert!(
3400 props.unwrap().contains_key("entry"),
3401 "schema should contain entry property"
3402 );
3403 }
3404
3405 #[test]
3408 fn fallow_config_json_duplicates_all_fields() {
3409 let json = r#"{
3410 "duplicates": {
3411 "enabled": true,
3412 "mode": "semantic",
3413 "minTokens": 200,
3414 "minLines": 20,
3415 "threshold": 10.5,
3416 "ignore": ["**/*.test.ts"],
3417 "skipLocal": true,
3418 "crossLanguage": true,
3419 "normalization": {
3420 "ignoreIdentifiers": true,
3421 "ignoreStringValues": false
3422 }
3423 }
3424 }"#;
3425 let config: FallowConfig = serde_json::from_str(json).unwrap();
3426 assert!(config.duplicates.enabled);
3427 assert_eq!(
3428 config.duplicates.mode,
3429 crate::config::DetectionMode::Semantic
3430 );
3431 assert_eq!(config.duplicates.min_tokens, 200);
3432 assert_eq!(config.duplicates.min_lines, 20);
3433 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3434 assert!(config.duplicates.skip_local);
3435 assert!(config.duplicates.cross_language);
3436 assert_eq!(
3437 config.duplicates.normalization.ignore_identifiers,
3438 Some(true)
3439 );
3440 assert_eq!(
3441 config.duplicates.normalization.ignore_string_values,
3442 Some(false)
3443 );
3444 }
3445
3446 #[test]
3449 fn normalize_url_basic() {
3450 assert_eq!(
3451 normalize_url_for_dedup("https://example.com/config.json"),
3452 "https://example.com/config.json"
3453 );
3454 }
3455
3456 #[test]
3457 fn normalize_url_trailing_slash() {
3458 assert_eq!(
3459 normalize_url_for_dedup("https://example.com/config/"),
3460 "https://example.com/config"
3461 );
3462 }
3463
3464 #[test]
3465 fn normalize_url_uppercase_scheme_and_host() {
3466 assert_eq!(
3467 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3468 "https://example.com/Config.json"
3469 );
3470 }
3471
3472 #[test]
3473 fn normalize_url_root_path() {
3474 assert_eq!(
3475 normalize_url_for_dedup("https://example.com/"),
3476 "https://example.com"
3477 );
3478 assert_eq!(
3479 normalize_url_for_dedup("https://example.com"),
3480 "https://example.com"
3481 );
3482 }
3483
3484 #[test]
3485 fn normalize_url_preserves_path_case() {
3486 assert_eq!(
3488 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3489 "https://github.com/Org/Repo/Fallow.json"
3490 );
3491 }
3492
3493 #[test]
3494 fn normalize_url_strips_query_string() {
3495 assert_eq!(
3496 normalize_url_for_dedup("https://example.com/config.json?v=1"),
3497 "https://example.com/config.json"
3498 );
3499 }
3500
3501 #[test]
3502 fn normalize_url_strips_fragment() {
3503 assert_eq!(
3504 normalize_url_for_dedup("https://example.com/config.json#section"),
3505 "https://example.com/config.json"
3506 );
3507 }
3508
3509 #[test]
3510 fn normalize_url_strips_query_and_fragment() {
3511 assert_eq!(
3512 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3513 "https://example.com/config.json"
3514 );
3515 }
3516
3517 #[test]
3518 fn normalize_url_default_https_port() {
3519 assert_eq!(
3520 normalize_url_for_dedup("https://example.com:443/config.json"),
3521 "https://example.com/config.json"
3522 );
3523 assert_eq!(
3525 normalize_url_for_dedup("https://example.com:8443/config.json"),
3526 "https://example.com:8443/config.json"
3527 );
3528 }
3529
3530 #[test]
3531 fn extends_http_rejected() {
3532 let dir = test_dir("http-rejected");
3533 std::fs::write(
3534 dir.path().join(".fallowrc.json"),
3535 r#"{"extends": "http://example.com/config.json"}"#,
3536 )
3537 .unwrap();
3538
3539 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3540 assert!(result.is_err());
3541 let err_msg = format!("{}", result.unwrap_err());
3542 assert!(
3543 err_msg.contains("https://"),
3544 "Expected https hint in error, got: {err_msg}"
3545 );
3546 assert!(
3547 err_msg.contains("http://"),
3548 "Expected http:// mention in error, got: {err_msg}"
3549 );
3550 }
3551
3552 #[test]
3553 fn extends_url_circular_detection() {
3554 let mut visited = FxHashSet::default();
3556 let url = "https://example.com/config.json";
3557 let normalized = normalize_url_for_dedup(url);
3558 visited.insert(normalized.clone());
3559
3560 assert!(
3562 !visited.insert(normalized),
3563 "Same URL should be detected as duplicate"
3564 );
3565 }
3566
3567 #[test]
3568 fn extends_url_circular_case_insensitive() {
3569 let mut visited = FxHashSet::default();
3571 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3572
3573 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3574 assert!(
3575 !visited.insert(normalized),
3576 "Case-different URLs should normalize to the same key"
3577 );
3578 }
3579
3580 #[test]
3581 fn extract_extends_array() {
3582 let mut value = serde_json::json!({
3583 "extends": ["a.json", "b.json"],
3584 "entry": ["src/index.ts"]
3585 });
3586 let extends = extract_extends(&mut value);
3587 assert_eq!(extends, vec!["a.json", "b.json"]);
3588 assert!(value.get("extends").is_none());
3590 assert!(value.get("entry").is_some());
3591 }
3592
3593 #[test]
3594 fn extract_extends_string_sugar() {
3595 let mut value = serde_json::json!({
3596 "extends": "base.json",
3597 "entry": ["src/index.ts"]
3598 });
3599 let extends = extract_extends(&mut value);
3600 assert_eq!(extends, vec!["base.json"]);
3601 }
3602
3603 #[test]
3604 fn extract_extends_none() {
3605 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3606 let extends = extract_extends(&mut value);
3607 assert!(extends.is_empty());
3608 }
3609
3610 #[test]
3611 fn url_timeout_default() {
3612 let timeout = url_timeout();
3614 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3617 }
3618
3619 #[test]
3620 fn extends_url_mixed_with_file_and_npm() {
3621 let dir = test_dir("url-mixed");
3624 std::fs::write(
3625 dir.path().join("local.json"),
3626 r#"{"rules": {"unused-files": "warn"}}"#,
3627 )
3628 .unwrap();
3629 std::fs::write(
3630 dir.path().join(".fallowrc.json"),
3631 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3632 )
3633 .unwrap();
3634
3635 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3636 assert!(result.is_err());
3637 let err_msg = format!("{}", result.unwrap_err());
3638 assert!(
3639 err_msg.contains("unreachable.invalid"),
3640 "Expected URL in error message, got: {err_msg}"
3641 );
3642 }
3643
3644 #[test]
3645 fn extends_https_url_unreachable_errors() {
3646 let dir = test_dir("url-unreachable");
3647 std::fs::write(
3648 dir.path().join(".fallowrc.json"),
3649 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3650 )
3651 .unwrap();
3652
3653 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3654 assert!(result.is_err());
3655 let err_msg = format!("{}", result.unwrap_err());
3656 assert!(
3657 err_msg.contains("unreachable.invalid"),
3658 "Expected URL in error, got: {err_msg}"
3659 );
3660 assert!(
3661 err_msg.contains("local path or npm:"),
3662 "Expected remediation hint, got: {err_msg}"
3663 );
3664 }
3665
3666 #[test]
3669 fn collect_unknown_rule_keys_flags_top_level_typo() {
3670 let merged = serde_json::json!({
3671 "rules": {
3672 "unsued-files": "warn",
3673 "unused-exports": "off"
3674 }
3675 });
3676 let findings = collect_unknown_rule_keys(&merged);
3677 assert_eq!(findings.len(), 1);
3678 assert_eq!(findings[0].context, "rules");
3679 assert_eq!(findings[0].key, "unsued-files");
3680 assert_eq!(findings[0].suggestion, Some("unused-files"));
3681 }
3682
3683 #[test]
3684 fn collect_unknown_rule_keys_flags_overrides_typo() {
3685 let merged = serde_json::json!({
3686 "overrides": [
3687 {
3688 "files": ["src/**/*.ts"],
3689 "rules": {
3690 "unsued-files": "warn"
3691 }
3692 },
3693 {
3694 "files": ["tests/**/*.ts"],
3695 "rules": {
3696 "circular-dependnecy": "off"
3697 }
3698 }
3699 ]
3700 });
3701 let findings = collect_unknown_rule_keys(&merged);
3702 assert_eq!(findings.len(), 2);
3703 assert_eq!(findings[0].context, "overrides[0].rules");
3704 assert_eq!(findings[1].context, "overrides[1].rules");
3705 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3706 }
3707
3708 #[test]
3709 fn collect_unknown_rule_keys_empty_for_valid_config() {
3710 let merged = serde_json::json!({
3711 "rules": {
3712 "unused-files": "warn",
3713 "unused-file": "off",
3714 "circular-dependency": "off",
3715 "boundary-violations": "warn"
3716 },
3717 "overrides": [
3718 {
3719 "files": ["src/**"],
3720 "rules": {
3721 "unused-exports": "warn"
3722 }
3723 }
3724 ]
3725 });
3726 let findings = collect_unknown_rule_keys(&merged);
3727 assert!(
3728 findings.is_empty(),
3729 "valid rule names and aliases must not be flagged: {findings:?}"
3730 );
3731 }
3732
3733 #[test]
3734 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3735 let merged = serde_json::json!({
3736 "entry": ["src/main.ts"]
3737 });
3738 let findings = collect_unknown_rule_keys(&merged);
3739 assert!(findings.is_empty());
3740 }
3741
3742 #[test]
3743 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3744 let dir = test_dir("wiring");
3756 let path = dir.path().join(".fallowrc.json");
3757 let typo = format!(
3758 "wiring-probe-{}-{}",
3759 std::process::id(),
3760 std::time::SystemTime::now()
3761 .duration_since(std::time::UNIX_EPOCH)
3762 .map_or(0, |d| d.as_nanos())
3763 );
3764 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3765
3766 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3767
3768 assert!(
3769 config_res.is_ok(),
3770 "load should succeed in phase 1: {:?}",
3771 config_res.err()
3772 );
3773 assert_eq!(
3774 captured.len(),
3775 1,
3776 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3777 );
3778 assert_eq!(captured[0].key, typo);
3779 assert_eq!(captured[0].context, "rules");
3780 }
3781
3782 #[test]
3783 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3784 let dir = test_dir("misspelled-rule");
3788 std::fs::write(
3789 dir.path().join(".fallowrc.json"),
3790 r#"{"rules": {"unsued-files": "warn"}}"#,
3791 )
3792 .unwrap();
3793
3794 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3795 .expect("load should succeed in phase 1");
3796
3797 assert_eq!(config.rules.unused_files, Severity::Error);
3799 }
3800
3801 #[test]
3804 fn validate_resolved_boundaries_passes_on_valid_config() {
3805 let dir = test_dir("boundaries-valid");
3806 let config = FallowConfig {
3807 boundaries: crate::BoundaryConfig {
3808 preset: None,
3809 zones: vec![
3810 crate::BoundaryZone {
3811 name: "ui".to_string(),
3812 patterns: vec!["src/components/**".to_string()],
3813 auto_discover: vec![],
3814 root: None,
3815 },
3816 crate::BoundaryZone {
3817 name: "db".to_string(),
3818 patterns: vec!["src/db/**".to_string()],
3819 auto_discover: vec![],
3820 root: None,
3821 },
3822 ],
3823 rules: vec![crate::BoundaryRule {
3824 from: "ui".to_string(),
3825 allow: vec!["db".to_string()],
3826 allow_type_only: vec![],
3827 }],
3828 },
3829 ..FallowConfig::default()
3830 };
3831 config
3832 .validate_resolved_boundaries(dir.path())
3833 .expect("valid config should pass");
3834 }
3835
3836 #[test]
3837 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3838 let dir = test_dir("boundaries-unknown-zones");
3839 let config = FallowConfig {
3840 boundaries: crate::BoundaryConfig {
3841 preset: None,
3842 zones: vec![crate::BoundaryZone {
3843 name: "ui".to_string(),
3844 patterns: vec!["src/ui/**".to_string()],
3845 auto_discover: vec![],
3846 root: None,
3847 }],
3848 rules: vec![
3849 crate::BoundaryRule {
3850 from: "typo-from".to_string(),
3851 allow: vec!["typo-allow".to_string()],
3852 allow_type_only: vec!["typo-type-only".to_string()],
3853 },
3854 crate::BoundaryRule {
3855 from: "ui".to_string(),
3856 allow: vec!["another-typo".to_string()],
3857 allow_type_only: vec![],
3858 },
3859 ],
3860 },
3861 ..FallowConfig::default()
3862 };
3863
3864 let errors = config
3865 .validate_resolved_boundaries(dir.path())
3866 .expect_err("invalid zone refs should fail");
3867
3868 assert_eq!(errors.len(), 4, "got: {errors:?}");
3869
3870 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3874 assert!(
3875 rendered
3876 .iter()
3877 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3878 );
3879 assert!(
3880 rendered
3881 .iter()
3882 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3883 );
3884 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3885 && m.contains("rules[0]")
3886 && m.contains("allowTypeOnly")));
3887 assert!(
3888 rendered.iter().any(|m| m.contains("another-typo")
3889 && m.contains("rules[1]")
3890 && m.contains("allow"))
3891 );
3892 }
3893
3894 #[test]
3895 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3896 let dir = test_dir("boundaries-redundant-prefix");
3897 let config = FallowConfig {
3898 boundaries: crate::BoundaryConfig {
3899 preset: None,
3900 zones: vec![crate::BoundaryZone {
3901 name: "ui".to_string(),
3902 patterns: vec!["packages/app/src/**".to_string()],
3903 auto_discover: vec![],
3904 root: Some("packages/app/".to_string()),
3905 }],
3906 rules: vec![],
3907 },
3908 ..FallowConfig::default()
3909 };
3910
3911 let errors = config
3912 .validate_resolved_boundaries(dir.path())
3913 .expect_err("redundant root prefix should fail");
3914 assert_eq!(errors.len(), 1, "got: {errors:?}");
3915 let rendered = errors[0].to_string();
3917 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3918 assert!(rendered.contains("zone 'ui'"));
3919 }
3920
3921 #[test]
3922 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3923 let dir = test_dir("boundaries-mixed-errors");
3926 let config = FallowConfig {
3927 boundaries: crate::BoundaryConfig {
3928 preset: None,
3929 zones: vec![crate::BoundaryZone {
3930 name: "ui".to_string(),
3931 patterns: vec!["packages/app/src/**".to_string()],
3932 auto_discover: vec![],
3933 root: Some("packages/app/".to_string()),
3934 }],
3935 rules: vec![crate::BoundaryRule {
3936 from: "ui".to_string(),
3937 allow: vec!["typo-zone".to_string()],
3938 allow_type_only: vec![],
3939 }],
3940 },
3941 ..FallowConfig::default()
3942 };
3943 let errors = config
3944 .validate_resolved_boundaries(dir.path())
3945 .expect_err("mixed errors should fail");
3946 assert_eq!(errors.len(), 2, "got: {errors:?}");
3947 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3948 assert!(
3949 rendered
3950 .iter()
3951 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3952 );
3953 assert!(
3954 rendered
3955 .iter()
3956 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3957 );
3958 }
3959
3960 #[test]
3961 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3962 let dir = test_dir("boundaries-bulletproof");
3968 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3970 let config = FallowConfig {
3971 boundaries: crate::BoundaryConfig {
3972 preset: Some(crate::BoundaryPreset::Bulletproof),
3973 zones: vec![],
3974 rules: vec![],
3975 },
3976 ..FallowConfig::default()
3977 };
3978 config
3979 .validate_resolved_boundaries(dir.path())
3980 .expect("Bulletproof with discoverable features should pass");
3981 }
3982}