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