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