1use std::net::Ipv6Addr;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use fallow_types::path_util::is_absolute_path_any_platform;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use super::FallowConfig;
9
10pub(super) const CONFIG_NAMES: &[&str] = &[
12 ".fallowrc.json",
13 ".fallowrc.jsonc",
14 "fallow.toml",
15 ".fallow.toml",
16];
17
18pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
19
20const NPM_PREFIX: &str = "npm:";
22
23const HTTPS_PREFIX: &str = "https://";
25
26const HTTP_PREFIX: &str = "http://";
28
29const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct ConfigLoadOptions {
35 pub allow_remote_extends: bool,
37}
38
39pub(super) enum ConfigFormat {
41 Toml,
42 Json,
43}
44
45impl ConfigFormat {
46 pub(super) fn from_path(path: &Path) -> Self {
47 match path.extension().and_then(|e| e.to_str()) {
48 Some("json" | "jsonc") => Self::Json,
49 _ => Self::Toml,
50 }
51 }
52}
53
54pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
57 match (base, overlay) {
58 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
59 for (key, value) in overlay_map {
60 if let Some(base_value) = base_map.get_mut(&key) {
61 deep_merge_json(base_value, value);
62 } else {
63 base_map.insert(key, value);
64 }
65 }
66 }
67 (base, overlay) => {
68 *base = overlay;
69 }
70 }
71}
72
73pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
74 let content = std::fs::read_to_string(path)
75 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
76 let content = content.trim_start_matches('\u{FEFF}');
77
78 match ConfigFormat::from_path(path) {
79 ConfigFormat::Toml => {
80 let toml_value: toml::Value = toml::from_str(content).map_err(|e| {
81 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
82 })?;
83 serde_json::to_value(toml_value).map_err(|e| {
84 miette::miette!(
85 "Failed to convert TOML to JSON for {}: {}",
86 path.display(),
87 e
88 )
89 })
90 }
91 ConfigFormat::Json => crate::jsonc::parse_to_value(content)
92 .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e)),
93 }
94}
95
96fn is_repo_root(dir: &Path) -> bool {
97 dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
98}
99
100fn resolve_confined(
101 base_dir: &Path,
102 resolved: &Path,
103 context: &str,
104 source_config: &Path,
105) -> Result<PathBuf, miette::Report> {
106 let canonical_base = dunce::canonicalize(base_dir)
107 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
108 let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
109 miette::miette!(
110 "Config file not found: {} ({}, referenced from {}): {}",
111 resolved.display(),
112 context,
113 source_config.display(),
114 e
115 )
116 })?;
117 if !canonical_file.starts_with(&canonical_base) {
118 return Err(miette::miette!(
119 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
120 resolved.display(),
121 base_dir.display(),
122 context,
123 source_config.display()
124 ));
125 }
126 Ok(canonical_file)
127}
128
129fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
130 if name.starts_with('@') && !name.contains('/') {
131 return Err(miette::miette!(
132 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
133 name,
134 source_config.display()
135 ));
136 }
137 if name.split('/').any(|c| c == ".." || c == ".") {
138 return Err(miette::miette!(
139 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
140 name,
141 source_config.display()
142 ));
143 }
144 Ok(())
145}
146
147fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
148 if specifier.starts_with('@') {
149 let mut slashes = 0;
150 for (i, ch) in specifier.char_indices() {
151 if ch == '/' {
152 slashes += 1;
153 if slashes == 2 {
154 return (&specifier[..i], Some(&specifier[i + 1..]));
155 }
156 }
157 }
158 (specifier, None)
159 } else if let Some(slash) = specifier.find('/') {
160 (&specifier[..slash], Some(&specifier[slash + 1..]))
161 } else {
162 (specifier, None)
163 }
164}
165
166fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
167 let exports = pkg.get("exports")?;
168 match exports {
169 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
170 serde_json::Value::Object(map) => {
171 let dot_export = map.get(".")?;
172 match dot_export {
173 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
174 serde_json::Value::Object(conditions) => {
175 for key in ["default", "node", "import", "require"] {
176 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
177 return Some(package_dir.join(s.as_str()));
178 }
179 }
180 None
181 }
182 _ => None,
183 }
184 }
185 _ => None,
186 }
187}
188
189fn find_config_in_npm_package(
190 package_dir: &Path,
191 source_config: &Path,
192) -> Result<PathBuf, miette::Report> {
193 let pkg_json_path = package_dir.join("package.json");
194 if pkg_json_path.exists() {
195 let content = std::fs::read_to_string(&pkg_json_path)
196 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
197 let pkg: serde_json::Value = serde_json::from_str(&content)
198 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
199 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
200 && config_path.exists()
201 {
202 return resolve_confined(
203 package_dir,
204 &config_path,
205 "package.json exports",
206 source_config,
207 );
208 }
209 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
210 let main_path = package_dir.join(main);
211 if main_path.exists() {
212 return resolve_confined(
213 package_dir,
214 &main_path,
215 "package.json main",
216 source_config,
217 );
218 }
219 }
220 }
221
222 for config_name in CONFIG_NAMES {
223 let config_path = package_dir.join(config_name);
224 if config_path.exists() {
225 return resolve_confined(
226 package_dir,
227 &config_path,
228 "config name fallback",
229 source_config,
230 );
231 }
232 }
233
234 Err(miette::miette!(
235 "No fallow config found in npm package at {}. \
236 Expected package.json with main/exports pointing to a config file, \
237 or one of: {}",
238 package_dir.display(),
239 CONFIG_NAMES.join(", ")
240 ))
241}
242
243fn resolve_npm_package(
244 config_dir: &Path,
245 specifier: &str,
246 source_config: &Path,
247) -> Result<PathBuf, miette::Report> {
248 let specifier = specifier.trim();
249 if specifier.is_empty() {
250 return Err(miette::miette!(
251 "Empty npm specifier in extends (in {})",
252 source_config.display()
253 ));
254 }
255
256 let (package_name, subpath) = parse_npm_specifier(specifier);
257 validate_npm_package_name(package_name, source_config)?;
258
259 let mut dir = Some(config_dir);
260 while let Some(d) = dir {
261 let candidate = d.join("node_modules").join(package_name);
262 if candidate.is_dir() {
263 return if let Some(sub) = subpath {
264 let file = candidate.join(sub);
265 if file.exists() {
266 resolve_confined(
267 &candidate,
268 &file,
269 &format!("subpath '{sub}'"),
270 source_config,
271 )
272 } else {
273 Err(miette::miette!(
274 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
275 file.display(),
276 sub,
277 candidate.display(),
278 source_config.display()
279 ))
280 }
281 } else {
282 find_config_in_npm_package(&candidate, source_config)
283 };
284 }
285 dir = d.parent();
286 }
287
288 Err(miette::miette!(
289 "npm package '{}' not found. \
290 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
291 If this package should be available, install it and ensure it is listed in your project's dependencies",
292 package_name,
293 package_name,
294 config_dir.display(),
295 source_config.display()
296 ))
297}
298
299fn normalize_url_for_dedup(url: &str) -> String {
301 let Some((scheme, rest)) = url.split_once("://") else {
302 return url.to_string();
303 };
304 let scheme = scheme.to_ascii_lowercase();
305
306 let rest = rest.split_once('#').map_or(rest, |(value, _)| value);
307 let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
308 let (authority, tail) = rest.split_at(authority_end);
309 let authority = normalize_url_authority(authority, &scheme);
310
311 let tail = if tail == "/" {
312 ""
313 } else if tail.starts_with("/?") {
314 &tail[1..]
315 } else {
316 tail
317 };
318
319 if tail.is_empty() {
320 format!("{scheme}://{authority}")
321 } else {
322 format!("{scheme}://{authority}{tail}")
323 }
324}
325
326fn normalize_url_authority(authority: &str, scheme: &str) -> String {
328 let (userinfo, host_port) = authority
329 .rsplit_once('@')
330 .map_or((None, authority), |(userinfo, host_port)| {
331 (Some(userinfo), host_port)
332 });
333
334 let (host, port) = if host_port.starts_with('[') {
335 host_port.find("]:").map_or((host_port, None), |separator| {
336 let (host, port) = host_port.split_at(separator + 1);
337 (host, port.strip_prefix(':'))
338 })
339 } else {
340 host_port
341 .rsplit_once(':')
342 .map_or((host_port, None), |(host, port)| (host, Some(port)))
343 };
344
345 let port = port.map(|value| {
346 value
347 .parse::<u16>()
348 .map_or_else(|_| value.to_string(), |number| number.to_string())
349 });
350 let port =
351 port.filter(|port| !matches!((scheme, port.as_str()), ("http", "80") | ("https", "443")));
352 let host = host
353 .strip_prefix('[')
354 .and_then(|value| value.strip_suffix(']'))
355 .and_then(|value| value.parse::<Ipv6Addr>().ok())
356 .map_or_else(
357 || host.to_ascii_lowercase(),
358 |address| format!("[{address}]"),
359 );
360
361 match (userinfo, port.as_deref()) {
362 (Some(userinfo), Some(port)) => format!("{userinfo}@{host}:{port}"),
363 (Some(userinfo), None) => format!("{userinfo}@{host}"),
364 (None, Some(port)) => format!("{host}:{port}"),
365 (None, None) => host,
366 }
367}
368
369fn remote_config_display(location: &str) -> String {
371 let Some((scheme, rest)) = location.split_once("://") else {
372 return location.to_string();
373 };
374
375 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
376 let (authority, tail) = rest.split_at(authority_end);
377 let authority = authority
378 .rsplit_once('@')
379 .map_or(authority, |(_, host)| host);
380 let path_end = tail.find(['?', '#']).unwrap_or(tail.len());
381 let path = &tail[..path_end];
382
383 format!("{scheme}://{authority}{path}")
384}
385
386fn remote_fetch_error_display(error: &ureq::Error, _url: &str) -> String {
388 match error {
389 ureq::Error::RequireHttpsOnly(redirect_target) => format!(
390 "configured for https only: {}",
391 remote_config_display(redirect_target)
392 ),
393 ureq::Error::StatusCode(code) => format!("http status {code}"),
394 ureq::Error::HostNotFound => "host not found".to_string(),
395 ureq::Error::Timeout(_) => "request timed out".to_string(),
396 _ => "request failed".to_string(),
397 }
398}
399
400fn starts_with_url_prefix(value: &str, prefix: &str) -> bool {
401 value
402 .get(..prefix.len())
403 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
404}
405
406fn url_timeout() -> Duration {
408 url_timeout_from(std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS").ok().as_deref())
409}
410
411fn url_timeout_from(raw: Option<&str>) -> Duration {
415 raw.and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
416 .map_or(
417 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
418 Duration::from_secs,
419 )
420}
421
422const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
424
425fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
427 let url_display = remote_config_display(url);
428 let source_display = remote_config_display(source);
429 let timeout = url_timeout();
430 let agent = ureq::Agent::config_builder()
431 .timeout_global(Some(timeout))
432 .https_only(true)
433 .build()
434 .new_agent();
435
436 let mut response = agent.get(url).call().map_err(|e| {
437 let error_display = remote_fetch_error_display(&e, url);
438 miette::miette!(
439 "Failed to fetch remote config from {url_display} \
440 (referenced from {source_display}): {error_display}. \
441 If this URL is unavailable, use a local path or npm: specifier instead"
442 )
443 })?;
444
445 let body = response
446 .body_mut()
447 .with_config()
448 .limit(MAX_URL_CONFIG_BYTES)
449 .read_to_string()
450 .map_err(|e| {
451 miette::miette!(
452 "Failed to read response body from {url_display} \
453 (referenced from {source_display}): {e}"
454 )
455 })?;
456
457 crate::jsonc::parse_to_value(&body).map_err(|e| {
458 miette::miette!(
459 "Failed to parse remote config as JSON from {url_display} \
460 (referenced from {source_display}): {e}. \
461 Only JSON/JSONC is supported for URL-sourced configs"
462 )
463 })
464}
465
466trait RemoteConfigFetcher {
467 fn fetch(&mut self, url: &str, source: &str) -> Result<serde_json::Value, miette::Report>;
468}
469
470struct NetworkRemoteConfigFetcher;
471
472impl RemoteConfigFetcher for NetworkRemoteConfigFetcher {
473 fn fetch(&mut self, url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
474 fetch_url_config(url, source)
475 }
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Hash)]
479enum ConfigResourceId {
480 Local(PathBuf),
481 Remote(String),
482}
483
484struct ExtendsResolver<'a, Fetcher> {
485 options: ConfigLoadOptions,
486 active: FxHashSet<ConfigResourceId>,
487 resolved: FxHashMap<ConfigResourceId, serde_json::Value>,
488 fetcher: &'a mut Fetcher,
489}
490
491#[derive(Clone, Copy)]
492struct LocalExtendsEntry<'a> {
493 path: &'a Path,
494 config_dir: &'a Path,
495 entry: &'a str,
496 sealed: bool,
497 sealed_dir_canonical: Option<&'a Path>,
498 depth: usize,
499}
500
501impl<'a, Fetcher: RemoteConfigFetcher> ExtendsResolver<'a, Fetcher> {
502 fn new(options: ConfigLoadOptions, fetcher: &'a mut Fetcher) -> Self {
503 Self {
504 options,
505 active: FxHashSet::default(),
506 resolved: FxHashMap::default(),
507 fetcher,
508 }
509 }
510
511 fn resolve_local(
512 &mut self,
513 path: &Path,
514 depth: usize,
515 ) -> Result<serde_json::Value, miette::Report> {
516 if depth >= MAX_EXTENDS_DEPTH {
517 return Err(miette::miette!(
518 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
519 path.display()
520 ));
521 }
522 let canonical = dunce::canonicalize(path).map_err(|e| {
523 miette::miette!(
524 "Config file not found or unresolvable: {}: {}",
525 path.display(),
526 e
527 )
528 })?;
529 let identity = ConfigResourceId::Local(canonical.clone());
530 if let Some(value) = self.resolved.get(&identity) {
531 return Ok(value.clone());
532 }
533 if !self.active.insert(identity.clone()) {
534 return Err(miette::miette!(
535 "Circular extends detected: {} is already active in the extends chain",
536 path.display()
537 ));
538 }
539
540 let result = self.resolve_local_uncached(&canonical, depth);
541 self.active.remove(&identity);
542 if let Ok(value) = &result {
543 self.resolved.insert(identity, value.clone());
544 }
545 result
546 }
547
548 fn resolve_local_uncached(
549 &mut self,
550 path: &Path,
551 depth: usize,
552 ) -> Result<serde_json::Value, miette::Report> {
553 let mut value = parse_config_to_value(path)?;
554 let extends = extract_extends(&mut value);
555 if extends.is_empty() {
556 return Ok(value);
557 }
558
559 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
560 let sealed = value
561 .get("sealed")
562 .and_then(serde_json::Value::as_bool)
563 .unwrap_or(false);
564 let sealed_dir_canonical = sealed_config_dir(config_dir, sealed)?;
565 let mut merged = serde_json::Value::Object(serde_json::Map::new());
566 for entry in &extends {
567 let base = self.resolve_local_entry(LocalExtendsEntry {
568 path,
569 config_dir,
570 entry,
571 sealed,
572 sealed_dir_canonical: sealed_dir_canonical.as_deref(),
573 depth,
574 })?;
575 deep_merge_json(&mut merged, base);
576 }
577 deep_merge_json(&mut merged, value);
578 Ok(merged)
579 }
580
581 fn resolve_local_entry(
582 &mut self,
583 input: LocalExtendsEntry<'_>,
584 ) -> Result<serde_json::Value, miette::Report> {
585 let LocalExtendsEntry {
586 path,
587 config_dir,
588 entry,
589 sealed,
590 sealed_dir_canonical,
591 depth,
592 } = input;
593 if starts_with_url_prefix(entry, HTTPS_PREFIX) {
594 reject_sealed_remote_extends(path, entry, sealed, "URL")?;
595 return self.resolve_remote(entry, depth + 1);
596 }
597 if starts_with_url_prefix(entry, HTTP_PREFIX) {
598 let entry_display = remote_config_display(entry);
599 return Err(miette::miette!(
600 "URL extends must use https://, got http:// URL '{}' (in {}). \
601 Change the URL to use https:// instead",
602 entry_display,
603 path.display()
604 ));
605 }
606 if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
607 reject_sealed_remote_extends(path, entry, sealed, "npm")?;
608 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
609 return self.resolve_local(&npm_path, depth + 1);
610 }
611 if is_absolute_path_any_platform(Path::new(entry)) {
612 return Err(miette::miette!(
613 "extends paths must be relative, got absolute path: {} (in {})",
614 entry,
615 path.display()
616 ));
617 }
618 let resolved_path = config_dir.join(entry);
619 if !resolved_path.exists() {
620 return Err(miette::miette!(
621 "Extended config file not found: {} (referenced from {})",
622 resolved_path.display(),
623 path.display()
624 ));
625 }
626 validate_sealed_relative_extends(path, entry, &resolved_path, sealed_dir_canonical)?;
627 self.resolve_local(&resolved_path, depth + 1)
628 }
629
630 fn resolve_remote(
631 &mut self,
632 url: &str,
633 depth: usize,
634 ) -> Result<serde_json::Value, miette::Report> {
635 if depth >= MAX_EXTENDS_DEPTH {
636 let url_display = remote_config_display(url);
637 return Err(miette::miette!(
638 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url_display}"
639 ));
640 }
641 if !self.options.allow_remote_extends {
642 let url_display = remote_config_display(url);
643 return Err(miette::miette!(
644 "Remote config extends '{url_display}' is disabled by default. \
645 CLI users can pass --allow-remote-extends. Library callers can use \
646 ConfigLoadOptions {{ allow_remote_extends: true }}"
647 ));
648 }
649
650 let identity = ConfigResourceId::Remote(normalize_url_for_dedup(url));
651 if let Some(value) = self.resolved.get(&identity) {
652 return Ok(value.clone());
653 }
654 if !self.active.insert(identity.clone()) {
655 let url_display = remote_config_display(url);
656 return Err(miette::miette!(
657 "Circular extends detected: {url_display} is already active in the extends chain"
658 ));
659 }
660
661 let result = self.resolve_remote_uncached(url, depth);
662 self.active.remove(&identity);
663 if let Ok(value) = &result {
664 self.resolved.insert(identity, value.clone());
665 }
666 result
667 }
668
669 fn resolve_remote_uncached(
670 &mut self,
671 url: &str,
672 depth: usize,
673 ) -> Result<serde_json::Value, miette::Report> {
674 let mut value = self.fetcher.fetch(url, url)?;
675 let extends = extract_extends(&mut value);
676 if extends.is_empty() {
677 return Ok(value);
678 }
679
680 let url_display = remote_config_display(url);
681 let mut merged = serde_json::Value::Object(serde_json::Map::new());
682 for entry in &extends {
683 let base = if starts_with_url_prefix(entry, HTTPS_PREFIX) {
684 self.resolve_remote(entry, depth + 1)?
685 } else if starts_with_url_prefix(entry, HTTP_PREFIX) {
686 let entry_display = remote_config_display(entry);
687 return Err(miette::miette!(
688 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
689 Change the URL to use https:// instead",
690 entry_display,
691 url_display
692 ));
693 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
694 let cwd = std::env::current_dir().map_err(|e| {
695 miette::miette!(
696 "Cannot resolve npm: specifier from URL-sourced config: \
697 failed to determine current directory: {e}"
698 )
699 })?;
700 let path_placeholder = PathBuf::from(&url_display);
701 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
702 self.resolve_local(&npm_path, depth + 1)?
703 } else {
704 return Err(miette::miette!(
705 "Relative paths in 'extends' are not supported when the base config was \
706 fetched from a URL ('{url_display}'). Use another https:// URL or npm: reference \
707 instead. Got: '{entry}'"
708 ));
709 };
710 deep_merge_json(&mut merged, base);
711 }
712 deep_merge_json(&mut merged, value);
713 Ok(merged)
714 }
715}
716
717fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
719 value
720 .as_object_mut()
721 .and_then(|obj| obj.remove("extends"))
722 .and_then(|v| match v {
723 serde_json::Value::Array(arr) => Some(
724 arr.into_iter()
725 .filter_map(|v| v.as_str().map(String::from))
726 .collect::<Vec<_>>(),
727 ),
728 serde_json::Value::String(s) => Some(vec![s]),
729 _ => None,
730 })
731 .unwrap_or_default()
732}
733
734#[cfg(test)]
735fn resolve_url_extends(
736 url: &str,
737 _visited: &mut FxHashSet<String>,
738 depth: usize,
739) -> Result<serde_json::Value, miette::Report> {
740 let mut fetcher = NetworkRemoteConfigFetcher;
741 ExtendsResolver::new(
742 ConfigLoadOptions {
743 allow_remote_extends: true,
744 },
745 &mut fetcher,
746 )
747 .resolve_remote(url, depth)
748}
749
750fn sealed_config_dir(config_dir: &Path, sealed: bool) -> Result<Option<PathBuf>, miette::Report> {
751 if !sealed {
752 return Ok(None);
753 }
754 dunce::canonicalize(config_dir).map(Some).map_err(|e| {
755 miette::miette!(
756 "Sealed config directory '{}' could not be canonicalized: {e}",
757 config_dir.display()
758 )
759 })
760}
761
762fn reject_sealed_remote_extends(
763 path: &Path,
764 entry: &str,
765 sealed: bool,
766 kind: &str,
767) -> Result<(), miette::Report> {
768 if sealed {
769 let entry_display = remote_config_display(entry);
770 Err(miette::miette!(
771 "'sealed: true' config at {} rejects {} extends '{}'. \
772 Sealed configs only allow file-relative extends within \
773 the config's directory",
774 path.display(),
775 kind,
776 entry_display
777 ))
778 } else {
779 Ok(())
780 }
781}
782
783fn validate_sealed_relative_extends(
784 path: &Path,
785 entry: &str,
786 resolved_path: &Path,
787 sealed_dir_canonical: Option<&Path>,
788) -> Result<(), miette::Report> {
789 let Some(dir_canonical) = sealed_dir_canonical else {
790 return Ok(());
791 };
792 let p_canonical = dunce::canonicalize(resolved_path).map_err(|e| {
793 miette::miette!(
794 "Sealed config extends path '{}' could not be canonicalized: {e}",
795 resolved_path.display()
796 )
797 })?;
798 if p_canonical.starts_with(dir_canonical) {
799 Ok(())
800 } else {
801 Err(miette::miette!(
802 "'sealed: true' config at {} rejects extends '{}' which resolves \
803 outside the config's directory ({}). Sealed configs only allow \
804 extends within the config's directory",
805 path.display(),
806 entry,
807 p_canonical.display()
808 ))
809 }
810}
811
812#[cfg(test)]
816pub(super) fn resolve_extends(
817 path: &Path,
818 _visited: &mut FxHashSet<String>,
819 depth: usize,
820) -> Result<serde_json::Value, miette::Report> {
821 let mut fetcher = NetworkRemoteConfigFetcher;
822 ExtendsResolver::new(ConfigLoadOptions::default(), &mut fetcher).resolve_local(path, depth)
823}
824
825pub(super) fn collect_unknown_rule_keys(
840 merged: &serde_json::Value,
841) -> Vec<super::rules::UnknownRuleKey> {
842 use super::rules::find_unknown_rule_keys;
843
844 let mut findings = Vec::new();
845
846 if let Some(rules) = merged.get("rules") {
847 findings.extend(find_unknown_rule_keys(rules, "rules"));
848 }
849
850 if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
851 for (i, entry) in overrides.iter().enumerate() {
852 if let Some(rules) = entry.get("rules") {
853 let context = format!("overrides[{i}].rules");
854 findings.extend(find_unknown_rule_keys(rules, &context));
855 }
856 }
857 }
858
859 findings
860}
861
862thread_local! {
863 #[cfg(test)]
869 static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
870 const { std::cell::RefCell::new(None) };
871}
872
873#[cfg(test)]
877pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
878 body: F,
879) -> (R, Vec<super::rules::UnknownRuleKey>) {
880 UNKNOWN_RULE_CAPTURE.with(|cell| {
881 *cell.borrow_mut() = Some(Vec::new());
882 });
883 let result = body();
884 let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
885 (result, findings)
886}
887
888fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
899 use std::sync::{Mutex, OnceLock};
900
901 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
902 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
903
904 let path_display = config_path.display().to_string();
905
906 for finding in collect_unknown_rule_keys(merged) {
907 let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
908 if let Ok(mut set) = warned.lock()
909 && !set.insert(dedupe_key)
910 {
911 continue;
912 }
913
914 #[cfg(test)]
915 UNKNOWN_RULE_CAPTURE.with(|cell| {
916 if let Some(buf) = cell.borrow_mut().as_mut() {
917 buf.push(finding.clone());
918 }
919 });
920
921 if let Some(suggestion) = finding.suggestion {
922 tracing::warn!(
923 "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
924 the rule will be ignored. A future release will reject unknown rule names.",
925 key = finding.key,
926 context = finding.context,
927 path = path_display,
928 );
929 } else {
930 tracing::warn!(
931 "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
932 A future release will reject unknown rule names.",
933 key = finding.key,
934 context = finding.context,
935 path = path_display,
936 );
937 }
938 }
939}
940
941fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
948 CONFIG_NAMES
949 .iter()
950 .skip(chosen_index + 1)
951 .filter(|name| dir.join(name).exists())
952 .copied()
953 .collect()
954}
955
956#[cfg(test)]
959type CoexistWarning = (String, Vec<String>);
960
961thread_local! {
962 #[cfg(test)]
969 static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
970 const { std::cell::RefCell::new(None) };
971}
972
973#[cfg(test)]
977pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
978 body: F,
979) -> (R, Vec<CoexistWarning>) {
980 COEXIST_CAPTURE.with(|cell| {
981 *cell.borrow_mut() = Some(Vec::new());
982 });
983 let result = body();
984 let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
985 (result, findings)
986}
987
988fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
1002 use std::sync::{Mutex, OnceLock};
1003
1004 if shadowed.is_empty() {
1005 return;
1006 }
1007
1008 let chosen_name = chosen_path.file_name().map_or_else(
1009 || chosen_path.display().to_string(),
1010 |n| n.to_string_lossy().into_owned(),
1011 );
1012 let dir = chosen_path.parent().unwrap_or(chosen_path);
1013
1014 #[cfg(test)]
1015 COEXIST_CAPTURE.with(|cell| {
1016 if let Some(buf) = cell.borrow_mut().as_mut() {
1017 buf.push((
1018 chosen_name.clone(),
1019 shadowed.iter().map(|s| (*s).to_owned()).collect(),
1020 ));
1021 }
1022 });
1023
1024 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
1025 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
1026 let dedupe_key = std::fs::canonicalize(dir)
1027 .unwrap_or_else(|_| dir.to_path_buf())
1028 .display()
1029 .to_string();
1030 if let Ok(mut set) = warned.lock()
1031 && !set.insert(dedupe_key)
1032 {
1033 return;
1034 }
1035
1036 tracing::warn!(
1037 "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
1038 fallow uses the first match in precedence order \
1039 (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
1040 remove the unused file(s) to silence this warning.",
1041 dir = dir.display(),
1042 chosen = chosen_name,
1043 shadowed = shadowed.join(", "),
1044 );
1045}
1046
1047fn load_with_fetcher<Fetcher: RemoteConfigFetcher>(
1048 path: &Path,
1049 options: ConfigLoadOptions,
1050 fetcher: &mut Fetcher,
1051) -> Result<FallowConfig, miette::Report> {
1052 let merged = ExtendsResolver::new(options, fetcher).resolve_local(path, 0)?;
1053 FallowConfig::from_merged(path, merged)
1054}
1055
1056impl FallowConfig {
1057 pub fn load(path: &Path) -> Result<Self, miette::Report> {
1079 Self::load_with_options(path, ConfigLoadOptions::default())
1080 }
1081
1082 pub fn load_with_options(
1093 path: &Path,
1094 options: ConfigLoadOptions,
1095 ) -> Result<Self, miette::Report> {
1096 let mut fetcher = NetworkRemoteConfigFetcher;
1097 load_with_fetcher(path, options, &mut fetcher)
1098 }
1099
1100 fn from_merged(path: &Path, merged: serde_json::Value) -> Result<Self, miette::Report> {
1101 warn_on_unknown_rule_keys(path, &merged);
1102
1103 let config: Self = serde_json::from_value(merged).map_err(|e| {
1104 miette::miette!(
1105 "Failed to deserialize config from {}: {}",
1106 path.display(),
1107 e
1108 )
1109 })?;
1110
1111 config.validate_user_globs().map_err(|errors| {
1112 let joined = errors
1113 .iter()
1114 .map(ToString::to_string)
1115 .collect::<Vec<_>>()
1116 .join("\n - ");
1117 miette::miette!("invalid config:\n - {}", joined)
1118 })?;
1119 if !config.security.request_receivers_are_valid() {
1120 return Err(miette::miette!(
1121 "invalid config:\n - security.requestReceivers entries must be non-empty strings"
1122 ));
1123 }
1124 let threshold_override_errors = config.health.threshold_override_errors();
1125 if !threshold_override_errors.is_empty() {
1126 return Err(miette::miette!(
1127 "invalid config:\n - {}",
1128 threshold_override_errors.join("\n - ")
1129 ));
1130 }
1131 if let Some(pattern) = &config.unused_component_props.ignore_pattern
1132 && let Err(e) = regex::Regex::new(pattern)
1133 {
1134 return Err(miette::miette!(
1135 "invalid config:\n - unusedComponentProps.ignorePattern is not a valid regex: {e}"
1136 ));
1137 }
1138
1139 Ok(config)
1140 }
1141
1142 pub fn validate_user_globs(
1172 &self,
1173 ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
1174 let mut errors = Vec::new();
1175
1176 self.validate_top_level_globs(&mut errors);
1177 self.validate_ignore_rule_globs(&mut errors);
1178 self.validate_boundary_globs(&mut errors);
1179
1180 for plugin in &self.framework {
1181 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
1182 errors.append(&mut plugin_errors);
1183 }
1184 }
1185
1186 if errors.is_empty() {
1187 Ok(())
1188 } else {
1189 Err(errors)
1190 }
1191 }
1192
1193 fn validate_top_level_globs(
1196 &self,
1197 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1198 ) {
1199 use super::glob_validation::{
1200 validate_user_finding_ignore_globs, validate_user_globs, validate_user_specifier_globs,
1201 };
1202
1203 validate_user_globs(&self.entry, "entry", errors);
1204 validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
1205 validate_user_finding_ignore_globs(&self.ignore_findings, "ignoreFindings", errors);
1206 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
1207 validate_user_specifier_globs(
1208 &self.ignore_unresolved_imports,
1209 "ignoreUnresolvedImports",
1210 errors,
1211 );
1212 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1213 validate_user_globs(&self.health.ignore, "health.ignore", errors);
1214 for override_entry in &self.health.threshold_overrides {
1215 validate_user_globs(
1216 &override_entry.files,
1217 "health.thresholdOverrides[].files",
1218 errors,
1219 );
1220 }
1221 for override_entry in &self.overrides {
1222 validate_user_globs(&override_entry.files, "overrides[].files", errors);
1223 }
1224 }
1225
1226 fn validate_ignore_rule_globs(
1228 &self,
1229 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1230 ) {
1231 use super::glob_validation::compile_user_glob;
1232
1233 for rule in &self.ignore_exports {
1234 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1235 errors.push(e);
1236 }
1237 }
1238
1239 for rule in &self.ignore_catalog_references {
1240 if let Some(consumer) = &rule.consumer
1241 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1242 {
1243 errors.push(e);
1244 }
1245 }
1246 }
1247
1248 fn validate_boundary_globs(
1251 &self,
1252 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1253 ) {
1254 use super::glob_validation::{
1255 validate_user_globs, validate_user_path, validate_user_paths,
1256 };
1257
1258 for zone in &self.boundaries.zones {
1259 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1260 if let Some(root) = &zone.root
1261 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1262 {
1263 errors.push(e);
1264 }
1265 validate_user_paths(
1266 &zone.auto_discover,
1267 "boundaries.zones[].autoDiscover",
1268 errors,
1269 );
1270 }
1271 validate_user_globs(
1272 &self.boundaries.coverage.allow_unmatched,
1273 "boundaries.coverage.allowUnmatched",
1274 errors,
1275 );
1276 }
1277
1278 #[must_use]
1281 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1282 let mut dir = start;
1283 loop {
1284 for name in CONFIG_NAMES {
1285 let candidate = dir.join(name);
1286 if candidate.exists() {
1287 return Some(candidate);
1288 }
1289 }
1290 if is_repo_root(dir) {
1291 break;
1292 }
1293 dir = dir.parent()?;
1294 }
1295 None
1296 }
1297
1298 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1304 Self::find_and_load_with_options(start, ConfigLoadOptions::default())
1305 }
1306
1307 pub fn find_and_load_with_options(
1314 start: &Path,
1315 options: ConfigLoadOptions,
1316 ) -> Result<Option<(Self, PathBuf)>, String> {
1317 let mut dir = start;
1318 loop {
1319 for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1320 let candidate = dir.join(name);
1321 if candidate.exists() {
1322 warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1323 match Self::load_with_options(&candidate, options) {
1324 Ok(config) => return Ok(Some((config, candidate))),
1325 Err(e) => {
1326 return Err(format!("Failed to parse {}: {e}", candidate.display()));
1327 }
1328 }
1329 }
1330 }
1331 if is_repo_root(dir) {
1332 break;
1333 }
1334 dir = match dir.parent() {
1335 Some(parent) => parent,
1336 None => break,
1337 };
1338 }
1339 Ok(None)
1340 }
1341
1342 #[must_use]
1344 pub fn json_schema() -> serde_json::Value {
1345 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1346 }
1347
1348 pub fn validate_resolved_boundaries(
1377 &self,
1378 root: &Path,
1379 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1380 use super::boundaries::ZoneValidationError;
1381
1382 let mut boundaries = self.boundaries.clone();
1383 if boundaries.preset.is_some() {
1384 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1385 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1386 .unwrap_or_else(|| "src".to_owned());
1387 boundaries.expand(&source_root);
1388 }
1389 let _logical_groups = boundaries.expand_auto_discover(root);
1390
1391 let mut errors: Vec<ZoneValidationError> = boundaries
1392 .validate_zone_references()
1393 .into_iter()
1394 .map(ZoneValidationError::UnknownZoneReference)
1395 .collect();
1396 errors.extend(
1397 boundaries
1398 .validate_root_prefixes()
1399 .into_iter()
1400 .map(ZoneValidationError::RedundantRootPrefix),
1401 );
1402 errors.extend(
1403 boundaries
1404 .validate_call_rules()
1405 .into_iter()
1406 .map(ZoneValidationError::InvalidForbiddenCallee),
1407 );
1408
1409 if errors.is_empty() {
1410 Ok(())
1411 } else {
1412 Err(errors)
1413 }
1414 }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use crate::CacheConfig;
1421 use crate::PackageJson;
1422 use crate::config::format::OutputFormat;
1423 use crate::config::rules::Severity;
1424
1425 fn test_dir(_name: &str) -> tempfile::TempDir {
1427 tempfile::tempdir().expect("create temp dir")
1428 }
1429
1430 #[derive(Default)]
1431 struct MockRemoteFetcher {
1432 responses: rustc_hash::FxHashMap<String, serde_json::Value>,
1433 requests: Vec<String>,
1434 }
1435
1436 impl MockRemoteFetcher {
1437 fn with_response(mut self, url: &str, value: serde_json::Value) -> Self {
1438 self.responses.insert(url.to_string(), value);
1439 self
1440 }
1441 }
1442
1443 impl RemoteConfigFetcher for MockRemoteFetcher {
1444 fn fetch(&mut self, url: &str, _source: &str) -> Result<serde_json::Value, miette::Report> {
1445 self.requests.push(url.to_string());
1446 self.responses
1447 .get(url)
1448 .cloned()
1449 .ok_or_else(|| miette::miette!("missing mock response for {url}"))
1450 }
1451 }
1452
1453 #[test]
1454 fn fallow_config_deserialize_minimal() {
1455 let toml_str = r#"
1456entry = ["src/main.ts"]
1457"#;
1458 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1459 assert_eq!(config.entry, vec!["src/main.ts"]);
1460 assert!(config.ignore_patterns.is_empty());
1461 }
1462
1463 #[test]
1464 fn fallow_config_deserialize_ignore_exports() {
1465 let toml_str = r#"
1466[[ignoreExports]]
1467file = "src/types/*.ts"
1468exports = ["*"]
1469
1470[[ignoreExports]]
1471file = "src/constants.ts"
1472exports = ["FOO", "BAR"]
1473"#;
1474 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1475 assert_eq!(config.ignore_exports.len(), 2);
1476 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1477 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1478 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1479 }
1480
1481 #[test]
1482 fn fallow_config_deserialize_ignore_dependencies() {
1483 let toml_str = r#"
1484ignoreDependencies = ["autoprefixer", "postcss"]
1485"#;
1486 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1487 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1488 }
1489
1490 #[test]
1491 fn fallow_config_deserialize_ignore_unresolved_imports() {
1492 let toml_str = r#"
1493ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1494"#;
1495 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1496 assert_eq!(
1497 config.ignore_unresolved_imports,
1498 vec!["@example/icons", "@example/icons/**", "../generated/**"]
1499 );
1500 }
1501
1502 #[test]
1503 fn fallow_config_resolve_default_ignores() {
1504 let config = FallowConfig::default();
1505 let resolved = config.resolve(
1506 PathBuf::from("/tmp/test"),
1507 OutputFormat::Human,
1508 4,
1509 true,
1510 true,
1511 None,
1512 );
1513
1514 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1515 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1516 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1517 assert!(resolved.ignore_patterns.is_match(".git/config"));
1518 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1519 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1520 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1521 }
1522
1523 #[test]
1524 fn fallow_config_resolve_custom_ignores() {
1525 let config = FallowConfig {
1526 entry: vec!["src/**/*.ts".to_string()],
1527 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1528 ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
1529 ..Default::default()
1530 };
1531 let resolved = config.resolve(
1532 PathBuf::from("/tmp/test"),
1533 OutputFormat::Json,
1534 4,
1535 false,
1536 true,
1537 None,
1538 );
1539
1540 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1541 assert!(
1542 resolved
1543 .ignore_findings
1544 .is_ignored("src/private/app.test.ts")
1545 );
1546 assert!(
1547 !resolved
1548 .ignore_findings
1549 .is_ignored("src/public/app.test.ts")
1550 );
1551 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1552 assert!(matches!(resolved.output, OutputFormat::Json));
1553 assert!(!resolved.no_cache);
1554 }
1555
1556 #[test]
1557 fn fallow_config_resolve_cache_dir() {
1558 let config = FallowConfig::default();
1559 let resolved = config.resolve(
1560 PathBuf::from("/tmp/project"),
1561 OutputFormat::Human,
1562 4,
1563 true,
1564 true,
1565 None,
1566 );
1567 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1568 assert!(resolved.no_cache);
1569 }
1570
1571 #[test]
1572 fn package_json_entry_points_main() {
1573 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1574 let entries = pkg.entry_points();
1575 assert!(entries.contains(&"dist/index.js".to_string()));
1576 }
1577
1578 #[test]
1579 fn package_json_entry_points_module() {
1580 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1581 let entries = pkg.entry_points();
1582 assert!(entries.contains(&"dist/index.mjs".to_string()));
1583 }
1584
1585 #[test]
1586 fn package_json_entry_points_types() {
1587 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1588 let entries = pkg.entry_points();
1589 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1590 }
1591
1592 #[test]
1593 fn package_json_entry_points_bin_string() {
1594 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1595 let entries = pkg.entry_points();
1596 assert!(entries.contains(&"bin/cli.js".to_string()));
1597 }
1598
1599 #[test]
1600 fn package_json_entry_points_bin_object() {
1601 let pkg: PackageJson =
1602 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1603 .unwrap();
1604 let entries = pkg.entry_points();
1605 assert!(entries.contains(&"bin/cli.js".to_string()));
1606 assert!(entries.contains(&"bin/serve.js".to_string()));
1607 }
1608
1609 #[test]
1610 fn package_json_entry_points_exports_string() {
1611 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1612 let entries = pkg.entry_points();
1613 assert!(entries.contains(&"./dist/index.js".to_string()));
1614 }
1615
1616 #[test]
1617 fn package_json_entry_points_exports_object() {
1618 let pkg: PackageJson = serde_json::from_str(
1619 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1620 )
1621 .unwrap();
1622 let entries = pkg.entry_points();
1623 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1624 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1625 }
1626
1627 #[test]
1628 fn package_json_dependency_names() {
1629 let pkg: PackageJson = serde_json::from_str(
1630 r#"{
1631 "dependencies": {"react": "^18", "lodash": "^4"},
1632 "devDependencies": {"typescript": "^5"},
1633 "peerDependencies": {"react-dom": "^18"}
1634 }"#,
1635 )
1636 .unwrap();
1637
1638 let all = pkg.all_dependency_names();
1639 assert!(all.contains(&"react".to_string()));
1640 assert!(all.contains(&"lodash".to_string()));
1641 assert!(all.contains(&"typescript".to_string()));
1642 assert!(all.contains(&"react-dom".to_string()));
1643
1644 let prod = pkg.production_dependency_names();
1645 assert!(prod.contains(&"react".to_string()));
1646 assert!(!prod.contains(&"typescript".to_string()));
1647
1648 let dev = pkg.dev_dependency_names();
1649 assert!(dev.contains(&"typescript".to_string()));
1650 assert!(!dev.contains(&"react".to_string()));
1651 }
1652
1653 #[test]
1654 fn package_json_no_dependencies() {
1655 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1656 assert!(pkg.all_dependency_names().is_empty());
1657 assert!(pkg.production_dependency_names().is_empty());
1658 assert!(pkg.dev_dependency_names().is_empty());
1659 assert!(pkg.entry_points().is_empty());
1660 }
1661
1662 #[test]
1663 fn rules_deserialize_toml_kebab_case() {
1664 let toml_str = r#"
1665[rules]
1666unused-files = "error"
1667unused-exports = "warn"
1668unused-types = "off"
1669"#;
1670 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1671 assert_eq!(config.rules.unused_files, Severity::Error);
1672 assert_eq!(config.rules.unused_exports, Severity::Warn);
1673 assert_eq!(config.rules.unused_types, Severity::Off);
1674 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1675 }
1676
1677 #[test]
1678 fn config_without_rules_defaults_to_error() {
1679 let toml_str = r#"
1680entry = ["src/main.ts"]
1681"#;
1682 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1683 assert_eq!(config.rules.unused_files, Severity::Error);
1684 assert_eq!(config.rules.unused_exports, Severity::Error);
1685 }
1686
1687 #[test]
1688 fn fallow_config_denies_unknown_fields() {
1689 let toml_str = r"
1690unknown_field = true
1691";
1692 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1693 assert!(result.is_err());
1694 }
1695
1696 #[test]
1697 fn fallow_config_deserialize_json() {
1698 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1699 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1700 assert_eq!(config.entry, vec!["src/main.ts"]);
1701 }
1702
1703 #[test]
1704 fn fallow_config_deserialize_jsonc() {
1705 let jsonc_str = r#"{
1706 "entry": ["src/main.ts"],
1707 "rules": {
1708 "unused-files": "warn"
1709 }
1710 }"#;
1711 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1712 assert_eq!(config.entry, vec!["src/main.ts"]);
1713 assert_eq!(config.rules.unused_files, Severity::Warn);
1714 }
1715
1716 #[test]
1717 fn fallow_config_json_with_schema_field() {
1718 let json_str =
1719 r#"{"$schema": "./node_modules/fallow/schema.json", "entry": ["src/main.ts"]}"#;
1720 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1721 assert_eq!(config.entry, vec!["src/main.ts"]);
1722 }
1723
1724 #[test]
1725 fn fallow_config_json_schema_generation() {
1726 let schema = FallowConfig::json_schema();
1727 assert!(schema.is_object());
1728 let obj = schema.as_object().unwrap();
1729 assert!(obj.contains_key("properties"));
1730 }
1731
1732 #[test]
1733 fn config_format_detection() {
1734 assert!(matches!(
1735 ConfigFormat::from_path(Path::new("fallow.toml")),
1736 ConfigFormat::Toml
1737 ));
1738 assert!(matches!(
1739 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1740 ConfigFormat::Json
1741 ));
1742 assert!(matches!(
1743 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1744 ConfigFormat::Json
1745 ));
1746 assert!(matches!(
1747 ConfigFormat::from_path(Path::new(".fallow.toml")),
1748 ConfigFormat::Toml
1749 ));
1750 }
1751
1752 #[test]
1753 fn config_names_priority_order() {
1754 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1755 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1756 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1757 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1758 }
1759
1760 #[test]
1761 fn load_json_config_file() {
1762 let dir = test_dir("json-config");
1763 let config_path = dir.path().join(".fallowrc.json");
1764 std::fs::write(
1765 &config_path,
1766 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1767 )
1768 .unwrap();
1769
1770 let config = FallowConfig::load(&config_path).unwrap();
1771 assert_eq!(config.entry, vec!["src/index.ts"]);
1772 assert_eq!(config.rules.unused_exports, Severity::Warn);
1773 }
1774
1775 #[test]
1776 fn load_json_config_file_with_health_threshold_override() {
1777 let dir = test_dir("json-health-threshold-override");
1778 let config_path = dir.path().join(".fallowrc.json");
1779 std::fs::write(
1780 &config_path,
1781 r#"{
1782 "health": {
1783 "thresholdOverrides": [
1784 {
1785 "files": ["src/legacy.ts"],
1786 "functions": ["legacyFlow"],
1787 "maxCyclomatic": 30,
1788 "maxCognitive": 25,
1789 "maxCrap": 80.5,
1790 "reason": "legacy migration"
1791 }
1792 ]
1793 }
1794 }"#,
1795 )
1796 .unwrap();
1797
1798 let config = FallowConfig::load(&config_path).unwrap();
1799 let override_config = &config.health.threshold_overrides[0];
1800 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1801 assert_eq!(override_config.functions, vec!["legacyFlow"]);
1802 assert_eq!(override_config.max_cyclomatic, Some(30));
1803 assert_eq!(override_config.max_cognitive, Some(25));
1804 assert_eq!(override_config.max_crap, Some(80.5));
1805 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1806 }
1807
1808 #[test]
1809 fn load_jsonc_config_file() {
1810 let dir = test_dir("jsonc-config");
1811 let config_path = dir.path().join(".fallowrc.json");
1812 std::fs::write(
1813 &config_path,
1814 r#"{
1815 "entry": ["src/index.ts"],
1816 /* Block comment */
1817 "rules": {
1818 "unused-exports": "warn"
1819 }
1820 }"#,
1821 )
1822 .unwrap();
1823
1824 let config = FallowConfig::load(&config_path).unwrap();
1825 assert_eq!(config.entry, vec!["src/index.ts"]);
1826 assert_eq!(config.rules.unused_exports, Severity::Warn);
1827 }
1828
1829 #[test]
1830 fn load_jsonc_config_file_with_health_threshold_override() {
1831 let dir = test_dir("jsonc-health-threshold-override");
1832 let config_path = dir.path().join(".fallowrc.jsonc");
1833 std::fs::write(
1834 &config_path,
1835 r#"{
1836 "health": {
1837 // Empty functions means every function in matching files.
1838 "thresholdOverrides": [
1839 { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1840 ]
1841 }
1842 }"#,
1843 )
1844 .unwrap();
1845
1846 let config = FallowConfig::load(&config_path).unwrap();
1847 let override_config = &config.health.threshold_overrides[0];
1848 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1849 assert!(override_config.functions.is_empty());
1850 assert_eq!(override_config.max_cognitive, Some(25));
1851 }
1852
1853 #[test]
1854 fn load_fallowrc_jsonc_extension() {
1855 let dir = test_dir("jsonc-extension");
1856 let config_path = dir.path().join(".fallowrc.jsonc");
1857 std::fs::write(
1858 &config_path,
1859 r#"{
1860 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1861 "entry": ["src/index.ts"]
1862 }"#,
1863 )
1864 .unwrap();
1865
1866 let config = FallowConfig::load(&config_path).unwrap();
1867 assert_eq!(config.entry, vec!["src/index.ts"]);
1868 assert_eq!(
1869 config.ignore_dependencies,
1870 vec!["tailwindcss-react-aria-components"]
1871 );
1872 }
1873
1874 #[test]
1875 fn json_config_ignore_dependencies_camel_case() {
1876 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1877 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1878 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1879 }
1880
1881 #[test]
1882 fn json_config_ignore_unresolved_imports_camel_case() {
1883 let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1884 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1885 assert_eq!(
1886 config.ignore_unresolved_imports,
1887 vec!["@example/icons", "@example/icons/**"]
1888 );
1889 }
1890
1891 #[test]
1892 fn json_config_all_fields() {
1893 let json_str = r#"{
1894 "ignoreDependencies": ["lodash"],
1895 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1896 "rules": {
1897 "unused-files": "off",
1898 "unused-exports": "warn",
1899 "unused-dependencies": "error",
1900 "unused-dev-dependencies": "off",
1901 "unused-types": "warn",
1902 "unused-enum-members": "error",
1903 "unused-class-members": "off",
1904 "unresolved-imports": "warn",
1905 "unlisted-dependencies": "error",
1906 "duplicate-exports": "off"
1907 },
1908 "duplicates": {
1909 "minTokens": 100,
1910 "minLines": 10,
1911 "skipLocal": true
1912 }
1913 }"#;
1914 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1915 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1916 assert_eq!(config.rules.unused_files, Severity::Off);
1917 assert_eq!(config.rules.unused_exports, Severity::Warn);
1918 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1919 assert_eq!(config.duplicates.min_tokens, 100);
1920 assert_eq!(config.duplicates.min_lines, 10);
1921 assert!(config.duplicates.skip_local);
1922 }
1923
1924 #[test]
1925 fn extends_single_base() {
1926 let dir = test_dir("extends-single");
1927
1928 std::fs::write(
1929 dir.path().join("base.json"),
1930 r#"{"rules": {"unused-files": "warn"}}"#,
1931 )
1932 .unwrap();
1933 std::fs::write(
1934 dir.path().join(".fallowrc.json"),
1935 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1936 )
1937 .unwrap();
1938
1939 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1940 assert_eq!(config.rules.unused_files, Severity::Warn);
1941 assert_eq!(config.entry, vec!["src/index.ts"]);
1942 assert_eq!(config.rules.unused_exports, Severity::Error);
1943 }
1944
1945 #[test]
1946 fn extends_overlay_overrides_base() {
1947 let dir = test_dir("extends-overlay");
1948
1949 std::fs::write(
1950 dir.path().join("base.json"),
1951 r#"{"ignoreFindings": ["generated/**"], "rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1952 )
1953 .unwrap();
1954 std::fs::write(
1955 dir.path().join(".fallowrc.json"),
1956 r#"{"extends": ["base.json"], "ignoreFindings": ["**/*.test.ts"], "rules": {"unused-files": "error"}}"#,
1957 )
1958 .unwrap();
1959
1960 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1961 assert_eq!(config.ignore_findings, vec!["**/*.test.ts"]);
1962 assert_eq!(config.rules.unused_files, Severity::Error);
1963 assert_eq!(config.rules.unused_exports, Severity::Off);
1964 }
1965
1966 #[test]
1967 fn extends_chained() {
1968 let dir = test_dir("extends-chained");
1969
1970 std::fs::write(
1971 dir.path().join("grandparent.json"),
1972 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1973 )
1974 .unwrap();
1975 std::fs::write(
1976 dir.path().join("parent.json"),
1977 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1978 )
1979 .unwrap();
1980 std::fs::write(
1981 dir.path().join(".fallowrc.json"),
1982 r#"{"extends": ["parent.json"]}"#,
1983 )
1984 .unwrap();
1985
1986 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1987 assert_eq!(config.rules.unused_files, Severity::Warn);
1988 assert_eq!(config.rules.unused_exports, Severity::Warn);
1989 }
1990
1991 #[test]
1992 fn extends_local_diamond_reuses_resolved_base() {
1993 let dir = test_dir("extends-local-diamond");
1994 std::fs::write(
1995 dir.path().join("base.json"),
1996 r#"{"ignorePatterns": ["generated/**"]}"#,
1997 )
1998 .unwrap();
1999 std::fs::write(
2000 dir.path().join("left.json"),
2001 r#"{"extends": ["base.json"], "rules": {"unused-files": "warn"}}"#,
2002 )
2003 .unwrap();
2004 std::fs::write(
2005 dir.path().join("right.json"),
2006 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2007 )
2008 .unwrap();
2009 std::fs::write(
2010 dir.path().join(".fallowrc.json"),
2011 r#"{"extends": ["left.json", "right.json"]}"#,
2012 )
2013 .unwrap();
2014
2015 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2016 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2017 assert_eq!(config.rules.unused_files, Severity::Warn);
2018 assert_eq!(config.rules.unused_exports, Severity::Off);
2019 }
2020
2021 #[test]
2022 fn extends_circular_detected() {
2023 let dir = test_dir("extends-circular");
2024
2025 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
2026 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
2027
2028 let result = FallowConfig::load(&dir.path().join("a.json"));
2029 assert!(result.is_err());
2030 let err_msg = format!("{}", result.unwrap_err());
2031 assert!(
2032 err_msg.contains("Circular extends"),
2033 "Expected circular error, got: {err_msg}"
2034 );
2035 }
2036
2037 #[test]
2038 fn remote_extends_are_denied_before_fetch_by_default() {
2039 let dir = test_dir("remote-default-denied");
2040 let url = "https://config-user:config-password@config.example:8443/base.json?token=config-token#config-fragment";
2041 std::fs::write(
2042 dir.path().join(".fallowrc.json"),
2043 format!(r#"{{"extends": "{url}"}}"#),
2044 )
2045 .unwrap();
2046 let mut fetcher = MockRemoteFetcher::default().with_response(url, serde_json::json!({}));
2047
2048 let error = load_with_fetcher(
2049 &dir.path().join(".fallowrc.json"),
2050 ConfigLoadOptions::default(),
2051 &mut fetcher,
2052 )
2053 .unwrap_err()
2054 .to_string();
2055
2056 assert!(
2057 error.contains("https://config.example:8443/base.json"),
2058 "denial must name the URL without secrets"
2059 );
2060 for secret in [
2061 "config-user",
2062 "config-password",
2063 "config-token",
2064 "config-fragment",
2065 ] {
2066 assert!(
2067 !error.contains(secret),
2068 "denial must not expose a secret value"
2069 );
2070 }
2071 assert!(
2072 error.contains("--allow-remote-extends"),
2073 "denial must name the CLI opt-in"
2074 );
2075 assert!(
2076 error.contains("ConfigLoadOptions"),
2077 "denial must name the library opt-in"
2078 );
2079 assert!(
2080 fetcher.requests.is_empty(),
2081 "denial must happen before fetch"
2082 );
2083 }
2084
2085 #[test]
2086 fn remote_parent_and_requested_url_secrets_are_redacted_in_errors() {
2087 let dir = test_dir("remote-error-redaction");
2088 let parent = "https://parent-user:parent-password@[2001:db8::1]:8443/base.json?token=parent-token#parent-fragment";
2089 let requested = "http://child-user:child-password@child.example/child.json?token=child-token#child-fragment";
2090 std::fs::write(
2091 dir.path().join(".fallowrc.json"),
2092 format!(r#"{{"extends": "{parent}"}}"#),
2093 )
2094 .unwrap();
2095 let mut fetcher = MockRemoteFetcher::default()
2096 .with_response(parent, serde_json::json!({"extends": requested}));
2097
2098 let error = load_with_fetcher(
2099 &dir.path().join(".fallowrc.json"),
2100 ConfigLoadOptions {
2101 allow_remote_extends: true,
2102 },
2103 &mut fetcher,
2104 )
2105 .unwrap_err()
2106 .to_string();
2107
2108 assert!(
2109 error.contains("http://child.example/child.json"),
2110 "error must preserve the requested host and path"
2111 );
2112 assert!(
2113 error.contains("https://[2001:db8::1]:8443/base.json"),
2114 "error must preserve the remote parent host, port, and path"
2115 );
2116 for secret in [
2117 "parent-user",
2118 "parent-password",
2119 "parent-token",
2120 "parent-fragment",
2121 "child-user",
2122 "child-password",
2123 "child-token",
2124 "child-fragment",
2125 ] {
2126 assert!(
2127 !error.contains(secret),
2128 "remote error must not expose a secret value"
2129 );
2130 }
2131 }
2132
2133 #[test]
2134 fn remote_fetch_network_error_redacts_requested_url_and_source() {
2135 let url = "https://request-user:request-password@127.0.0.1:0/config.json?token=request-token#request-fragment";
2136 let source = "https://source-user:source-password@source.example/parent.json?token=source-token#source-fragment";
2137
2138 let error = fetch_url_config(url, source)
2139 .expect_err("the reserved local port must reject the request")
2140 .to_string();
2141
2142 assert!(
2143 error.contains("https://127.0.0.1:0/config.json"),
2144 "network error must preserve the requested host, port, and path"
2145 );
2146 assert!(
2147 error.contains("https://source.example/parent.json"),
2148 "network error must preserve the source host and path"
2149 );
2150 for secret in [
2151 "request-user",
2152 "request-password",
2153 "request-token",
2154 "request-fragment",
2155 "source-user",
2156 "source-password",
2157 "source-token",
2158 "source-fragment",
2159 ] {
2160 assert!(
2161 !error.contains(secret),
2162 "network error must not expose a secret value"
2163 );
2164 }
2165 }
2166
2167 #[test]
2168 fn remote_fetch_error_detail_does_not_trust_normalized_urls() {
2169 let url = "https://request-user:request-password@config.example/config.json?token=request-token#request-fragment";
2170 let normalized_error = ureq::Error::BadUri(
2171 "https://request-user:request-password@config.example/config.json?token=request-token"
2172 .to_string(),
2173 );
2174
2175 assert!(
2176 remote_fetch_error_display(&normalized_error, url) == "request failed",
2177 "normalized network errors must not bypass URL redaction"
2178 );
2179 }
2180
2181 #[test]
2182 fn remote_fetch_error_detail_does_not_trust_redirect_like_payloads() {
2183 let original = "https://config.example/config.json";
2184 let error = ureq::Error::BadUri(
2185 "http://redirect-user:redirect-password@example.com/next.json?token=secret#anchor"
2186 .to_string(),
2187 );
2188
2189 let display = remote_fetch_error_display(&error, original);
2190 assert_eq!(display, "request failed");
2191 for secret in [
2192 "redirect-user",
2193 "redirect-password",
2194 "token=secret",
2195 "anchor",
2196 ] {
2197 assert!(
2198 !display.contains(secret),
2199 "untrusted error detail must be hidden"
2200 );
2201 }
2202 }
2203
2204 #[test]
2205 fn remote_extends_dispatch_when_explicitly_allowed() {
2206 let dir = test_dir("remote-explicitly-allowed");
2207 let url = "https://config.example/base.json";
2208 std::fs::write(
2209 dir.path().join(".fallowrc.json"),
2210 format!(r#"{{"extends": "{url}"}}"#),
2211 )
2212 .unwrap();
2213 let mut fetcher = MockRemoteFetcher::default()
2214 .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2215
2216 let config = load_with_fetcher(
2217 &dir.path().join(".fallowrc.json"),
2218 ConfigLoadOptions {
2219 allow_remote_extends: true,
2220 },
2221 &mut fetcher,
2222 )
2223 .unwrap();
2224
2225 assert_eq!(config.rules.unused_files, Severity::Warn);
2226 assert_eq!(fetcher.requests, vec![url]);
2227 }
2228
2229 #[test]
2230 fn uppercase_https_remote_extends_dispatch_when_allowed() {
2231 let dir = test_dir("remote-uppercase-scheme");
2232 let url = "HTTPS://config.example/base.json";
2233 std::fs::write(
2234 dir.path().join(".fallowrc.json"),
2235 format!(r#"{{"extends": "{url}"}}"#),
2236 )
2237 .unwrap();
2238 let mut fetcher = MockRemoteFetcher::default()
2239 .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2240
2241 let config = load_with_fetcher(
2242 &dir.path().join(".fallowrc.json"),
2243 ConfigLoadOptions {
2244 allow_remote_extends: true,
2245 },
2246 &mut fetcher,
2247 )
2248 .unwrap();
2249
2250 assert_eq!(config.rules.unused_files, Severity::Warn);
2251 assert_eq!(fetcher.requests, vec![url]);
2252 }
2253
2254 #[test]
2255 fn remote_extends_diamond_reuses_mocked_base() {
2256 let dir = test_dir("remote-diamond");
2257 let left = "https://config.example/left.json";
2258 let right = "https://config.example/right.json";
2259 let base = "https://config.example/base.json";
2260 std::fs::write(
2261 dir.path().join(".fallowrc.json"),
2262 format!(r#"{{"extends": ["{left}", "{right}"]}}"#),
2263 )
2264 .unwrap();
2265 let mut fetcher = MockRemoteFetcher::default()
2266 .with_response(
2267 left,
2268 serde_json::json!({
2269 "extends": base,
2270 "rules": {"unused-files": "warn"}
2271 }),
2272 )
2273 .with_response(
2274 right,
2275 serde_json::json!({
2276 "extends": base,
2277 "rules": {"unused-exports": "off"}
2278 }),
2279 )
2280 .with_response(
2281 base,
2282 serde_json::json!({"ignorePatterns": ["generated/**"]}),
2283 );
2284
2285 let config = load_with_fetcher(
2286 &dir.path().join(".fallowrc.json"),
2287 ConfigLoadOptions {
2288 allow_remote_extends: true,
2289 },
2290 &mut fetcher,
2291 )
2292 .unwrap();
2293
2294 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2295 assert_eq!(config.rules.unused_files, Severity::Warn);
2296 assert_eq!(config.rules.unused_exports, Severity::Off);
2297 assert_eq!(
2298 fetcher
2299 .requests
2300 .iter()
2301 .filter(|request| *request == base)
2302 .count(),
2303 1,
2304 "the shared remote base should be fetched once"
2305 );
2306 }
2307
2308 #[test]
2309 fn remote_extends_active_cycle_still_fails() {
2310 let dir = test_dir("remote-cycle");
2311 let first = "https://config.example/first.json";
2312 let second = "https://config.example/second.json";
2313 std::fs::write(
2314 dir.path().join(".fallowrc.json"),
2315 format!(r#"{{"extends": "{first}"}}"#),
2316 )
2317 .unwrap();
2318 let mut fetcher = MockRemoteFetcher::default()
2319 .with_response(first, serde_json::json!({"extends": second}))
2320 .with_response(second, serde_json::json!({"extends": first}));
2321
2322 let error = load_with_fetcher(
2323 &dir.path().join(".fallowrc.json"),
2324 ConfigLoadOptions {
2325 allow_remote_extends: true,
2326 },
2327 &mut fetcher,
2328 )
2329 .unwrap_err()
2330 .to_string();
2331
2332 assert!(
2333 error.contains("Circular extends"),
2334 "unexpected error: {error}"
2335 );
2336 }
2337
2338 #[test]
2339 fn query_distinct_remote_extends_remain_distinct() {
2340 let dir = test_dir("remote-query-distinct");
2341 let first = "https://config.example/base.json?profile=one";
2342 let second = "https://config.example/base.json?profile=two";
2343 std::fs::write(
2344 dir.path().join(".fallowrc.json"),
2345 format!(r#"{{"extends": ["{first}", "{second}"]}}"#),
2346 )
2347 .unwrap();
2348 let mut fetcher = MockRemoteFetcher::default()
2349 .with_response(
2350 first,
2351 serde_json::json!({"rules": {"unused-files": "warn"}}),
2352 )
2353 .with_response(
2354 second,
2355 serde_json::json!({"rules": {"unused-exports": "off"}}),
2356 );
2357
2358 let config = load_with_fetcher(
2359 &dir.path().join(".fallowrc.json"),
2360 ConfigLoadOptions {
2361 allow_remote_extends: true,
2362 },
2363 &mut fetcher,
2364 )
2365 .unwrap();
2366
2367 assert_eq!(config.rules.unused_files, Severity::Warn);
2368 assert_eq!(config.rules.unused_exports, Severity::Off);
2369 assert_eq!(fetcher.requests, vec![first, second]);
2370 }
2371
2372 #[test]
2373 fn extends_missing_file_errors() {
2374 let dir = test_dir("extends-missing");
2375
2376 std::fs::write(
2377 dir.path().join(".fallowrc.json"),
2378 r#"{"extends": ["nonexistent.json"]}"#,
2379 )
2380 .unwrap();
2381
2382 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2383 assert!(result.is_err());
2384 let err_msg = format!("{}", result.unwrap_err());
2385 assert!(
2386 err_msg.contains("not found"),
2387 "Expected not found error, got: {err_msg}"
2388 );
2389 }
2390
2391 #[test]
2392 fn sealed_allows_in_directory_extends() {
2393 let dir = test_dir("sealed-allows-local");
2394 std::fs::write(
2395 dir.path().join("base.json"),
2396 r#"{"ignorePatterns": ["gen/**"]}"#,
2397 )
2398 .unwrap();
2399 std::fs::write(
2400 dir.path().join(".fallowrc.json"),
2401 r#"{"sealed": true, "extends": ["./base.json"]}"#,
2402 )
2403 .unwrap();
2404
2405 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2406 assert!(config.sealed);
2407 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2408 }
2409
2410 #[test]
2411 fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
2412 let dir = test_dir("boundary-coverage-invalid-glob");
2413 std::fs::write(
2414 dir.path().join(".fallowrc.json"),
2415 r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
2416 )
2417 .unwrap();
2418
2419 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2420 assert!(result.is_err());
2421 let err_msg = format!("{}", result.unwrap_err());
2422 assert!(
2423 err_msg.contains("boundaries.coverage.allowUnmatched"),
2424 "expected coverage field in error, got: {err_msg}"
2425 );
2426 }
2427
2428 #[test]
2429 fn sealed_rejects_extends_escaping_directory() {
2430 let dir = test_dir("sealed-rejects-escape");
2431 let sub = dir.path().join("packages").join("app");
2432 std::fs::create_dir_all(&sub).unwrap();
2433
2434 std::fs::write(
2435 dir.path().join("base.json"),
2436 r#"{"ignorePatterns": ["dist/**"]}"#,
2437 )
2438 .unwrap();
2439 std::fs::write(
2440 sub.join(".fallowrc.json"),
2441 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
2442 )
2443 .unwrap();
2444
2445 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
2446 assert!(
2447 result.is_err(),
2448 "Expected sealed config to reject escaping extends"
2449 );
2450 let err_msg = format!("{}", result.unwrap_err());
2451 assert!(
2452 err_msg.contains("sealed"),
2453 "Error must mention sealed: {err_msg}"
2454 );
2455 assert!(
2456 err_msg.contains("outside the config's directory"),
2457 "Error must explain the constraint: {err_msg}"
2458 );
2459 }
2460
2461 #[test]
2462 fn sealed_rejects_https_extends() {
2463 let dir = test_dir("sealed-rejects-https");
2464 std::fs::write(
2465 dir.path().join(".fallowrc.json"),
2466 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
2467 )
2468 .unwrap();
2469
2470 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2471 assert!(result.is_err());
2472 let err_msg = format!("{}", result.unwrap_err());
2473 assert!(
2474 err_msg.contains("sealed"),
2475 "Error must mention sealed: {err_msg}"
2476 );
2477 assert!(
2478 err_msg.contains("URL extends"),
2479 "Error must mention URL: {err_msg}"
2480 );
2481 }
2482
2483 #[test]
2484 fn sealed_rejects_npm_extends() {
2485 let dir = test_dir("sealed-rejects-npm");
2486 std::fs::write(
2487 dir.path().join(".fallowrc.json"),
2488 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
2489 )
2490 .unwrap();
2491
2492 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2493 assert!(result.is_err());
2494 let err_msg = format!("{}", result.unwrap_err());
2495 assert!(
2496 err_msg.contains("sealed"),
2497 "Error must mention sealed: {err_msg}"
2498 );
2499 assert!(
2500 err_msg.contains("npm extends"),
2501 "Error must mention npm: {err_msg}"
2502 );
2503 }
2504
2505 #[test]
2506 fn sealed_default_is_false() {
2507 let dir = test_dir("sealed-default");
2508 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
2509 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2510 assert!(!config.sealed);
2511 }
2512
2513 #[test]
2514 fn sealed_false_allows_escaping_extends() {
2515 let dir = test_dir("sealed-false-allows");
2516 let sub = dir.path().join("packages").join("app");
2517 std::fs::create_dir_all(&sub).unwrap();
2518
2519 std::fs::write(
2520 dir.path().join("base.json"),
2521 r#"{"ignorePatterns": ["dist/**"]}"#,
2522 )
2523 .unwrap();
2524 std::fs::write(
2525 sub.join(".fallowrc.json"),
2526 r#"{"extends": ["../../base.json"]}"#,
2527 )
2528 .unwrap();
2529
2530 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2531 assert!(!config.sealed);
2532 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2533 }
2534
2535 #[test]
2536 fn extends_string_sugar() {
2537 let dir = test_dir("extends-string");
2538
2539 std::fs::write(
2540 dir.path().join("base.json"),
2541 r#"{"ignorePatterns": ["gen/**"]}"#,
2542 )
2543 .unwrap();
2544 std::fs::write(
2545 dir.path().join(".fallowrc.json"),
2546 r#"{"extends": "base.json"}"#,
2547 )
2548 .unwrap();
2549
2550 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2551 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2552 }
2553
2554 #[test]
2555 fn extends_deep_merge_preserves_arrays() {
2556 let dir = test_dir("extends-array");
2557
2558 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
2559 std::fs::write(
2560 dir.path().join(".fallowrc.json"),
2561 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
2562 )
2563 .unwrap();
2564
2565 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2566 assert_eq!(config.entry, vec!["src/b.ts"]);
2567 }
2568
2569 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
2570 let pkg_dir = root.join("node_modules").join(name);
2571 std::fs::create_dir_all(&pkg_dir).unwrap();
2572 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
2573 }
2574
2575 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
2576 let pkg_dir = root.join("node_modules").join(name);
2577 std::fs::create_dir_all(&pkg_dir).unwrap();
2578 std::fs::write(
2579 pkg_dir.join("package.json"),
2580 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
2581 )
2582 .unwrap();
2583 std::fs::write(pkg_dir.join(main), config_json).unwrap();
2584 }
2585
2586 #[test]
2587 fn extends_npm_basic_unscoped() {
2588 let dir = test_dir("npm-basic");
2589 create_npm_package(
2590 dir.path(),
2591 "fallow-config-acme",
2592 r#"{"rules": {"unused-files": "warn"}}"#,
2593 );
2594 std::fs::write(
2595 dir.path().join(".fallowrc.json"),
2596 r#"{"extends": "npm:fallow-config-acme"}"#,
2597 )
2598 .unwrap();
2599
2600 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2601 assert_eq!(config.rules.unused_files, Severity::Warn);
2602 }
2603
2604 #[test]
2605 fn extends_npm_scoped_package() {
2606 let dir = test_dir("npm-scoped");
2607 create_npm_package(
2608 dir.path(),
2609 "@company/fallow-config",
2610 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
2611 );
2612 std::fs::write(
2613 dir.path().join(".fallowrc.json"),
2614 r#"{"extends": "npm:@company/fallow-config"}"#,
2615 )
2616 .unwrap();
2617
2618 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2619 assert_eq!(config.rules.unused_exports, Severity::Off);
2620 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2621 }
2622
2623 #[test]
2624 fn extends_npm_with_subpath() {
2625 let dir = test_dir("npm-subpath");
2626 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2627 std::fs::create_dir_all(&pkg_dir).unwrap();
2628 std::fs::write(
2629 pkg_dir.join("strict.json"),
2630 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2631 )
2632 .unwrap();
2633
2634 std::fs::write(
2635 dir.path().join(".fallowrc.json"),
2636 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2637 )
2638 .unwrap();
2639
2640 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2641 assert_eq!(config.rules.unused_files, Severity::Error);
2642 assert_eq!(config.rules.unused_exports, Severity::Error);
2643 }
2644
2645 #[test]
2646 fn extends_npm_package_json_main() {
2647 let dir = test_dir("npm-main");
2648 create_npm_package_with_main(
2649 dir.path(),
2650 "fallow-config-acme",
2651 "config.json",
2652 r#"{"rules": {"unused-types": "off"}}"#,
2653 );
2654 std::fs::write(
2655 dir.path().join(".fallowrc.json"),
2656 r#"{"extends": "npm:fallow-config-acme"}"#,
2657 )
2658 .unwrap();
2659
2660 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2661 assert_eq!(config.rules.unused_types, Severity::Off);
2662 }
2663
2664 #[test]
2665 fn extends_npm_package_json_exports_string() {
2666 let dir = test_dir("npm-exports-str");
2667 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2668 std::fs::create_dir_all(&pkg_dir).unwrap();
2669 std::fs::write(
2670 pkg_dir.join("package.json"),
2671 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2672 )
2673 .unwrap();
2674 std::fs::write(
2675 pkg_dir.join("base.json"),
2676 r#"{"rules": {"circular-dependencies": "warn"}}"#,
2677 )
2678 .unwrap();
2679
2680 std::fs::write(
2681 dir.path().join(".fallowrc.json"),
2682 r#"{"extends": "npm:fallow-config-co"}"#,
2683 )
2684 .unwrap();
2685
2686 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2687 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2688 }
2689
2690 #[test]
2691 fn extends_npm_package_json_exports_object() {
2692 let dir = test_dir("npm-exports-obj");
2693 let pkg_dir = dir.path().join("node_modules/@co/cfg");
2694 std::fs::create_dir_all(&pkg_dir).unwrap();
2695 std::fs::write(
2696 pkg_dir.join("package.json"),
2697 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2698 )
2699 .unwrap();
2700 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2701
2702 std::fs::write(
2703 dir.path().join(".fallowrc.json"),
2704 r#"{"extends": "npm:@co/cfg"}"#,
2705 )
2706 .unwrap();
2707
2708 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2709 assert_eq!(config.entry, vec!["src/app.ts"]);
2710 }
2711
2712 #[test]
2713 fn extends_npm_exports_takes_priority_over_main() {
2714 let dir = test_dir("npm-exports-prio");
2715 let pkg_dir = dir.path().join("node_modules/my-config");
2716 std::fs::create_dir_all(&pkg_dir).unwrap();
2717 std::fs::write(
2718 pkg_dir.join("package.json"),
2719 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2720 )
2721 .unwrap();
2722 std::fs::write(
2723 pkg_dir.join("old.json"),
2724 r#"{"rules": {"unused-files": "off"}}"#,
2725 )
2726 .unwrap();
2727 std::fs::write(
2728 pkg_dir.join("new.json"),
2729 r#"{"rules": {"unused-files": "warn"}}"#,
2730 )
2731 .unwrap();
2732
2733 std::fs::write(
2734 dir.path().join(".fallowrc.json"),
2735 r#"{"extends": "npm:my-config"}"#,
2736 )
2737 .unwrap();
2738
2739 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2740 assert_eq!(config.rules.unused_files, Severity::Warn);
2741 }
2742
2743 #[test]
2744 fn extends_npm_walk_up_directories() {
2745 let dir = test_dir("npm-walkup");
2746 create_npm_package(
2747 dir.path(),
2748 "shared-config",
2749 r#"{"rules": {"unused-files": "warn"}}"#,
2750 );
2751 let sub = dir.path().join("packages/app");
2752 std::fs::create_dir_all(&sub).unwrap();
2753 std::fs::write(
2754 sub.join(".fallowrc.json"),
2755 r#"{"extends": "npm:shared-config"}"#,
2756 )
2757 .unwrap();
2758
2759 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2760 assert_eq!(config.rules.unused_files, Severity::Warn);
2761 }
2762
2763 #[test]
2764 fn extends_npm_overlay_overrides_base() {
2765 let dir = test_dir("npm-overlay");
2766 create_npm_package(
2767 dir.path(),
2768 "@company/base",
2769 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2770 );
2771 std::fs::write(
2772 dir.path().join(".fallowrc.json"),
2773 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2774 )
2775 .unwrap();
2776
2777 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2778 assert_eq!(config.rules.unused_files, Severity::Error);
2779 assert_eq!(config.rules.unused_exports, Severity::Off);
2780 assert_eq!(config.entry, vec!["src/app.ts"]);
2781 }
2782
2783 #[test]
2784 fn extends_npm_chained_with_relative() {
2785 let dir = test_dir("npm-chained");
2786 let pkg_dir = dir.path().join("node_modules/my-config");
2787 std::fs::create_dir_all(&pkg_dir).unwrap();
2788 std::fs::write(
2789 pkg_dir.join("base.json"),
2790 r#"{"rules": {"unused-files": "warn"}}"#,
2791 )
2792 .unwrap();
2793 std::fs::write(
2794 pkg_dir.join(".fallowrc.json"),
2795 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2796 )
2797 .unwrap();
2798
2799 std::fs::write(
2800 dir.path().join(".fallowrc.json"),
2801 r#"{"extends": "npm:my-config"}"#,
2802 )
2803 .unwrap();
2804
2805 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2806 assert_eq!(config.rules.unused_files, Severity::Warn);
2807 assert_eq!(config.rules.unused_exports, Severity::Off);
2808 }
2809
2810 #[test]
2811 fn extends_npm_mixed_with_relative_paths() {
2812 let dir = test_dir("npm-mixed");
2813 create_npm_package(
2814 dir.path(),
2815 "shared-base",
2816 r#"{"rules": {"unused-files": "off"}}"#,
2817 );
2818 std::fs::write(
2819 dir.path().join("local-overrides.json"),
2820 r#"{"rules": {"unused-files": "warn"}}"#,
2821 )
2822 .unwrap();
2823 std::fs::write(
2824 dir.path().join(".fallowrc.json"),
2825 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2826 )
2827 .unwrap();
2828
2829 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2830 assert_eq!(config.rules.unused_files, Severity::Warn);
2831 }
2832
2833 #[test]
2834 fn extends_npm_missing_package_errors() {
2835 let dir = test_dir("npm-missing");
2836 std::fs::write(
2837 dir.path().join(".fallowrc.json"),
2838 r#"{"extends": "npm:nonexistent-package"}"#,
2839 )
2840 .unwrap();
2841
2842 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2843 assert!(result.is_err());
2844 let err_msg = format!("{}", result.unwrap_err());
2845 assert!(
2846 err_msg.contains("not found"),
2847 "Expected 'not found' error, got: {err_msg}"
2848 );
2849 assert!(
2850 err_msg.contains("nonexistent-package"),
2851 "Expected package name in error, got: {err_msg}"
2852 );
2853 assert!(
2854 err_msg.contains("install it"),
2855 "Expected install hint in error, got: {err_msg}"
2856 );
2857 }
2858
2859 #[test]
2860 fn extends_npm_no_config_in_package_errors() {
2861 let dir = test_dir("npm-no-config");
2862 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2863 std::fs::create_dir_all(&pkg_dir).unwrap();
2864 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2865
2866 std::fs::write(
2867 dir.path().join(".fallowrc.json"),
2868 r#"{"extends": "npm:empty-pkg"}"#,
2869 )
2870 .unwrap();
2871
2872 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2873 assert!(result.is_err());
2874 let err_msg = format!("{}", result.unwrap_err());
2875 assert!(
2876 err_msg.contains("No fallow config found"),
2877 "Expected 'No fallow config found' error, got: {err_msg}"
2878 );
2879 }
2880
2881 #[test]
2882 fn extends_npm_missing_subpath_errors() {
2883 let dir = test_dir("npm-missing-sub");
2884 let pkg_dir = dir.path().join("node_modules/@co/config");
2885 std::fs::create_dir_all(&pkg_dir).unwrap();
2886
2887 std::fs::write(
2888 dir.path().join(".fallowrc.json"),
2889 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2890 )
2891 .unwrap();
2892
2893 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2894 assert!(result.is_err());
2895 let err_msg = format!("{}", result.unwrap_err());
2896 assert!(
2897 err_msg.contains("nonexistent.json"),
2898 "Expected subpath in error, got: {err_msg}"
2899 );
2900 }
2901
2902 #[test]
2903 fn extends_npm_empty_specifier_errors() {
2904 let dir = test_dir("npm-empty");
2905 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2906
2907 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2908 assert!(result.is_err());
2909 let err_msg = format!("{}", result.unwrap_err());
2910 assert!(
2911 err_msg.contains("Empty npm specifier"),
2912 "Expected 'Empty npm specifier' error, got: {err_msg}"
2913 );
2914 }
2915
2916 #[test]
2917 fn extends_npm_space_after_colon_trimmed() {
2918 let dir = test_dir("npm-space");
2919 create_npm_package(
2920 dir.path(),
2921 "fallow-config-acme",
2922 r#"{"rules": {"unused-files": "warn"}}"#,
2923 );
2924 std::fs::write(
2925 dir.path().join(".fallowrc.json"),
2926 r#"{"extends": "npm: fallow-config-acme"}"#,
2927 )
2928 .unwrap();
2929
2930 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2931 assert_eq!(config.rules.unused_files, Severity::Warn);
2932 }
2933
2934 #[test]
2935 fn extends_npm_exports_node_condition() {
2936 let dir = test_dir("npm-node-cond");
2937 let pkg_dir = dir.path().join("node_modules/node-config");
2938 std::fs::create_dir_all(&pkg_dir).unwrap();
2939 std::fs::write(
2940 pkg_dir.join("package.json"),
2941 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2942 )
2943 .unwrap();
2944 std::fs::write(
2945 pkg_dir.join("node.json"),
2946 r#"{"rules": {"unused-files": "off"}}"#,
2947 )
2948 .unwrap();
2949
2950 std::fs::write(
2951 dir.path().join(".fallowrc.json"),
2952 r#"{"extends": "npm:node-config"}"#,
2953 )
2954 .unwrap();
2955
2956 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2957 assert_eq!(config.rules.unused_files, Severity::Off);
2958 }
2959
2960 #[test]
2961 fn parse_npm_specifier_unscoped() {
2962 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2963 }
2964
2965 #[test]
2966 fn parse_npm_specifier_unscoped_with_subpath() {
2967 assert_eq!(
2968 parse_npm_specifier("my-config/strict.json"),
2969 ("my-config", Some("strict.json"))
2970 );
2971 }
2972
2973 #[test]
2974 fn parse_npm_specifier_scoped() {
2975 assert_eq!(
2976 parse_npm_specifier("@company/fallow-config"),
2977 ("@company/fallow-config", None)
2978 );
2979 }
2980
2981 #[test]
2982 fn parse_npm_specifier_scoped_with_subpath() {
2983 assert_eq!(
2984 parse_npm_specifier("@company/fallow-config/strict.json"),
2985 ("@company/fallow-config", Some("strict.json"))
2986 );
2987 }
2988
2989 #[test]
2990 fn parse_npm_specifier_scoped_with_nested_subpath() {
2991 assert_eq!(
2992 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2993 ("@company/fallow-config", Some("presets/strict.json"))
2994 );
2995 }
2996
2997 #[test]
2998 fn extends_npm_subpath_traversal_rejected() {
2999 let dir = test_dir("npm-traversal-sub");
3000 let pkg_dir = dir.path().join("node_modules/evil-pkg");
3001 std::fs::create_dir_all(&pkg_dir).unwrap();
3002 std::fs::write(
3003 dir.path().join("secret.json"),
3004 r#"{"entry": ["stolen.ts"]}"#,
3005 )
3006 .unwrap();
3007
3008 std::fs::write(
3009 dir.path().join(".fallowrc.json"),
3010 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
3011 )
3012 .unwrap();
3013
3014 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3015 assert!(result.is_err());
3016 let err_msg = format!("{}", result.unwrap_err());
3017 assert!(
3018 err_msg.contains("traversal") || err_msg.contains("not found"),
3019 "Expected traversal or not-found error, got: {err_msg}"
3020 );
3021 }
3022
3023 #[test]
3024 fn extends_npm_dotdot_package_name_rejected() {
3025 let dir = test_dir("npm-dotdot-name");
3026 std::fs::write(
3027 dir.path().join(".fallowrc.json"),
3028 r#"{"extends": "npm:../relative"}"#,
3029 )
3030 .unwrap();
3031
3032 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3033 assert!(result.is_err());
3034 let err_msg = format!("{}", result.unwrap_err());
3035 assert!(
3036 err_msg.contains("path traversal"),
3037 "Expected 'path traversal' error, got: {err_msg}"
3038 );
3039 }
3040
3041 #[test]
3042 fn extends_npm_scoped_without_name_rejected() {
3043 let dir = test_dir("npm-scope-only");
3044 std::fs::write(
3045 dir.path().join(".fallowrc.json"),
3046 r#"{"extends": "npm:@scope"}"#,
3047 )
3048 .unwrap();
3049
3050 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3051 assert!(result.is_err());
3052 let err_msg = format!("{}", result.unwrap_err());
3053 assert!(
3054 err_msg.contains("@scope/name"),
3055 "Expected scoped name format error, got: {err_msg}"
3056 );
3057 }
3058
3059 #[test]
3060 fn extends_npm_malformed_package_json_errors() {
3061 let dir = test_dir("npm-bad-pkgjson");
3062 let pkg_dir = dir.path().join("node_modules/bad-pkg");
3063 std::fs::create_dir_all(&pkg_dir).unwrap();
3064 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
3065
3066 std::fs::write(
3067 dir.path().join(".fallowrc.json"),
3068 r#"{"extends": "npm:bad-pkg"}"#,
3069 )
3070 .unwrap();
3071
3072 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3073 assert!(result.is_err());
3074 let err_msg = format!("{}", result.unwrap_err());
3075 assert!(
3076 err_msg.contains("Failed to parse"),
3077 "Expected parse error, got: {err_msg}"
3078 );
3079 }
3080
3081 #[test]
3082 fn extends_npm_exports_traversal_rejected() {
3083 let dir = test_dir("npm-exports-escape");
3084 let pkg_dir = dir.path().join("node_modules/evil-exports");
3085 std::fs::create_dir_all(&pkg_dir).unwrap();
3086 std::fs::write(
3087 pkg_dir.join("package.json"),
3088 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
3089 )
3090 .unwrap();
3091 std::fs::write(
3092 dir.path().join("secret.json"),
3093 r#"{"entry": ["stolen.ts"]}"#,
3094 )
3095 .unwrap();
3096
3097 std::fs::write(
3098 dir.path().join(".fallowrc.json"),
3099 r#"{"extends": "npm:evil-exports"}"#,
3100 )
3101 .unwrap();
3102
3103 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3104 assert!(result.is_err());
3105 let err_msg = format!("{}", result.unwrap_err());
3106 assert!(
3107 err_msg.contains("traversal"),
3108 "Expected traversal error, got: {err_msg}"
3109 );
3110 }
3111
3112 #[test]
3113 fn deep_merge_scalar_overlay_replaces_base() {
3114 let mut base = serde_json::json!("hello");
3115 deep_merge_json(&mut base, serde_json::json!("world"));
3116 assert_eq!(base, serde_json::json!("world"));
3117 }
3118
3119 #[test]
3120 fn deep_merge_array_overlay_replaces_base() {
3121 let mut base = serde_json::json!(["a", "b"]);
3122 deep_merge_json(&mut base, serde_json::json!(["c"]));
3123 assert_eq!(base, serde_json::json!(["c"]));
3124 }
3125
3126 #[test]
3127 fn deep_merge_nested_object_merge() {
3128 let mut base = serde_json::json!({
3129 "level1": {
3130 "level2": {
3131 "a": 1,
3132 "b": 2
3133 }
3134 }
3135 });
3136 let overlay = serde_json::json!({
3137 "level1": {
3138 "level2": {
3139 "b": 99,
3140 "c": 3
3141 }
3142 }
3143 });
3144 deep_merge_json(&mut base, overlay);
3145 assert_eq!(base["level1"]["level2"]["a"], 1);
3146 assert_eq!(base["level1"]["level2"]["b"], 99);
3147 assert_eq!(base["level1"]["level2"]["c"], 3);
3148 }
3149
3150 #[test]
3151 fn deep_merge_overlay_adds_new_fields() {
3152 let mut base = serde_json::json!({"existing": true});
3153 let overlay = serde_json::json!({"new_field": "added", "another": 42});
3154 deep_merge_json(&mut base, overlay);
3155 assert_eq!(base["existing"], true);
3156 assert_eq!(base["new_field"], "added");
3157 assert_eq!(base["another"], 42);
3158 }
3159
3160 #[test]
3161 fn deep_merge_null_overlay_replaces_object() {
3162 let mut base = serde_json::json!({"key": "value"});
3163 deep_merge_json(&mut base, serde_json::json!(null));
3164 assert_eq!(base, serde_json::json!(null));
3165 }
3166
3167 #[test]
3168 fn deep_merge_empty_object_overlay_preserves_base() {
3169 let mut base = serde_json::json!({"a": 1, "b": 2});
3170 deep_merge_json(&mut base, serde_json::json!({}));
3171 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3172 }
3173
3174 #[test]
3175 fn rules_severity_error_warn_off_from_json() {
3176 let json_str = r#"{
3177 "rules": {
3178 "unused-files": "error",
3179 "unused-exports": "warn",
3180 "unused-types": "off"
3181 }
3182 }"#;
3183 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3184 assert_eq!(config.rules.unused_files, Severity::Error);
3185 assert_eq!(config.rules.unused_exports, Severity::Warn);
3186 assert_eq!(config.rules.unused_types, Severity::Off);
3187 }
3188
3189 #[test]
3190 fn rules_omitted_default_to_error() {
3191 let json_str = r#"{
3192 "rules": {
3193 "unused-files": "warn"
3194 }
3195 }"#;
3196 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3197 assert_eq!(config.rules.unused_files, Severity::Warn);
3198 assert_eq!(config.rules.unused_exports, Severity::Error);
3199 assert_eq!(config.rules.unused_types, Severity::Error);
3200 assert_eq!(config.rules.unused_dependencies, Severity::Error);
3201 assert_eq!(config.rules.unresolved_imports, Severity::Error);
3202 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
3203 assert_eq!(config.rules.duplicate_exports, Severity::Error);
3204 assert_eq!(config.rules.circular_dependencies, Severity::Error);
3205 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
3206 }
3207
3208 #[test]
3209 fn find_and_load_returns_none_when_no_config() {
3210 let dir = test_dir("find-none");
3211 std::fs::create_dir(dir.path().join(".git")).unwrap();
3212
3213 let result = FallowConfig::find_and_load(dir.path()).unwrap();
3214 assert!(result.is_none());
3215 }
3216
3217 #[test]
3218 fn find_and_load_finds_fallowrc_json() {
3219 let dir = test_dir("find-json");
3220 std::fs::create_dir(dir.path().join(".git")).unwrap();
3221 std::fs::write(
3222 dir.path().join(".fallowrc.json"),
3223 r#"{"entry": ["src/main.ts"]}"#,
3224 )
3225 .unwrap();
3226
3227 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3228 assert_eq!(config.entry, vec!["src/main.ts"]);
3229 assert!(path.ends_with(".fallowrc.json"));
3230 }
3231
3232 #[test]
3233 fn find_and_load_finds_fallowrc_jsonc() {
3234 let dir = test_dir("find-jsonc");
3235 std::fs::create_dir(dir.path().join(".git")).unwrap();
3236 std::fs::write(
3237 dir.path().join(".fallowrc.jsonc"),
3238 r#"{
3239 "entry": ["src/main.ts"]
3240 }"#,
3241 )
3242 .unwrap();
3243
3244 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3245 assert_eq!(config.entry, vec!["src/main.ts"]);
3246 assert!(path.ends_with(".fallowrc.jsonc"));
3247 }
3248
3249 #[test]
3250 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
3251 let dir = test_dir("find-json-vs-jsonc");
3252 std::fs::create_dir(dir.path().join(".git")).unwrap();
3253 std::fs::write(
3254 dir.path().join(".fallowrc.json"),
3255 r#"{"entry": ["from-json.ts"]}"#,
3256 )
3257 .unwrap();
3258 std::fs::write(
3259 dir.path().join(".fallowrc.jsonc"),
3260 r#"{"entry": ["from-jsonc.ts"]}"#,
3261 )
3262 .unwrap();
3263
3264 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3265 assert_eq!(config.entry, vec!["from-json.ts"]);
3266 assert!(path.ends_with(".fallowrc.json"));
3267 }
3268
3269 #[test]
3270 fn find_and_load_prefers_fallowrc_json_over_toml() {
3271 let dir = test_dir("find-priority");
3272 std::fs::create_dir(dir.path().join(".git")).unwrap();
3273 std::fs::write(
3274 dir.path().join(".fallowrc.json"),
3275 r#"{"entry": ["from-json.ts"]}"#,
3276 )
3277 .unwrap();
3278 std::fs::write(
3279 dir.path().join("fallow.toml"),
3280 "entry = [\"from-toml.ts\"]\n",
3281 )
3282 .unwrap();
3283
3284 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3285 assert_eq!(config.entry, vec!["from-json.ts"]);
3286 assert!(path.ends_with(".fallowrc.json"));
3287 }
3288
3289 #[test]
3290 fn shadowed_config_names_empty_when_single_config() {
3291 let dir = test_dir("shadow-single");
3292 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3293 assert!(shadowed_config_names(dir.path(), 0).is_empty());
3294 }
3295
3296 #[test]
3297 fn shadowed_config_names_reports_lower_precedence_toml() {
3298 let dir = test_dir("shadow-json-toml");
3299 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3300 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3301 assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
3302 }
3303
3304 #[test]
3305 fn shadowed_config_names_reports_jsonc_sibling() {
3306 let dir = test_dir("shadow-json-jsonc");
3307 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3308 std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
3309 assert_eq!(
3310 shadowed_config_names(dir.path(), 0),
3311 vec![".fallowrc.jsonc"]
3312 );
3313 }
3314
3315 #[test]
3316 fn shadowed_config_names_reports_all_lower_when_four_coexist() {
3317 let dir = test_dir("shadow-all-four");
3318 for name in CONFIG_NAMES {
3319 std::fs::write(dir.path().join(name), "").unwrap();
3320 }
3321 assert_eq!(
3322 shadowed_config_names(dir.path(), 0),
3323 vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
3324 );
3325 }
3326
3327 #[test]
3328 fn shadowed_config_names_scoped_to_indices_after_winner() {
3329 let dir = test_dir("shadow-toml-dottoml");
3330 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3331 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
3332 assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
3333 }
3334
3335 #[test]
3336 fn find_and_load_warns_when_configs_coexist() {
3337 let dir = test_dir("coexist-warn");
3338 std::fs::create_dir(dir.path().join(".git")).unwrap();
3339 std::fs::write(
3340 dir.path().join(".fallowrc.json"),
3341 r#"{"entry": ["from-json.ts"]}"#,
3342 )
3343 .unwrap();
3344 std::fs::write(
3345 dir.path().join("fallow.toml"),
3346 "entry = [\"from-toml.ts\"]\n",
3347 )
3348 .unwrap();
3349
3350 let (result, captured) =
3351 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3352
3353 let (config, path) = result.unwrap().unwrap();
3354 assert_eq!(config.entry, vec!["from-json.ts"]);
3355 assert!(path.ends_with(".fallowrc.json"));
3356
3357 assert_eq!(captured.len(), 1);
3358 let (chosen, shadowed) = &captured[0];
3359 assert_eq!(chosen, ".fallowrc.json");
3360 assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
3361 }
3362
3363 #[test]
3364 fn find_and_load_does_not_warn_for_single_config() {
3365 let dir = test_dir("coexist-none");
3366 std::fs::create_dir(dir.path().join(".git")).unwrap();
3367 std::fs::write(
3368 dir.path().join(".fallowrc.json"),
3369 r#"{"entry": ["only.ts"]}"#,
3370 )
3371 .unwrap();
3372
3373 let (result, captured) =
3374 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3375 assert!(result.unwrap().is_some());
3376 assert!(captured.is_empty());
3377 }
3378
3379 #[test]
3380 fn find_and_load_warns_per_directory_independently() {
3381 let make = |name: &str| {
3382 let dir = test_dir(name);
3383 std::fs::create_dir(dir.path().join(".git")).unwrap();
3384 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
3385 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
3386 dir
3387 };
3388 let first = make("coexist-dir-a");
3389 let second = make("coexist-dir-b");
3390
3391 let ((), captured) = capture_coexisting_config_warnings(|| {
3392 FallowConfig::find_and_load(first.path()).unwrap();
3393 FallowConfig::find_and_load(second.path()).unwrap();
3394 });
3395
3396 assert_eq!(captured.len(), 2);
3397 assert!(captured.iter().all(|(chosen, shadowed)| {
3398 chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
3399 }));
3400 }
3401
3402 #[test]
3403 fn explicit_load_does_not_warn_about_coexisting_configs() {
3404 let dir = test_dir("coexist-explicit");
3405 std::fs::write(
3406 dir.path().join(".fallowrc.json"),
3407 r#"{"entry": ["chosen.ts"]}"#,
3408 )
3409 .unwrap();
3410 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
3411
3412 let chosen = dir.path().join("fallow.toml");
3413 let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
3414 assert!(result.is_ok());
3415 assert!(captured.is_empty());
3416 }
3417
3418 #[test]
3419 fn find_and_load_finds_fallow_toml() {
3420 let dir = test_dir("find-toml");
3421 std::fs::create_dir(dir.path().join(".git")).unwrap();
3422 std::fs::write(
3423 dir.path().join("fallow.toml"),
3424 "entry = [\"src/index.ts\"]\n",
3425 )
3426 .unwrap();
3427
3428 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3429 assert_eq!(config.entry, vec!["src/index.ts"]);
3430 }
3431
3432 #[test]
3433 fn find_and_load_stops_at_git_dir() {
3434 let dir = test_dir("find-git-stop");
3435 let sub = dir.path().join("sub");
3436 std::fs::create_dir(&sub).unwrap();
3437 std::fs::create_dir(dir.path().join(".git")).unwrap();
3438 let result = FallowConfig::find_and_load(&sub).unwrap();
3439 assert!(result.is_none());
3440 }
3441
3442 #[test]
3443 fn find_and_load_walks_past_package_json_in_monorepo() {
3444 let dir = test_dir("find-monorepo");
3445 std::fs::create_dir(dir.path().join(".git")).unwrap();
3446 std::fs::write(
3447 dir.path().join(".fallowrc.json"),
3448 r#"{"entry": ["src/index.ts"]}"#,
3449 )
3450 .unwrap();
3451
3452 let sub = dir.path().join("packages").join("app");
3453 std::fs::create_dir_all(&sub).unwrap();
3454 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3455
3456 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3457 assert_eq!(config.entry, vec!["src/index.ts"]);
3458 assert_eq!(path, dir.path().join(".fallowrc.json"));
3459 }
3460
3461 #[test]
3462 fn find_and_load_sub_package_config_wins_over_root() {
3463 let dir = test_dir("find-monorepo-override");
3464 std::fs::create_dir(dir.path().join(".git")).unwrap();
3465 std::fs::write(
3466 dir.path().join(".fallowrc.json"),
3467 r#"{"entry": ["src/root.ts"]}"#,
3468 )
3469 .unwrap();
3470
3471 let sub = dir.path().join("packages").join("app");
3472 std::fs::create_dir_all(&sub).unwrap();
3473 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3474 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
3475
3476 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3477 assert_eq!(config.entry, vec!["src/sub.ts"]);
3478 assert_eq!(path, sub.join(".fallowrc.json"));
3479 }
3480
3481 #[test]
3482 fn find_and_load_stops_at_git_file_submodule() {
3483 let dir = test_dir("find-git-file");
3484 std::fs::create_dir(dir.path().join(".git")).unwrap();
3485 std::fs::write(
3486 dir.path().join(".fallowrc.json"),
3487 r#"{"entry": ["src/parent.ts"]}"#,
3488 )
3489 .unwrap();
3490
3491 let submodule = dir.path().join("vendor").join("lib");
3492 std::fs::create_dir_all(&submodule).unwrap();
3493 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
3494
3495 let result = FallowConfig::find_and_load(&submodule).unwrap();
3496 assert!(
3497 result.is_none(),
3498 "submodule boundary should stop config walk",
3499 );
3500 }
3501
3502 #[test]
3503 fn find_and_load_stops_at_hg_dir() {
3504 let dir = test_dir("find-hg-stop");
3505 let sub = dir.path().join("sub");
3506 std::fs::create_dir(&sub).unwrap();
3507 std::fs::create_dir(dir.path().join(".hg")).unwrap();
3508
3509 let result = FallowConfig::find_and_load(&sub).unwrap();
3510 assert!(result.is_none());
3511 }
3512
3513 #[test]
3514 fn find_and_load_returns_error_for_invalid_config() {
3515 let dir = test_dir("find-invalid");
3516 std::fs::create_dir(dir.path().join(".git")).unwrap();
3517 std::fs::write(
3518 dir.path().join(".fallowrc.json"),
3519 r"{ this is not valid json }",
3520 )
3521 .unwrap();
3522
3523 let result = FallowConfig::find_and_load(dir.path());
3524 assert!(result.is_err());
3525 }
3526
3527 #[test]
3528 fn load_toml_config_file() {
3529 let dir = test_dir("toml-config");
3530 let config_path = dir.path().join("fallow.toml");
3531 std::fs::write(
3532 &config_path,
3533 r#"
3534entry = ["src/index.ts"]
3535ignorePatterns = ["dist/**"]
3536
3537[rules]
3538unused-files = "warn"
3539
3540[duplicates]
3541minTokens = 100
3542"#,
3543 )
3544 .unwrap();
3545
3546 let config = FallowConfig::load(&config_path).unwrap();
3547 assert_eq!(config.entry, vec!["src/index.ts"]);
3548 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
3549 assert_eq!(config.rules.unused_files, Severity::Warn);
3550 assert_eq!(config.duplicates.min_tokens, 100);
3551 }
3552
3553 #[test]
3554 fn load_toml_config_file_with_health_threshold_override() {
3555 let dir = test_dir("toml-health-threshold-override");
3556 let config_path = dir.path().join("fallow.toml");
3557 std::fs::write(
3558 &config_path,
3559 r#"
3560[health]
3561thresholdOverrides = [
3562 { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
3563]
3564"#,
3565 )
3566 .unwrap();
3567
3568 let config = FallowConfig::load(&config_path).unwrap();
3569 let override_config = &config.health.threshold_overrides[0];
3570 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
3571 assert_eq!(override_config.functions, vec!["legacyFlow"]);
3572 assert_eq!(override_config.max_cyclomatic, Some(30));
3573 assert_eq!(override_config.max_cognitive, Some(25));
3574 assert_eq!(override_config.max_crap, Some(80.5));
3575 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
3576 }
3577
3578 #[test]
3579 fn extends_absolute_path_rejected() {
3580 let dir = test_dir("extends-absolute");
3581
3582 #[cfg(unix)]
3583 let abs_path = "/absolute/path/config.json";
3584 #[cfg(windows)]
3585 let abs_path = "C:\\absolute\\path\\config.json";
3586
3587 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
3588 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
3589
3590 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3591 assert!(result.is_err());
3592 let err_msg = format!("{}", result.unwrap_err());
3593 assert!(
3594 err_msg.contains("must be relative"),
3595 "Expected 'must be relative' error, got: {err_msg}"
3596 );
3597 }
3598
3599 #[test]
3600 fn extends_windows_drive_absolute_path_rejected_on_any_host() {
3601 let dir = test_dir("extends-windows-absolute");
3602
3603 std::fs::write(
3604 dir.path().join(".fallowrc.json"),
3605 r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
3606 )
3607 .unwrap();
3608
3609 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3610 assert!(result.is_err());
3611 let err_msg = format!("{}", result.unwrap_err());
3612 assert!(
3613 err_msg.contains("must be relative"),
3614 "Expected 'must be relative' error, got: {err_msg}"
3615 );
3616 }
3617
3618 #[cfg(windows)]
3619 #[test]
3620 fn extends_posix_rooted_absolute_path_rejected_on_windows() {
3621 let dir = test_dir("extends-posix-rooted-absolute");
3622
3623 std::fs::write(
3624 dir.path().join(".fallowrc.json"),
3625 r#"{"extends": ["/absolute/path/config.json"]}"#,
3626 )
3627 .unwrap();
3628
3629 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3630 assert!(result.is_err());
3631 let err_msg = format!("{}", result.unwrap_err());
3632 assert!(
3633 err_msg.contains("must be relative"),
3634 "Expected 'must be relative' error, got: {err_msg}"
3635 );
3636 }
3637
3638 #[test]
3639 fn resolve_production_mode_disables_dev_deps() {
3640 let config = FallowConfig {
3641 production: true.into(),
3642 ..Default::default()
3643 };
3644 let resolved = config.resolve(
3645 PathBuf::from("/tmp/test"),
3646 OutputFormat::Human,
3647 4,
3648 false,
3649 true,
3650 None,
3651 );
3652 assert!(resolved.production);
3653 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3654 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3655 assert_eq!(resolved.rules.unused_files, Severity::Error);
3656 assert_eq!(resolved.rules.unused_exports, Severity::Error);
3657 }
3658
3659 #[test]
3660 fn include_entry_exports_deserializes_from_camelcase_json() {
3661 let json = r#"{ "includeEntryExports": true }"#;
3662 let config: FallowConfig = serde_json::from_str(json).unwrap();
3663 assert!(config.include_entry_exports);
3664 }
3665
3666 #[test]
3667 fn include_entry_exports_deserializes_from_camelcase_toml() {
3668 let toml_str = "includeEntryExports = true\n";
3669 let config: FallowConfig = toml::from_str(toml_str).unwrap();
3670 assert!(config.include_entry_exports);
3671 }
3672
3673 #[test]
3674 fn include_entry_exports_default_is_false() {
3675 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3676 assert!(!config.include_entry_exports);
3677 }
3678
3679 #[test]
3680 fn include_entry_exports_propagates_through_resolve() {
3681 let config = FallowConfig {
3682 include_entry_exports: true,
3683 auto_imports: false,
3684 cache: CacheConfig::default(),
3685 ..Default::default()
3686 };
3687 let resolved = config.resolve(
3688 PathBuf::from("/tmp/test"),
3689 OutputFormat::Human,
3690 1,
3691 true,
3692 true,
3693 None,
3694 );
3695 assert!(resolved.include_entry_exports);
3696 }
3697
3698 #[test]
3699 fn config_format_defaults_to_toml_for_unknown() {
3700 assert!(matches!(
3701 ConfigFormat::from_path(Path::new("config.yaml")),
3702 ConfigFormat::Toml
3703 ));
3704 assert!(matches!(
3705 ConfigFormat::from_path(Path::new("config")),
3706 ConfigFormat::Toml
3707 ));
3708 }
3709
3710 #[test]
3711 fn deep_merge_object_over_scalar_replaces() {
3712 let mut base = serde_json::json!("just a string");
3713 let overlay = serde_json::json!({"key": "value"});
3714 deep_merge_json(&mut base, overlay);
3715 assert_eq!(base, serde_json::json!({"key": "value"}));
3716 }
3717
3718 #[test]
3719 fn deep_merge_scalar_over_object_replaces() {
3720 let mut base = serde_json::json!({"key": "value"});
3721 let overlay = serde_json::json!(42);
3722 deep_merge_json(&mut base, overlay);
3723 assert_eq!(base, serde_json::json!(42));
3724 }
3725
3726 #[test]
3727 fn extends_non_string_non_array_ignored() {
3728 let dir = test_dir("extends-numeric");
3729 std::fs::write(
3730 dir.path().join(".fallowrc.json"),
3731 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3732 )
3733 .unwrap();
3734
3735 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3736 assert_eq!(config.entry, vec!["src/index.ts"]);
3737 }
3738
3739 #[test]
3740 fn extends_multiple_bases_later_wins() {
3741 let dir = test_dir("extends-multi-base");
3742
3743 std::fs::write(
3744 dir.path().join("base-a.json"),
3745 r#"{"rules": {"unused-files": "warn"}}"#,
3746 )
3747 .unwrap();
3748 std::fs::write(
3749 dir.path().join("base-b.json"),
3750 r#"{"rules": {"unused-files": "off"}}"#,
3751 )
3752 .unwrap();
3753 std::fs::write(
3754 dir.path().join(".fallowrc.json"),
3755 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3756 )
3757 .unwrap();
3758
3759 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3760 assert_eq!(config.rules.unused_files, Severity::Off);
3761 }
3762
3763 #[test]
3764 fn load_rejects_empty_security_request_receivers() {
3765 let dir = test_dir("empty-security-request-receivers");
3766 std::fs::write(
3767 dir.path().join(".fallowrc.json"),
3768 r#"{"security": {"requestReceivers": ["req", " "]}}"#,
3769 )
3770 .unwrap();
3771
3772 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3773 let err = result.expect_err("empty receiver should be rejected");
3774 assert!(
3775 err.to_string().contains("security.requestReceivers"),
3776 "error should name security.requestReceivers: {err}"
3777 );
3778 }
3779
3780 #[test]
3781 fn resolve_normalizes_security_request_receivers() {
3782 let dir = test_dir("normalize-security-request-receivers");
3783 std::fs::write(
3784 dir.path().join(".fallowrc.json"),
3785 r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3786 )
3787 .unwrap();
3788
3789 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3790 .unwrap()
3791 .resolve(
3792 dir.path().to_path_buf(),
3793 OutputFormat::Human,
3794 1,
3795 true,
3796 true,
3797 None,
3798 );
3799 assert_eq!(
3800 config.security.request_receivers,
3801 vec!["httpreq".to_string(), "r".to_string()]
3802 );
3803 }
3804
3805 #[test]
3806 fn fallow_config_deserialize_production() {
3807 let json_str = r#"{"production": true}"#;
3808 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3809 assert!(config.production);
3810 }
3811
3812 #[test]
3813 fn fallow_config_production_defaults_false() {
3814 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3815 assert!(!config.production);
3816 }
3817
3818 #[test]
3819 fn package_json_optional_dependency_names() {
3820 let pkg: PackageJson = serde_json::from_str(
3821 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3822 )
3823 .unwrap();
3824 let opt = pkg.optional_dependency_names();
3825 assert_eq!(opt.len(), 2);
3826 assert!(opt.contains(&"fsevents".to_string()));
3827 assert!(opt.contains(&"chokidar".to_string()));
3828 }
3829
3830 #[test]
3831 fn package_json_optional_deps_empty_when_missing() {
3832 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3833 assert!(pkg.optional_dependency_names().is_empty());
3834 }
3835
3836 #[test]
3837 fn find_config_path_returns_fallowrc_json() {
3838 let dir = test_dir("find-path-json");
3839 std::fs::create_dir(dir.path().join(".git")).unwrap();
3840 std::fs::write(
3841 dir.path().join(".fallowrc.json"),
3842 r#"{"entry": ["src/main.ts"]}"#,
3843 )
3844 .unwrap();
3845
3846 let path = FallowConfig::find_config_path(dir.path());
3847 assert!(path.is_some());
3848 assert!(path.unwrap().ends_with(".fallowrc.json"));
3849 }
3850
3851 #[test]
3852 fn find_config_path_returns_fallow_toml() {
3853 let dir = test_dir("find-path-toml");
3854 std::fs::create_dir(dir.path().join(".git")).unwrap();
3855 std::fs::write(
3856 dir.path().join("fallow.toml"),
3857 "entry = [\"src/main.ts\"]\n",
3858 )
3859 .unwrap();
3860
3861 let path = FallowConfig::find_config_path(dir.path());
3862 assert!(path.is_some());
3863 assert!(path.unwrap().ends_with("fallow.toml"));
3864 }
3865
3866 #[test]
3867 fn find_config_path_returns_dot_fallow_toml() {
3868 let dir = test_dir("find-path-dot-toml");
3869 std::fs::create_dir(dir.path().join(".git")).unwrap();
3870 std::fs::write(
3871 dir.path().join(".fallow.toml"),
3872 "entry = [\"src/main.ts\"]\n",
3873 )
3874 .unwrap();
3875
3876 let path = FallowConfig::find_config_path(dir.path());
3877 assert!(path.is_some());
3878 assert!(path.unwrap().ends_with(".fallow.toml"));
3879 }
3880
3881 #[test]
3882 fn find_config_path_prefers_json_over_toml() {
3883 let dir = test_dir("find-path-priority");
3884 std::fs::create_dir(dir.path().join(".git")).unwrap();
3885 std::fs::write(
3886 dir.path().join(".fallowrc.json"),
3887 r#"{"entry": ["json.ts"]}"#,
3888 )
3889 .unwrap();
3890 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3891
3892 let path = FallowConfig::find_config_path(dir.path());
3893 assert!(path.unwrap().ends_with(".fallowrc.json"));
3894 }
3895
3896 #[test]
3897 fn find_config_path_none_when_no_config() {
3898 let dir = test_dir("find-path-none");
3899 std::fs::create_dir(dir.path().join(".git")).unwrap();
3900
3901 let path = FallowConfig::find_config_path(dir.path());
3902 assert!(path.is_none());
3903 }
3904
3905 #[test]
3906 fn find_config_path_walks_past_package_json_in_monorepo() {
3907 let dir = test_dir("find-path-monorepo");
3908 std::fs::create_dir(dir.path().join(".git")).unwrap();
3909 std::fs::write(
3910 dir.path().join(".fallowrc.json"),
3911 r#"{"entry": ["src/index.ts"]}"#,
3912 )
3913 .unwrap();
3914
3915 let sub = dir.path().join("packages").join("app");
3916 std::fs::create_dir_all(&sub).unwrap();
3917 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3918
3919 let path = FallowConfig::find_config_path(&sub).unwrap();
3920 assert_eq!(path, dir.path().join(".fallowrc.json"));
3921 }
3922
3923 #[test]
3924 fn extends_toml_base() {
3925 let dir = test_dir("extends-toml");
3926
3927 std::fs::write(
3928 dir.path().join("base.json"),
3929 r#"{"rules": {"unused-files": "warn"}}"#,
3930 )
3931 .unwrap();
3932 std::fs::write(
3933 dir.path().join("fallow.toml"),
3934 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3935 )
3936 .unwrap();
3937
3938 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3939 assert_eq!(config.rules.unused_files, Severity::Warn);
3940 assert_eq!(config.entry, vec!["src/index.ts"]);
3941 }
3942
3943 #[test]
3944 fn deep_merge_boolean_overlay() {
3945 let mut base = serde_json::json!(true);
3946 deep_merge_json(&mut base, serde_json::json!(false));
3947 assert_eq!(base, serde_json::json!(false));
3948 }
3949
3950 #[test]
3951 fn deep_merge_number_overlay() {
3952 let mut base = serde_json::json!(42);
3953 deep_merge_json(&mut base, serde_json::json!(99));
3954 assert_eq!(base, serde_json::json!(99));
3955 }
3956
3957 #[test]
3958 fn deep_merge_disjoint_objects() {
3959 let mut base = serde_json::json!({"a": 1});
3960 let overlay = serde_json::json!({"b": 2});
3961 deep_merge_json(&mut base, overlay);
3962 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3963 }
3964
3965 #[test]
3966 fn max_extends_depth_is_reasonable() {
3967 assert_eq!(MAX_EXTENDS_DEPTH, 10);
3968 }
3969
3970 #[test]
3971 fn config_names_has_four_entries() {
3972 assert_eq!(CONFIG_NAMES.len(), 4);
3973 for name in CONFIG_NAMES {
3974 assert!(
3975 name.starts_with('.') || name.starts_with("fallow"),
3976 "unexpected config name: {name}"
3977 );
3978 }
3979 }
3980
3981 #[test]
3982 fn package_json_peer_dependency_names() {
3983 let pkg: PackageJson = serde_json::from_str(
3984 r#"{
3985 "dependencies": {"react": "^18"},
3986 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3987 }"#,
3988 )
3989 .unwrap();
3990 let all = pkg.all_dependency_names();
3991 assert!(all.contains(&"react".to_string()));
3992 assert!(all.contains(&"react-dom".to_string()));
3993 assert!(all.contains(&"react-native".to_string()));
3994 }
3995
3996 #[test]
3997 fn package_json_scripts_field() {
3998 let pkg: PackageJson = serde_json::from_str(
3999 r#"{
4000 "scripts": {
4001 "build": "tsc",
4002 "test": "vitest",
4003 "lint": "fallow check"
4004 }
4005 }"#,
4006 )
4007 .unwrap();
4008 let scripts = pkg.scripts.unwrap();
4009 assert_eq!(scripts.len(), 3);
4010 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
4011 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
4012 }
4013
4014 #[test]
4015 fn extends_toml_chain() {
4016 let dir = test_dir("extends-toml-chain");
4017
4018 std::fs::write(
4019 dir.path().join("base.json"),
4020 r#"{"entry": ["src/base.ts"]}"#,
4021 )
4022 .unwrap();
4023 std::fs::write(
4024 dir.path().join("middle.json"),
4025 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4026 )
4027 .unwrap();
4028 std::fs::write(
4029 dir.path().join("fallow.toml"),
4030 "extends = [\"middle.json\"]\n",
4031 )
4032 .unwrap();
4033
4034 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4035 assert_eq!(config.entry, vec!["src/base.ts"]);
4036 assert_eq!(config.rules.unused_files, Severity::Off);
4037 }
4038
4039 #[test]
4040 fn find_and_load_walks_up_directories() {
4041 let dir = test_dir("find-walk-up");
4042 let sub = dir.path().join("src").join("deep");
4043 std::fs::create_dir_all(&sub).unwrap();
4044 std::fs::write(
4045 dir.path().join(".fallowrc.json"),
4046 r#"{"entry": ["src/main.ts"]}"#,
4047 )
4048 .unwrap();
4049 std::fs::create_dir(dir.path().join(".git")).unwrap();
4050
4051 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4052 assert_eq!(config.entry, vec!["src/main.ts"]);
4053 assert!(path.ends_with(".fallowrc.json"));
4054 }
4055
4056 #[test]
4057 fn json_schema_contains_entry_field() {
4058 let schema = FallowConfig::json_schema();
4059 let obj = schema.as_object().unwrap();
4060 let props = obj.get("properties").and_then(|v| v.as_object());
4061 assert!(props.is_some(), "schema should have properties");
4062 assert!(
4063 props.unwrap().contains_key("entry"),
4064 "schema should contain entry property"
4065 );
4066 }
4067
4068 #[test]
4069 fn fallow_config_json_duplicates_all_fields() {
4070 let json = r#"{
4071 "duplicates": {
4072 "enabled": true,
4073 "mode": "semantic",
4074 "minTokens": 200,
4075 "minLines": 20,
4076 "threshold": 10.5,
4077 "ignore": ["**/*.test.ts"],
4078 "skipLocal": true,
4079 "crossLanguage": true,
4080 "normalization": {
4081 "ignoreIdentifiers": true,
4082 "ignoreStringValues": false
4083 }
4084 }
4085 }"#;
4086 let config: FallowConfig = serde_json::from_str(json).unwrap();
4087 assert!(config.duplicates.enabled);
4088 assert_eq!(
4089 config.duplicates.mode,
4090 crate::config::DetectionMode::Semantic
4091 );
4092 assert_eq!(config.duplicates.min_tokens, 200);
4093 assert_eq!(config.duplicates.min_lines, 20);
4094 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4095 assert!(config.duplicates.skip_local);
4096 assert!(config.duplicates.cross_language);
4097 assert_eq!(
4098 config.duplicates.normalization.ignore_identifiers,
4099 Some(true)
4100 );
4101 assert_eq!(
4102 config.duplicates.normalization.ignore_string_values,
4103 Some(false)
4104 );
4105 }
4106
4107 #[test]
4108 fn normalize_url_basic() {
4109 assert_eq!(
4110 normalize_url_for_dedup("https://example.com/config.json"),
4111 "https://example.com/config.json"
4112 );
4113 }
4114
4115 #[test]
4116 fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4117 let cases = [
4118 (
4119 "https://user:password@example.com/config.json",
4120 "https://example.com/config.json",
4121 ),
4122 (
4123 "https://example.com/config.json?token=query-secret#fragment-secret",
4124 "https://example.com/config.json",
4125 ),
4126 (
4127 "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4128 "https://[2001:db8::1]:8443/config.json",
4129 ),
4130 (
4131 "/workspace/configs/base.json?literal-query#literal-fragment",
4132 "/workspace/configs/base.json?literal-query#literal-fragment",
4133 ),
4134 ];
4135
4136 for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4137 assert!(
4138 remote_config_display(input) == expected,
4139 "remote config display case {case_index} must be sanitized"
4140 );
4141 }
4142 }
4143
4144 #[test]
4145 fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4146 let redirect_target =
4147 "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4148 let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4149
4150 let display =
4151 remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4152
4153 assert_eq!(
4154 display,
4155 "configured for https only: http://example.com/config.json"
4156 );
4157 for secret in [
4158 "redirect-user",
4159 "redirect-password",
4160 "token=secret",
4161 "anchor",
4162 ] {
4163 assert!(
4164 !display.contains(secret),
4165 "redirect secret must be redacted"
4166 );
4167 }
4168 }
4169
4170 #[test]
4171 fn normalize_url_preserves_non_root_trailing_slash() {
4172 assert_eq!(
4173 normalize_url_for_dedup("https://example.com/config/"),
4174 "https://example.com/config/"
4175 );
4176 }
4177
4178 #[test]
4179 fn normalize_url_uppercase_scheme_and_host() {
4180 assert_eq!(
4181 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4182 "https://example.com/Config.json"
4183 );
4184 }
4185
4186 #[test]
4187 fn normalize_url_root_path() {
4188 assert_eq!(
4189 normalize_url_for_dedup("https://example.com/"),
4190 "https://example.com"
4191 );
4192 assert_eq!(
4193 normalize_url_for_dedup("https://example.com"),
4194 "https://example.com"
4195 );
4196 }
4197
4198 #[test]
4199 fn normalize_url_preserves_path_case() {
4200 assert_eq!(
4201 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4202 "https://github.com/Org/Repo/Fallow.json"
4203 );
4204 }
4205
4206 #[test]
4207 fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4208 assert_eq!(
4209 normalize_url_for_dedup(
4210 "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4211 ),
4212 "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4213 );
4214 }
4215
4216 #[test]
4217 fn normalize_url_preserves_query_string() {
4218 assert_eq!(
4219 normalize_url_for_dedup("https://example.com/config.json?v=1"),
4220 "https://example.com/config.json?v=1"
4221 );
4222 }
4223
4224 #[test]
4225 fn normalize_url_strips_fragment() {
4226 assert_eq!(
4227 normalize_url_for_dedup("https://example.com/config.json#section"),
4228 "https://example.com/config.json"
4229 );
4230 }
4231
4232 #[test]
4233 fn normalize_url_preserves_query_and_strips_fragment() {
4234 assert_eq!(
4235 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4236 "https://example.com/config.json?v=1"
4237 );
4238 }
4239
4240 #[test]
4241 fn normalize_url_query_selects_resource_but_fragment_does_not() {
4242 assert_ne!(
4243 normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4244 normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4245 );
4246 assert_eq!(
4247 normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4248 normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4249 );
4250 }
4251
4252 #[test]
4253 fn normalize_url_default_https_port() {
4254 assert_eq!(
4255 normalize_url_for_dedup("https://example.com:443/config.json"),
4256 "https://example.com/config.json"
4257 );
4258 assert_eq!(
4259 normalize_url_for_dedup("https://example.com:8443/config.json"),
4260 "https://example.com:8443/config.json"
4261 );
4262 }
4263
4264 #[test]
4265 fn normalize_url_only_strips_the_scheme_default_port() {
4266 assert_eq!(
4267 normalize_url_for_dedup("http://example.com:80/config.json"),
4268 "http://example.com/config.json"
4269 );
4270 assert_eq!(
4271 normalize_url_for_dedup("http://example.com:443/config.json"),
4272 "http://example.com:443/config.json"
4273 );
4274 assert_eq!(
4275 normalize_url_for_dedup("https://example.com:80/config.json"),
4276 "https://example.com:80/config.json"
4277 );
4278 }
4279
4280 #[test]
4281 fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4282 assert_eq!(
4283 normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4284 "https://example.com?profile=one"
4285 );
4286 assert_eq!(
4287 normalize_url_for_dedup("https://example.com:080/config.json"),
4288 "https://example.com:80/config.json"
4289 );
4290 }
4291
4292 #[test]
4293 fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4294 assert_eq!(
4295 normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4296 "https://[2001:db8::1]/config.json"
4297 );
4298 }
4299
4300 #[test]
4301 fn extends_http_rejected() {
4302 let dir = test_dir("http-rejected");
4303 std::fs::write(
4304 dir.path().join(".fallowrc.json"),
4305 r#"{"extends": "http://example.com/config.json"}"#,
4306 )
4307 .unwrap();
4308
4309 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4310 assert!(result.is_err());
4311 let err_msg = format!("{}", result.unwrap_err());
4312 assert!(
4313 err_msg.contains("https://"),
4314 "Expected https hint in error, got: {err_msg}"
4315 );
4316 assert!(
4317 err_msg.contains("http://"),
4318 "Expected http:// mention in error, got: {err_msg}"
4319 );
4320 }
4321
4322 #[test]
4323 fn extends_url_circular_detection() {
4324 let mut visited = FxHashSet::default();
4325 let url = "https://example.com/config.json";
4326 let normalized = normalize_url_for_dedup(url);
4327 visited.insert(normalized.clone());
4328
4329 assert!(
4330 !visited.insert(normalized),
4331 "Same URL should be detected as duplicate"
4332 );
4333 }
4334
4335 #[test]
4336 fn extends_url_circular_case_insensitive() {
4337 let mut visited = FxHashSet::default();
4338 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4339
4340 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4341 assert!(
4342 !visited.insert(normalized),
4343 "Case-different URLs should normalize to the same key"
4344 );
4345 }
4346
4347 #[test]
4348 fn extract_extends_array() {
4349 let mut value = serde_json::json!({
4350 "extends": ["a.json", "b.json"],
4351 "entry": ["src/index.ts"]
4352 });
4353 let extends = extract_extends(&mut value);
4354 assert_eq!(extends, vec!["a.json", "b.json"]);
4355 assert!(value.get("extends").is_none());
4356 assert!(value.get("entry").is_some());
4357 }
4358
4359 #[test]
4360 fn extract_extends_string_sugar() {
4361 let mut value = serde_json::json!({
4362 "extends": "base.json",
4363 "entry": ["src/index.ts"]
4364 });
4365 let extends = extract_extends(&mut value);
4366 assert_eq!(extends, vec!["base.json"]);
4367 }
4368
4369 #[test]
4370 fn extract_extends_none() {
4371 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4372 let extends = extract_extends(&mut value);
4373 assert!(extends.is_empty());
4374 }
4375
4376 #[test]
4377 fn url_timeout_default() {
4378 let timeout = url_timeout();
4379 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4380 }
4381
4382 #[test]
4383 fn extends_url_mixed_with_file_and_npm() {
4384 let dir = test_dir("url-mixed");
4385 std::fs::write(
4386 dir.path().join("local.json"),
4387 r#"{"rules": {"unused-files": "warn"}}"#,
4388 )
4389 .unwrap();
4390 std::fs::write(
4391 dir.path().join(".fallowrc.json"),
4392 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4393 )
4394 .unwrap();
4395
4396 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4397 assert!(result.is_err());
4398 let err_msg = format!("{}", result.unwrap_err());
4399 assert!(
4400 err_msg.contains("unreachable.invalid"),
4401 "Expected URL in error message, got: {err_msg}"
4402 );
4403 }
4404
4405 #[test]
4406 fn extends_https_url_default_denial_has_opt_in_hint() {
4407 let dir = test_dir("url-unreachable");
4408 std::fs::write(
4409 dir.path().join(".fallowrc.json"),
4410 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4411 )
4412 .unwrap();
4413
4414 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4415 assert!(result.is_err());
4416 let err_msg = format!("{}", result.unwrap_err());
4417 assert!(
4418 err_msg.contains("unreachable.invalid"),
4419 "Expected URL in error, got: {err_msg}"
4420 );
4421 assert!(
4422 err_msg.contains("--allow-remote-extends"),
4423 "Expected remediation hint, got: {err_msg}"
4424 );
4425 }
4426
4427 #[test]
4428 fn collect_unknown_rule_keys_flags_top_level_typo() {
4429 let merged = serde_json::json!({
4430 "rules": {
4431 "unsued-files": "warn",
4432 "unused-exports": "off"
4433 }
4434 });
4435 let findings = collect_unknown_rule_keys(&merged);
4436 assert_eq!(findings.len(), 1);
4437 assert_eq!(findings[0].context, "rules");
4438 assert_eq!(findings[0].key, "unsued-files");
4439 assert_eq!(findings[0].suggestion, Some("unused-files"));
4440 }
4441
4442 #[test]
4443 fn collect_unknown_rule_keys_flags_overrides_typo() {
4444 let merged = serde_json::json!({
4445 "overrides": [
4446 {
4447 "files": ["src/**/*.ts"],
4448 "rules": {
4449 "unsued-files": "warn"
4450 }
4451 },
4452 {
4453 "files": ["tests/**/*.ts"],
4454 "rules": {
4455 "circular-dependnecy": "off"
4456 }
4457 }
4458 ]
4459 });
4460 let findings = collect_unknown_rule_keys(&merged);
4461 assert_eq!(findings.len(), 2);
4462 assert_eq!(findings[0].context, "overrides[0].rules");
4463 assert_eq!(findings[1].context, "overrides[1].rules");
4464 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4465 }
4466
4467 #[test]
4468 fn collect_unknown_rule_keys_empty_for_valid_config() {
4469 let merged = serde_json::json!({
4470 "rules": {
4471 "unused-files": "warn",
4472 "unused-file": "off",
4473 "circular-dependency": "off",
4474 "boundary-violations": "warn"
4475 },
4476 "overrides": [
4477 {
4478 "files": ["src/**"],
4479 "rules": {
4480 "unused-exports": "warn"
4481 }
4482 }
4483 ]
4484 });
4485 let findings = collect_unknown_rule_keys(&merged);
4486 assert!(
4487 findings.is_empty(),
4488 "valid rule names and aliases must not be flagged: {findings:?}"
4489 );
4490 }
4491
4492 #[test]
4493 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
4494 let merged = serde_json::json!({
4495 "entry": ["src/main.ts"]
4496 });
4497 let findings = collect_unknown_rule_keys(&merged);
4498 assert!(findings.is_empty());
4499 }
4500
4501 #[test]
4502 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
4503 let dir = test_dir("wiring");
4504 let path = dir.path().join(".fallowrc.json");
4505 let typo = format!(
4506 "wiring-probe-{}-{}",
4507 std::process::id(),
4508 std::time::SystemTime::now()
4509 .duration_since(std::time::UNIX_EPOCH)
4510 .map_or(0, |d| d.as_nanos())
4511 );
4512 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
4513
4514 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
4515
4516 assert!(
4517 config_res.is_ok(),
4518 "load should succeed in phase 1: {:?}",
4519 config_res.err()
4520 );
4521 assert_eq!(
4522 captured.len(),
4523 1,
4524 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
4525 );
4526 assert_eq!(captured[0].key, typo);
4527 assert_eq!(captured[0].context, "rules");
4528 }
4529
4530 #[test]
4531 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
4532 let dir = test_dir("misspelled-rule");
4533 std::fs::write(
4534 dir.path().join(".fallowrc.json"),
4535 r#"{"rules": {"unsued-files": "warn"}}"#,
4536 )
4537 .unwrap();
4538
4539 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4540 .expect("load should succeed in phase 1");
4541
4542 assert_eq!(config.rules.unused_files, Severity::Error);
4543 }
4544
4545 #[test]
4546 fn validate_resolved_boundaries_passes_on_valid_config() {
4547 let dir = test_dir("boundaries-valid");
4548 let config = FallowConfig {
4549 boundaries: crate::BoundaryConfig {
4550 coverage: crate::BoundaryCoverageConfig::default(),
4551 calls: crate::BoundaryCallsConfig::default(),
4552 preset: None,
4553 zones: vec![
4554 crate::BoundaryZone {
4555 name: "ui".to_string(),
4556 patterns: vec!["src/components/**".to_string()],
4557 auto_discover: vec![],
4558 root: None,
4559 },
4560 crate::BoundaryZone {
4561 name: "db".to_string(),
4562 patterns: vec!["src/db/**".to_string()],
4563 auto_discover: vec![],
4564 root: None,
4565 },
4566 ],
4567 rules: vec![crate::BoundaryRule {
4568 from: "ui".to_string(),
4569 allow: vec!["db".to_string()],
4570 allow_type_only: vec![],
4571 }],
4572 },
4573 ..FallowConfig::default()
4574 };
4575 config
4576 .validate_resolved_boundaries(dir.path())
4577 .expect("valid config should pass");
4578 }
4579
4580 #[test]
4581 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
4582 let dir = test_dir("boundaries-unknown-zones");
4583 let config = FallowConfig {
4584 boundaries: crate::BoundaryConfig {
4585 coverage: crate::BoundaryCoverageConfig::default(),
4586 calls: crate::BoundaryCallsConfig::default(),
4587 preset: None,
4588 zones: vec![crate::BoundaryZone {
4589 name: "ui".to_string(),
4590 patterns: vec!["src/ui/**".to_string()],
4591 auto_discover: vec![],
4592 root: None,
4593 }],
4594 rules: vec![
4595 crate::BoundaryRule {
4596 from: "typo-from".to_string(),
4597 allow: vec!["typo-allow".to_string()],
4598 allow_type_only: vec!["typo-type-only".to_string()],
4599 },
4600 crate::BoundaryRule {
4601 from: "ui".to_string(),
4602 allow: vec!["another-typo".to_string()],
4603 allow_type_only: vec![],
4604 },
4605 ],
4606 },
4607 ..FallowConfig::default()
4608 };
4609
4610 let errors = config
4611 .validate_resolved_boundaries(dir.path())
4612 .expect_err("invalid zone refs should fail");
4613
4614 assert_eq!(errors.len(), 4, "got: {errors:?}");
4615
4616 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4617 assert!(
4618 rendered
4619 .iter()
4620 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
4621 );
4622 assert!(
4623 rendered
4624 .iter()
4625 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
4626 );
4627 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
4628 && m.contains("rules[0]")
4629 && m.contains("allowTypeOnly")));
4630 assert!(
4631 rendered.iter().any(|m| m.contains("another-typo")
4632 && m.contains("rules[1]")
4633 && m.contains("allow"))
4634 );
4635 }
4636
4637 #[test]
4638 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
4639 let dir = test_dir("boundaries-redundant-prefix");
4640 let config = FallowConfig {
4641 boundaries: crate::BoundaryConfig {
4642 coverage: crate::BoundaryCoverageConfig::default(),
4643 calls: crate::BoundaryCallsConfig::default(),
4644 preset: None,
4645 zones: vec![crate::BoundaryZone {
4646 name: "ui".to_string(),
4647 patterns: vec!["packages/app/src/**".to_string()],
4648 auto_discover: vec![],
4649 root: Some("packages/app/".to_string()),
4650 }],
4651 rules: vec![],
4652 },
4653 ..FallowConfig::default()
4654 };
4655
4656 let errors = config
4657 .validate_resolved_boundaries(dir.path())
4658 .expect_err("redundant root prefix should fail");
4659 assert_eq!(errors.len(), 1, "got: {errors:?}");
4660 let rendered = errors[0].to_string();
4661 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
4662 assert!(rendered.contains("zone 'ui'"));
4663 }
4664
4665 #[test]
4666 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
4667 let dir = test_dir("boundaries-mixed-errors");
4668 let config = FallowConfig {
4669 boundaries: crate::BoundaryConfig {
4670 coverage: crate::BoundaryCoverageConfig::default(),
4671 calls: crate::BoundaryCallsConfig::default(),
4672 preset: None,
4673 zones: vec![crate::BoundaryZone {
4674 name: "ui".to_string(),
4675 patterns: vec!["packages/app/src/**".to_string()],
4676 auto_discover: vec![],
4677 root: Some("packages/app/".to_string()),
4678 }],
4679 rules: vec![crate::BoundaryRule {
4680 from: "ui".to_string(),
4681 allow: vec!["typo-zone".to_string()],
4682 allow_type_only: vec![],
4683 }],
4684 },
4685 ..FallowConfig::default()
4686 };
4687 let errors = config
4688 .validate_resolved_boundaries(dir.path())
4689 .expect_err("mixed errors should fail");
4690 assert_eq!(errors.len(), 2, "got: {errors:?}");
4691 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4692 assert!(
4693 rendered
4694 .iter()
4695 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
4696 );
4697 assert!(
4698 rendered
4699 .iter()
4700 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
4701 );
4702 }
4703
4704 #[test]
4705 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
4706 let dir = test_dir("boundaries-bulletproof");
4707 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4708 let config = FallowConfig {
4709 boundaries: crate::BoundaryConfig {
4710 coverage: crate::BoundaryCoverageConfig::default(),
4711 calls: crate::BoundaryCallsConfig::default(),
4712 preset: Some(crate::BoundaryPreset::Bulletproof),
4713 zones: vec![],
4714 rules: vec![],
4715 },
4716 ..FallowConfig::default()
4717 };
4718 config
4719 .validate_resolved_boundaries(dir.path())
4720 .expect("Bulletproof with discoverable features should pass");
4721 }
4722
4723 #[test]
4728 #[cfg_attr(miri, ignore)]
4729 fn parse_config_to_value_strips_utf8_bom() {
4730 let dir = test_dir("parse-bom");
4731 let path = dir.path().join("fallow.toml");
4732 let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4734 std::fs::write(&path, content_with_bom).unwrap();
4735
4736 let value = parse_config_to_value(&path).unwrap();
4737 assert!(
4738 value.get("entry").is_some(),
4739 "BOM should be stripped before TOML parsing"
4740 );
4741 }
4742
4743 #[test]
4744 #[cfg_attr(miri, ignore)]
4745 fn parse_config_to_value_toml_parse_error() {
4746 let dir = test_dir("parse-toml-error");
4747 let path = dir.path().join("fallow.toml");
4748 std::fs::write(&path, "entry = [unquoted\n").unwrap();
4749
4750 let result = parse_config_to_value(&path);
4751 assert!(result.is_err());
4752 let err = result.unwrap_err().to_string();
4753 assert!(
4754 err.contains("Failed to parse config file"),
4755 "error should mention parse failure: {err}"
4756 );
4757 }
4758
4759 #[test]
4760 #[cfg_attr(miri, ignore)]
4761 fn parse_config_to_value_json_parse_error() {
4762 let dir = test_dir("parse-json-error");
4763 let path = dir.path().join(".fallowrc.json");
4764 std::fs::write(&path, "{ this is not json }").unwrap();
4765
4766 let result = parse_config_to_value(&path);
4767 assert!(result.is_err());
4768 let err = result.unwrap_err().to_string();
4769 assert!(
4770 err.contains("Failed to parse config file"),
4771 "error should mention parse failure: {err}"
4772 );
4773 }
4774
4775 #[test]
4776 #[cfg_attr(miri, ignore)]
4777 fn parse_config_to_value_missing_file_error() {
4778 let dir = test_dir("parse-missing");
4779 let path = dir.path().join("nonexistent.toml");
4780
4781 let result = parse_config_to_value(&path);
4782 assert!(result.is_err());
4783 let err = result.unwrap_err().to_string();
4784 assert!(
4785 err.contains("Failed to read config file"),
4786 "error should mention read failure: {err}"
4787 );
4788 }
4789
4790 #[test]
4795 #[cfg_attr(miri, ignore)]
4796 fn find_and_load_stops_at_svn_dir() {
4797 let dir = test_dir("find-svn-stop");
4798 let sub = dir.path().join("sub");
4799 std::fs::create_dir(&sub).unwrap();
4800 std::fs::create_dir(dir.path().join(".svn")).unwrap();
4801
4802 let result = FallowConfig::find_and_load(&sub).unwrap();
4803 assert!(result.is_none(), "svn boundary should stop config walk");
4804 }
4805
4806 #[test]
4812 #[cfg_attr(miri, ignore)]
4813 fn extends_npm_single_dot_package_name_rejected() {
4814 let dir = test_dir("npm-dot-name");
4815 std::fs::write(
4816 dir.path().join(".fallowrc.json"),
4817 r#"{"extends": "npm:./relative"}"#,
4818 )
4819 .unwrap();
4820
4821 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4822 assert!(result.is_err());
4823 let err = result.unwrap_err().to_string();
4824 assert!(
4825 err.contains("path traversal"),
4826 "single-dot component should be rejected as path traversal: {err}"
4827 );
4828 }
4829
4830 #[test]
4836 #[cfg_attr(miri, ignore)]
4837 fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4838 let dir = test_dir("npm-main-missing");
4839 let pkg_dir = dir.path().join("node_modules/my-config");
4840 std::fs::create_dir_all(&pkg_dir).unwrap();
4841 std::fs::write(
4843 pkg_dir.join("package.json"),
4844 r#"{"name": "my-config", "main": "./missing.json"}"#,
4845 )
4846 .unwrap();
4847 std::fs::write(
4849 pkg_dir.join(".fallowrc.json"),
4850 r#"{"rules": {"unused-files": "warn"}}"#,
4851 )
4852 .unwrap();
4853
4854 std::fs::write(
4855 dir.path().join(".fallowrc.json"),
4856 r#"{"extends": "npm:my-config"}"#,
4857 )
4858 .unwrap();
4859
4860 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4861 assert_eq!(config.rules.unused_files, Severity::Warn);
4862 }
4863
4864 #[test]
4870 #[cfg_attr(miri, ignore)]
4871 fn extends_npm_exports_nonexistent_falls_through_to_main() {
4872 let dir = test_dir("npm-exports-missing-file");
4873 let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4874 std::fs::create_dir_all(&pkg_dir).unwrap();
4875 std::fs::write(
4877 pkg_dir.join("package.json"),
4878 r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4879 )
4880 .unwrap();
4881 std::fs::write(
4882 pkg_dir.join("real.json"),
4883 r#"{"rules": {"unused-types": "off"}}"#,
4884 )
4885 .unwrap();
4886
4887 std::fs::write(
4888 dir.path().join(".fallowrc.json"),
4889 r#"{"extends": "npm:cfg-pkg"}"#,
4890 )
4891 .unwrap();
4892
4893 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4894 assert_eq!(config.rules.unused_types, Severity::Off);
4895 }
4896
4897 #[test]
4902 fn normalize_url_no_scheme_returns_raw() {
4903 assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
4905 assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
4906 }
4907
4908 #[test]
4913 fn normalize_url_fragment_only_stripped() {
4914 assert_eq!(
4916 normalize_url_for_dedup("https://example.com/file.json#anchor"),
4917 "https://example.com/file.json"
4918 );
4919 }
4920
4921 #[test]
4929 fn url_timeout_uses_env_var_when_set() {
4930 assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
4931 }
4932
4933 #[test]
4934 fn url_timeout_zero_falls_back_to_default() {
4935 assert_eq!(
4936 url_timeout_from(Some("0")),
4937 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4938 "zero should fall back to the hardcoded default"
4939 );
4940 }
4941
4942 #[test]
4943 fn url_timeout_non_numeric_falls_back_to_default() {
4944 assert_eq!(
4945 url_timeout_from(Some("not-a-number")),
4946 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4947 "non-numeric value should fall back to the hardcoded default"
4948 );
4949 }
4950
4951 #[test]
4952 fn url_timeout_absent_uses_default() {
4953 assert_eq!(
4954 url_timeout_from(None),
4955 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
4956 );
4957 }
4958
4959 #[test]
4964 fn resolve_url_extends_depth_limit_error() {
4965 let mut visited = FxHashSet::default();
4966 let result = resolve_url_extends(
4967 "https://example.invalid/config.json",
4968 &mut visited,
4969 MAX_EXTENDS_DEPTH, );
4971 assert!(result.is_err());
4972 let err = result.unwrap_err().to_string();
4973 assert!(
4974 err.contains("too deep"),
4975 "error should mention depth limit: {err}"
4976 );
4977 }
4978
4979 #[test]
4984 #[cfg_attr(miri, ignore)]
4985 fn resolve_extends_file_depth_limit_error() {
4986 let dir = test_dir("extends-file-depth");
4987 let path = dir.path().join(".fallowrc.json");
4988 std::fs::write(&path, r#"{"entry": []}"#).unwrap();
4989
4990 let mut visited = FxHashSet::default();
4991 let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
4992 assert!(result.is_err());
4993 let err = result.unwrap_err().to_string();
4994 assert!(
4995 err.contains("too deep"),
4996 "error should mention depth limit: {err}"
4997 );
4998 }
4999
5000 #[test]
5005 #[cfg_attr(miri, ignore)]
5006 fn extends_http_url_in_file_extends_rejected() {
5007 let dir = test_dir("file-extends-http");
5008 std::fs::write(
5009 dir.path().join(".fallowrc.json"),
5010 r#"{"extends": ["http://example.com/config.json"]}"#,
5011 )
5012 .unwrap();
5013
5014 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5015 assert!(result.is_err());
5016 let err = result.unwrap_err().to_string();
5017 assert!(
5018 err.contains("https://"),
5019 "error should suggest https: {err}"
5020 );
5021 }
5022
5023 #[test]
5028 #[cfg_attr(miri, ignore)]
5029 fn sealed_config_dir_returns_some_when_sealed() {
5030 let dir = test_dir("sealed-dir");
5031 let result = sealed_config_dir(dir.path(), true);
5032 assert!(result.is_ok());
5033 assert!(
5034 result.unwrap().is_some(),
5035 "sealed=true must return Some(canonicalized path)"
5036 );
5037 }
5038
5039 #[test]
5040 fn sealed_config_dir_returns_none_when_not_sealed() {
5041 let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5042 assert!(result.is_ok());
5043 assert!(result.unwrap().is_none(), "sealed=false must return None");
5044 }
5045
5046 #[test]
5052 fn collect_unknown_rule_keys_override_without_rules_key() {
5053 let merged = serde_json::json!({
5054 "overrides": [
5055 {
5056 "files": ["src/**/*.ts"]
5057 },
5059 {
5060 "files": ["tests/**"],
5061 "rules": {
5062 "unsued-exports": "off"
5063 }
5064 }
5065 ]
5066 });
5067 let findings = collect_unknown_rule_keys(&merged);
5068 assert_eq!(
5069 findings.len(),
5070 1,
5071 "only the entry with rules should produce a finding"
5072 );
5073 assert_eq!(findings[0].context, "overrides[1].rules");
5074 }
5075
5076 #[test]
5081 #[cfg_attr(miri, ignore)]
5082 fn load_fails_on_deserialization_error() {
5083 let dir = test_dir("deser-error");
5084 let path = dir.path().join(".fallowrc.json");
5085 std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5087
5088 let result = FallowConfig::load(&path);
5089 assert!(result.is_err());
5090 let err = result.unwrap_err().to_string();
5091 assert!(
5092 err.contains("Failed to deserialize"),
5093 "error should mention deserialization: {err}"
5094 );
5095 }
5096
5097 #[test]
5103 #[cfg_attr(miri, ignore)]
5104 fn load_rejects_threshold_override_with_empty_files() {
5105 let dir = test_dir("threshold-empty-files");
5106 let path = dir.path().join(".fallowrc.json");
5107 std::fs::write(
5108 &path,
5109 r#"{
5110 "health": {
5111 "thresholdOverrides": [
5112 {"files": [], "maxCyclomatic": 30}
5113 ]
5114 }
5115 }"#,
5116 )
5117 .unwrap();
5118
5119 let result = FallowConfig::load(&path);
5120 assert!(result.is_err());
5121 let err = result.unwrap_err().to_string();
5122 assert!(
5123 err.contains("thresholdOverrides"),
5124 "error should mention thresholdOverrides: {err}"
5125 );
5126 assert!(
5127 err.contains("files"),
5128 "error should name the files field: {err}"
5129 );
5130 }
5131
5132 #[test]
5133 #[cfg_attr(miri, ignore)]
5134 fn load_rejects_threshold_override_with_no_threshold_set() {
5135 let dir = test_dir("threshold-no-threshold");
5136 let path = dir.path().join(".fallowrc.json");
5137 std::fs::write(
5138 &path,
5139 r#"{
5140 "health": {
5141 "thresholdOverrides": [
5142 {"files": ["src/legacy.ts"]}
5143 ]
5144 }
5145 }"#,
5146 )
5147 .unwrap();
5148
5149 let result = FallowConfig::load(&path);
5150 assert!(result.is_err());
5151 let err = result.unwrap_err().to_string();
5152 assert!(
5153 err.contains("maxCyclomatic")
5154 || err.contains("maxCognitive")
5155 || err.contains("maxCrap"),
5156 "error should name at least one threshold field: {err}"
5157 );
5158 }
5159
5160 #[test]
5166 #[cfg_attr(miri, ignore)]
5167 fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5168 let dir = test_dir("invalid-catalog-consumer-glob");
5169 let path = dir.path().join(".fallowrc.json");
5170 std::fs::write(
5171 &path,
5172 r#"{
5173 "ignoreCatalogReferences": [
5174 {"package": "react", "consumer": "[invalid-glob"}
5175 ]
5176 }"#,
5177 )
5178 .unwrap();
5179
5180 let result = FallowConfig::load(&path);
5181 assert!(result.is_err());
5182 let err = result.unwrap_err().to_string();
5183 assert!(
5184 err.contains("ignoreCatalogReferences"),
5185 "error should mention the field: {err}"
5186 );
5187 }
5188
5189 #[test]
5190 #[cfg_attr(miri, ignore)]
5191 fn load_accepts_ignore_catalog_references_without_consumer() {
5192 let dir = test_dir("catalog-ref-no-consumer");
5193 let path = dir.path().join(".fallowrc.json");
5194 std::fs::write(
5195 &path,
5196 r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5197 )
5198 .unwrap();
5199
5200 let config = FallowConfig::load(&path).unwrap();
5201 assert_eq!(config.ignore_catalog_references.len(), 1);
5202 assert!(config.ignore_catalog_references[0].consumer.is_none());
5203 }
5204
5205 #[test]
5206 #[cfg_attr(miri, ignore)]
5207 fn load_accepts_unused_component_props_ignore_pattern() {
5208 let dir = test_dir("unused-component-props-ignore-pattern");
5209 let path = dir.path().join(".fallowrc.json");
5210 std::fs::write(
5211 &path,
5212 r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5213 )
5214 .unwrap();
5215
5216 let config = FallowConfig::load(&path).unwrap();
5217 assert_eq!(
5218 config.unused_component_props.ignore_pattern.as_deref(),
5219 Some("^_")
5220 );
5221 }
5222
5223 #[test]
5224 #[cfg_attr(miri, ignore)]
5225 fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5226 let dir = test_dir("unused-component-props-bad-regex");
5227 let path = dir.path().join(".fallowrc.json");
5228 std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5230
5231 let result = FallowConfig::load(&path);
5232 assert!(result.is_err());
5233 let err = result.unwrap_err().to_string();
5234 assert!(
5235 err.contains("unusedComponentProps.ignorePattern"),
5236 "error should mention the field: {err}"
5237 );
5238 }
5239
5240 #[test]
5241 #[cfg_attr(miri, ignore)]
5242 fn load_rejects_unknown_unused_component_props_field() {
5243 let dir = test_dir("unused-component-props-unknown-field");
5244 let path = dir.path().join(".fallowrc.json");
5245 std::fs::write(
5246 &path,
5247 r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5248 )
5249 .unwrap();
5250
5251 assert!(FallowConfig::load(&path).is_err());
5253 }
5254
5255 #[test]
5262 #[cfg_attr(miri, ignore)]
5263 fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
5264 let dir = test_dir("boundaries-preset-no-tsconfig");
5267 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5268 let config = FallowConfig {
5269 boundaries: crate::BoundaryConfig {
5270 coverage: crate::BoundaryCoverageConfig::default(),
5271 calls: crate::BoundaryCallsConfig::default(),
5272 preset: Some(crate::BoundaryPreset::Bulletproof),
5273 zones: vec![],
5274 rules: vec![],
5275 },
5276 ..FallowConfig::default()
5277 };
5278 let _ = config.validate_resolved_boundaries(dir.path());
5280 }
5281
5282 #[test]
5288 fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5289 use crate::ExternalPluginDef;
5290 use crate::external_plugin::EntryPointRole;
5291 let config = FallowConfig {
5292 framework: vec![ExternalPluginDef {
5293 schema: None,
5294 name: "test-plugin".to_owned(),
5295 detection: None,
5296 enablers: vec![],
5297 entry_points: vec!["[invalid-glob".to_owned()],
5298 entry_point_role: EntryPointRole::Support,
5299 manifest_entries: vec![],
5300 config_patterns: vec![],
5301 always_used: vec![],
5302 tooling_dependencies: vec![],
5303 used_exports: vec![],
5304 used_class_members: vec![],
5305 }],
5306 ..FallowConfig::default()
5307 };
5308
5309 let result = config.validate_user_globs();
5310 assert!(
5311 result.is_err(),
5312 "invalid entry_points glob should fail validation"
5313 );
5314 let errors = result.unwrap_err();
5315 assert!(!errors.is_empty());
5316 }
5317
5318 #[test]
5323 #[cfg_attr(miri, ignore)]
5324 fn shadowed_config_names_empty_when_last_config_wins() {
5325 let dir = test_dir("shadow-last");
5326 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5327 assert!(shadowed_config_names(dir.path(), 3).is_empty());
5329 }
5330
5331 #[test]
5337 fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5338 let ((), captured) = capture_coexisting_config_warnings(|| {
5339 warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5340 });
5341 assert!(
5342 captured.is_empty(),
5343 "empty shadowed list must produce no warning"
5344 );
5345 }
5346
5347 #[test]
5352 fn extract_extends_array_filters_non_strings() {
5353 let mut value = serde_json::json!({
5354 "extends": ["a.json", 42, null, "b.json", true]
5355 });
5356 let extends = extract_extends(&mut value);
5357 assert_eq!(extends, vec!["a.json", "b.json"]);
5358 }
5359
5360 #[test]
5365 #[cfg_attr(miri, ignore)]
5366 fn config_resource_identity_cannot_collide_across_kinds() {
5367 let dir = test_dir("visit-circular");
5368 let path = dir.path().join("config.json");
5369 std::fs::write(&path, "{}").unwrap();
5370
5371 let canonical = dunce::canonicalize(path).unwrap();
5372 let local = ConfigResourceId::Local(canonical.clone());
5373 let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
5374 assert_ne!(local, remote);
5375 }
5376
5377 #[test]
5382 #[cfg_attr(miri, ignore)]
5383 fn find_config_path_stops_at_svn_dir() {
5384 let dir = test_dir("find-path-svn");
5385 let sub = dir.path().join("sub");
5386 std::fs::create_dir(&sub).unwrap();
5387 std::fs::create_dir(dir.path().join(".svn")).unwrap();
5388
5389 let path = FallowConfig::find_config_path(&sub);
5390 assert!(path.is_none(), "svn root should stop config search");
5391 }
5392
5393 #[test]
5398 fn deep_merge_array_over_object_replaces() {
5399 let mut base = serde_json::json!({"key": "value"});
5400 deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
5401 assert_eq!(base, serde_json::json!(["a", "b"]));
5402 }
5403
5404 #[test]
5409 #[cfg_attr(miri, ignore)]
5410 fn find_and_load_returns_error_for_invalid_glob_in_config() {
5411 let dir = test_dir("find-invalid-glob");
5412 std::fs::create_dir(dir.path().join(".git")).unwrap();
5413 std::fs::write(
5414 dir.path().join(".fallowrc.json"),
5415 r#"{"entry": ["[invalid-glob"]}"#,
5416 )
5417 .unwrap();
5418
5419 let result = FallowConfig::find_and_load(dir.path());
5420 assert!(
5421 result.is_err(),
5422 "invalid glob should surface as an error from find_and_load"
5423 );
5424 }
5425
5426 #[test]
5427 #[cfg_attr(miri, ignore)]
5428 fn find_and_load_rejects_bare_finding_ignore_negation() {
5429 let dir = test_dir("find-bare-finding-ignore-negation");
5430 std::fs::create_dir(dir.path().join(".git")).unwrap();
5431 std::fs::write(
5432 dir.path().join(".fallowrc.json"),
5433 r#"{"ignoreFindings": ["!"]}"#,
5434 )
5435 .unwrap();
5436
5437 let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5438 assert!(error.contains("ignoreFindings"), "error: {error}");
5439 assert!(error.contains("requires a pattern"), "error: {error}");
5440 }
5441
5442 #[test]
5443 #[cfg_attr(miri, ignore)]
5444 fn find_and_load_rejects_finding_ignore_set_that_exceeds_matcher_limits() {
5445 let dir = test_dir("find-oversized-finding-ignore-set");
5446 std::fs::create_dir(dir.path().join(".git")).unwrap();
5447 let alternatives = (0..50_000)
5448 .map(|index| format!("name{index}"))
5449 .collect::<Vec<_>>()
5450 .join(",");
5451 let config = serde_json::json!({
5452 "ignoreFindings": [format!("**/{{{alternatives}}}.ts")]
5453 });
5454 std::fs::write(
5455 dir.path().join(".fallowrc.json"),
5456 serde_json::to_vec(&config).unwrap(),
5457 )
5458 .unwrap();
5459
5460 let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5461 assert!(error.contains("ignoreFindings"), "error: {error}");
5462 assert!(
5463 error.contains("cannot be compiled together"),
5464 "error: {error}"
5465 );
5466 }
5467
5468 #[test]
5474 fn resolve_package_exports_dot_key_array_returns_none() {
5475 let pkg = serde_json::json!({
5477 "exports": {".": ["array-value"]}
5478 });
5479 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5480 assert!(result.is_none(), "array dot-export should return None");
5481 }
5482
5483 #[test]
5484 fn resolve_package_exports_exports_is_array_returns_none() {
5485 let pkg = serde_json::json!({
5487 "exports": ["./index.js"]
5488 });
5489 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5490 assert!(result.is_none(), "array-form exports should return None");
5491 }
5492
5493 #[test]
5494 fn resolve_package_exports_object_no_dot_key_returns_none() {
5495 let pkg = serde_json::json!({
5497 "exports": {"./sub": "./sub.js"}
5498 });
5499 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5500 assert!(result.is_none(), "no dot key should return None");
5501 }
5502
5503 #[test]
5504 fn resolve_package_exports_conditions_without_known_key_returns_none() {
5505 let pkg = serde_json::json!({
5507 "exports": {".": {"browser": "./browser.js"}}
5508 });
5509 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5510 assert!(result.is_none(), "unknown condition key should return None");
5511 }
5512
5513 #[test]
5518 #[cfg_attr(miri, ignore)]
5519 fn extends_npm_exports_import_condition() {
5520 let dir = test_dir("npm-import-cond");
5521 let pkg_dir = dir.path().join("node_modules/import-config");
5522 std::fs::create_dir_all(&pkg_dir).unwrap();
5523 std::fs::write(
5524 pkg_dir.join("package.json"),
5525 r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
5526 )
5527 .unwrap();
5528 std::fs::write(
5529 pkg_dir.join("esm.json"),
5530 r#"{"rules": {"unused-types": "warn"}}"#,
5531 )
5532 .unwrap();
5533
5534 std::fs::write(
5535 dir.path().join(".fallowrc.json"),
5536 r#"{"extends": "npm:import-config"}"#,
5537 )
5538 .unwrap();
5539
5540 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5541 assert_eq!(config.rules.unused_types, Severity::Warn);
5542 }
5543
5544 #[test]
5549 #[cfg_attr(miri, ignore)]
5550 fn extends_npm_exports_require_condition() {
5551 let dir = test_dir("npm-require-cond");
5552 let pkg_dir = dir.path().join("node_modules/require-config");
5553 std::fs::create_dir_all(&pkg_dir).unwrap();
5554 std::fs::write(
5555 pkg_dir.join("package.json"),
5556 r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
5557 )
5558 .unwrap();
5559 std::fs::write(
5560 pkg_dir.join("cjs.json"),
5561 r#"{"rules": {"unused-class-members": "warn"}}"#,
5562 )
5563 .unwrap();
5564
5565 std::fs::write(
5566 dir.path().join(".fallowrc.json"),
5567 r#"{"extends": "npm:require-config"}"#,
5568 )
5569 .unwrap();
5570
5571 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5572 assert_eq!(config.rules.unused_class_members, Severity::Warn);
5573 }
5574}