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