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::{validate_user_globs, validate_user_specifier_globs};
1200
1201 validate_user_globs(&self.entry, "entry", errors);
1202 validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
1203 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
1204 validate_user_specifier_globs(
1205 &self.ignore_unresolved_imports,
1206 "ignoreUnresolvedImports",
1207 errors,
1208 );
1209 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1210 validate_user_globs(&self.health.ignore, "health.ignore", errors);
1211 for override_entry in &self.health.threshold_overrides {
1212 validate_user_globs(
1213 &override_entry.files,
1214 "health.thresholdOverrides[].files",
1215 errors,
1216 );
1217 }
1218 for override_entry in &self.overrides {
1219 validate_user_globs(&override_entry.files, "overrides[].files", errors);
1220 }
1221 }
1222
1223 fn validate_ignore_rule_globs(
1225 &self,
1226 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1227 ) {
1228 use super::glob_validation::compile_user_glob;
1229
1230 for rule in &self.ignore_exports {
1231 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1232 errors.push(e);
1233 }
1234 }
1235
1236 for rule in &self.ignore_catalog_references {
1237 if let Some(consumer) = &rule.consumer
1238 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1239 {
1240 errors.push(e);
1241 }
1242 }
1243 }
1244
1245 fn validate_boundary_globs(
1248 &self,
1249 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1250 ) {
1251 use super::glob_validation::{
1252 validate_user_globs, validate_user_path, validate_user_paths,
1253 };
1254
1255 for zone in &self.boundaries.zones {
1256 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1257 if let Some(root) = &zone.root
1258 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1259 {
1260 errors.push(e);
1261 }
1262 validate_user_paths(
1263 &zone.auto_discover,
1264 "boundaries.zones[].autoDiscover",
1265 errors,
1266 );
1267 }
1268 validate_user_globs(
1269 &self.boundaries.coverage.allow_unmatched,
1270 "boundaries.coverage.allowUnmatched",
1271 errors,
1272 );
1273 }
1274
1275 #[must_use]
1278 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1279 let mut dir = start;
1280 loop {
1281 for name in CONFIG_NAMES {
1282 let candidate = dir.join(name);
1283 if candidate.exists() {
1284 return Some(candidate);
1285 }
1286 }
1287 if is_repo_root(dir) {
1288 break;
1289 }
1290 dir = dir.parent()?;
1291 }
1292 None
1293 }
1294
1295 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1301 Self::find_and_load_with_options(start, ConfigLoadOptions::default())
1302 }
1303
1304 pub fn find_and_load_with_options(
1311 start: &Path,
1312 options: ConfigLoadOptions,
1313 ) -> Result<Option<(Self, PathBuf)>, String> {
1314 let mut dir = start;
1315 loop {
1316 for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1317 let candidate = dir.join(name);
1318 if candidate.exists() {
1319 warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1320 match Self::load_with_options(&candidate, options) {
1321 Ok(config) => return Ok(Some((config, candidate))),
1322 Err(e) => {
1323 return Err(format!("Failed to parse {}: {e}", candidate.display()));
1324 }
1325 }
1326 }
1327 }
1328 if is_repo_root(dir) {
1329 break;
1330 }
1331 dir = match dir.parent() {
1332 Some(parent) => parent,
1333 None => break,
1334 };
1335 }
1336 Ok(None)
1337 }
1338
1339 #[must_use]
1341 pub fn json_schema() -> serde_json::Value {
1342 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1343 }
1344
1345 pub fn validate_resolved_boundaries(
1374 &self,
1375 root: &Path,
1376 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1377 use super::boundaries::ZoneValidationError;
1378
1379 let mut boundaries = self.boundaries.clone();
1380 if boundaries.preset.is_some() {
1381 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1382 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1383 .unwrap_or_else(|| "src".to_owned());
1384 boundaries.expand(&source_root);
1385 }
1386 let _logical_groups = boundaries.expand_auto_discover(root);
1387
1388 let mut errors: Vec<ZoneValidationError> = boundaries
1389 .validate_zone_references()
1390 .into_iter()
1391 .map(ZoneValidationError::UnknownZoneReference)
1392 .collect();
1393 errors.extend(
1394 boundaries
1395 .validate_root_prefixes()
1396 .into_iter()
1397 .map(ZoneValidationError::RedundantRootPrefix),
1398 );
1399 errors.extend(
1400 boundaries
1401 .validate_call_rules()
1402 .into_iter()
1403 .map(ZoneValidationError::InvalidForbiddenCallee),
1404 );
1405
1406 if errors.is_empty() {
1407 Ok(())
1408 } else {
1409 Err(errors)
1410 }
1411 }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416 use super::*;
1417 use crate::CacheConfig;
1418 use crate::PackageJson;
1419 use crate::config::format::OutputFormat;
1420 use crate::config::rules::Severity;
1421
1422 fn test_dir(_name: &str) -> tempfile::TempDir {
1424 tempfile::tempdir().expect("create temp dir")
1425 }
1426
1427 #[derive(Default)]
1428 struct MockRemoteFetcher {
1429 responses: rustc_hash::FxHashMap<String, serde_json::Value>,
1430 requests: Vec<String>,
1431 }
1432
1433 impl MockRemoteFetcher {
1434 fn with_response(mut self, url: &str, value: serde_json::Value) -> Self {
1435 self.responses.insert(url.to_string(), value);
1436 self
1437 }
1438 }
1439
1440 impl RemoteConfigFetcher for MockRemoteFetcher {
1441 fn fetch(&mut self, url: &str, _source: &str) -> Result<serde_json::Value, miette::Report> {
1442 self.requests.push(url.to_string());
1443 self.responses
1444 .get(url)
1445 .cloned()
1446 .ok_or_else(|| miette::miette!("missing mock response for {url}"))
1447 }
1448 }
1449
1450 #[test]
1451 fn fallow_config_deserialize_minimal() {
1452 let toml_str = r#"
1453entry = ["src/main.ts"]
1454"#;
1455 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1456 assert_eq!(config.entry, vec!["src/main.ts"]);
1457 assert!(config.ignore_patterns.is_empty());
1458 }
1459
1460 #[test]
1461 fn fallow_config_deserialize_ignore_exports() {
1462 let toml_str = r#"
1463[[ignoreExports]]
1464file = "src/types/*.ts"
1465exports = ["*"]
1466
1467[[ignoreExports]]
1468file = "src/constants.ts"
1469exports = ["FOO", "BAR"]
1470"#;
1471 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1472 assert_eq!(config.ignore_exports.len(), 2);
1473 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1474 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1475 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1476 }
1477
1478 #[test]
1479 fn fallow_config_deserialize_ignore_dependencies() {
1480 let toml_str = r#"
1481ignoreDependencies = ["autoprefixer", "postcss"]
1482"#;
1483 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1484 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1485 }
1486
1487 #[test]
1488 fn fallow_config_deserialize_ignore_unresolved_imports() {
1489 let toml_str = r#"
1490ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1491"#;
1492 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1493 assert_eq!(
1494 config.ignore_unresolved_imports,
1495 vec!["@example/icons", "@example/icons/**", "../generated/**"]
1496 );
1497 }
1498
1499 #[test]
1500 fn fallow_config_resolve_default_ignores() {
1501 let config = FallowConfig::default();
1502 let resolved = config.resolve(
1503 PathBuf::from("/tmp/test"),
1504 OutputFormat::Human,
1505 4,
1506 true,
1507 true,
1508 None,
1509 );
1510
1511 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1512 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1513 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1514 assert!(resolved.ignore_patterns.is_match(".git/config"));
1515 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1516 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1517 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1518 }
1519
1520 #[test]
1521 fn fallow_config_resolve_custom_ignores() {
1522 let config = FallowConfig {
1523 entry: vec!["src/**/*.ts".to_string()],
1524 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1525 ..Default::default()
1526 };
1527 let resolved = config.resolve(
1528 PathBuf::from("/tmp/test"),
1529 OutputFormat::Json,
1530 4,
1531 false,
1532 true,
1533 None,
1534 );
1535
1536 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1537 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1538 assert!(matches!(resolved.output, OutputFormat::Json));
1539 assert!(!resolved.no_cache);
1540 }
1541
1542 #[test]
1543 fn fallow_config_resolve_cache_dir() {
1544 let config = FallowConfig::default();
1545 let resolved = config.resolve(
1546 PathBuf::from("/tmp/project"),
1547 OutputFormat::Human,
1548 4,
1549 true,
1550 true,
1551 None,
1552 );
1553 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1554 assert!(resolved.no_cache);
1555 }
1556
1557 #[test]
1558 fn package_json_entry_points_main() {
1559 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1560 let entries = pkg.entry_points();
1561 assert!(entries.contains(&"dist/index.js".to_string()));
1562 }
1563
1564 #[test]
1565 fn package_json_entry_points_module() {
1566 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1567 let entries = pkg.entry_points();
1568 assert!(entries.contains(&"dist/index.mjs".to_string()));
1569 }
1570
1571 #[test]
1572 fn package_json_entry_points_types() {
1573 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1574 let entries = pkg.entry_points();
1575 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1576 }
1577
1578 #[test]
1579 fn package_json_entry_points_bin_string() {
1580 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1581 let entries = pkg.entry_points();
1582 assert!(entries.contains(&"bin/cli.js".to_string()));
1583 }
1584
1585 #[test]
1586 fn package_json_entry_points_bin_object() {
1587 let pkg: PackageJson =
1588 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1589 .unwrap();
1590 let entries = pkg.entry_points();
1591 assert!(entries.contains(&"bin/cli.js".to_string()));
1592 assert!(entries.contains(&"bin/serve.js".to_string()));
1593 }
1594
1595 #[test]
1596 fn package_json_entry_points_exports_string() {
1597 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1598 let entries = pkg.entry_points();
1599 assert!(entries.contains(&"./dist/index.js".to_string()));
1600 }
1601
1602 #[test]
1603 fn package_json_entry_points_exports_object() {
1604 let pkg: PackageJson = serde_json::from_str(
1605 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1606 )
1607 .unwrap();
1608 let entries = pkg.entry_points();
1609 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1610 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1611 }
1612
1613 #[test]
1614 fn package_json_dependency_names() {
1615 let pkg: PackageJson = serde_json::from_str(
1616 r#"{
1617 "dependencies": {"react": "^18", "lodash": "^4"},
1618 "devDependencies": {"typescript": "^5"},
1619 "peerDependencies": {"react-dom": "^18"}
1620 }"#,
1621 )
1622 .unwrap();
1623
1624 let all = pkg.all_dependency_names();
1625 assert!(all.contains(&"react".to_string()));
1626 assert!(all.contains(&"lodash".to_string()));
1627 assert!(all.contains(&"typescript".to_string()));
1628 assert!(all.contains(&"react-dom".to_string()));
1629
1630 let prod = pkg.production_dependency_names();
1631 assert!(prod.contains(&"react".to_string()));
1632 assert!(!prod.contains(&"typescript".to_string()));
1633
1634 let dev = pkg.dev_dependency_names();
1635 assert!(dev.contains(&"typescript".to_string()));
1636 assert!(!dev.contains(&"react".to_string()));
1637 }
1638
1639 #[test]
1640 fn package_json_no_dependencies() {
1641 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1642 assert!(pkg.all_dependency_names().is_empty());
1643 assert!(pkg.production_dependency_names().is_empty());
1644 assert!(pkg.dev_dependency_names().is_empty());
1645 assert!(pkg.entry_points().is_empty());
1646 }
1647
1648 #[test]
1649 fn rules_deserialize_toml_kebab_case() {
1650 let toml_str = r#"
1651[rules]
1652unused-files = "error"
1653unused-exports = "warn"
1654unused-types = "off"
1655"#;
1656 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1657 assert_eq!(config.rules.unused_files, Severity::Error);
1658 assert_eq!(config.rules.unused_exports, Severity::Warn);
1659 assert_eq!(config.rules.unused_types, Severity::Off);
1660 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1661 }
1662
1663 #[test]
1664 fn config_without_rules_defaults_to_error() {
1665 let toml_str = r#"
1666entry = ["src/main.ts"]
1667"#;
1668 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1669 assert_eq!(config.rules.unused_files, Severity::Error);
1670 assert_eq!(config.rules.unused_exports, Severity::Error);
1671 }
1672
1673 #[test]
1674 fn fallow_config_denies_unknown_fields() {
1675 let toml_str = r"
1676unknown_field = true
1677";
1678 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1679 assert!(result.is_err());
1680 }
1681
1682 #[test]
1683 fn fallow_config_deserialize_json() {
1684 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1685 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1686 assert_eq!(config.entry, vec!["src/main.ts"]);
1687 }
1688
1689 #[test]
1690 fn fallow_config_deserialize_jsonc() {
1691 let jsonc_str = r#"{
1692 "entry": ["src/main.ts"],
1693 "rules": {
1694 "unused-files": "warn"
1695 }
1696 }"#;
1697 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1698 assert_eq!(config.entry, vec!["src/main.ts"]);
1699 assert_eq!(config.rules.unused_files, Severity::Warn);
1700 }
1701
1702 #[test]
1703 fn fallow_config_json_with_schema_field() {
1704 let json_str =
1705 r#"{"$schema": "./node_modules/fallow/schema.json", "entry": ["src/main.ts"]}"#;
1706 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1707 assert_eq!(config.entry, vec!["src/main.ts"]);
1708 }
1709
1710 #[test]
1711 fn fallow_config_json_schema_generation() {
1712 let schema = FallowConfig::json_schema();
1713 assert!(schema.is_object());
1714 let obj = schema.as_object().unwrap();
1715 assert!(obj.contains_key("properties"));
1716 }
1717
1718 #[test]
1719 fn config_format_detection() {
1720 assert!(matches!(
1721 ConfigFormat::from_path(Path::new("fallow.toml")),
1722 ConfigFormat::Toml
1723 ));
1724 assert!(matches!(
1725 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1726 ConfigFormat::Json
1727 ));
1728 assert!(matches!(
1729 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1730 ConfigFormat::Json
1731 ));
1732 assert!(matches!(
1733 ConfigFormat::from_path(Path::new(".fallow.toml")),
1734 ConfigFormat::Toml
1735 ));
1736 }
1737
1738 #[test]
1739 fn config_names_priority_order() {
1740 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1741 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1742 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1743 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1744 }
1745
1746 #[test]
1747 fn load_json_config_file() {
1748 let dir = test_dir("json-config");
1749 let config_path = dir.path().join(".fallowrc.json");
1750 std::fs::write(
1751 &config_path,
1752 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1753 )
1754 .unwrap();
1755
1756 let config = FallowConfig::load(&config_path).unwrap();
1757 assert_eq!(config.entry, vec!["src/index.ts"]);
1758 assert_eq!(config.rules.unused_exports, Severity::Warn);
1759 }
1760
1761 #[test]
1762 fn load_json_config_file_with_health_threshold_override() {
1763 let dir = test_dir("json-health-threshold-override");
1764 let config_path = dir.path().join(".fallowrc.json");
1765 std::fs::write(
1766 &config_path,
1767 r#"{
1768 "health": {
1769 "thresholdOverrides": [
1770 {
1771 "files": ["src/legacy.ts"],
1772 "functions": ["legacyFlow"],
1773 "maxCyclomatic": 30,
1774 "maxCognitive": 25,
1775 "maxCrap": 80.5,
1776 "reason": "legacy migration"
1777 }
1778 ]
1779 }
1780 }"#,
1781 )
1782 .unwrap();
1783
1784 let config = FallowConfig::load(&config_path).unwrap();
1785 let override_config = &config.health.threshold_overrides[0];
1786 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1787 assert_eq!(override_config.functions, vec!["legacyFlow"]);
1788 assert_eq!(override_config.max_cyclomatic, Some(30));
1789 assert_eq!(override_config.max_cognitive, Some(25));
1790 assert_eq!(override_config.max_crap, Some(80.5));
1791 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1792 }
1793
1794 #[test]
1795 fn load_jsonc_config_file() {
1796 let dir = test_dir("jsonc-config");
1797 let config_path = dir.path().join(".fallowrc.json");
1798 std::fs::write(
1799 &config_path,
1800 r#"{
1801 "entry": ["src/index.ts"],
1802 /* Block comment */
1803 "rules": {
1804 "unused-exports": "warn"
1805 }
1806 }"#,
1807 )
1808 .unwrap();
1809
1810 let config = FallowConfig::load(&config_path).unwrap();
1811 assert_eq!(config.entry, vec!["src/index.ts"]);
1812 assert_eq!(config.rules.unused_exports, Severity::Warn);
1813 }
1814
1815 #[test]
1816 fn load_jsonc_config_file_with_health_threshold_override() {
1817 let dir = test_dir("jsonc-health-threshold-override");
1818 let config_path = dir.path().join(".fallowrc.jsonc");
1819 std::fs::write(
1820 &config_path,
1821 r#"{
1822 "health": {
1823 // Empty functions means every function in matching files.
1824 "thresholdOverrides": [
1825 { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1826 ]
1827 }
1828 }"#,
1829 )
1830 .unwrap();
1831
1832 let config = FallowConfig::load(&config_path).unwrap();
1833 let override_config = &config.health.threshold_overrides[0];
1834 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1835 assert!(override_config.functions.is_empty());
1836 assert_eq!(override_config.max_cognitive, Some(25));
1837 }
1838
1839 #[test]
1840 fn load_fallowrc_jsonc_extension() {
1841 let dir = test_dir("jsonc-extension");
1842 let config_path = dir.path().join(".fallowrc.jsonc");
1843 std::fs::write(
1844 &config_path,
1845 r#"{
1846 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1847 "entry": ["src/index.ts"]
1848 }"#,
1849 )
1850 .unwrap();
1851
1852 let config = FallowConfig::load(&config_path).unwrap();
1853 assert_eq!(config.entry, vec!["src/index.ts"]);
1854 assert_eq!(
1855 config.ignore_dependencies,
1856 vec!["tailwindcss-react-aria-components"]
1857 );
1858 }
1859
1860 #[test]
1861 fn json_config_ignore_dependencies_camel_case() {
1862 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1863 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1864 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1865 }
1866
1867 #[test]
1868 fn json_config_ignore_unresolved_imports_camel_case() {
1869 let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1870 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1871 assert_eq!(
1872 config.ignore_unresolved_imports,
1873 vec!["@example/icons", "@example/icons/**"]
1874 );
1875 }
1876
1877 #[test]
1878 fn json_config_all_fields() {
1879 let json_str = r#"{
1880 "ignoreDependencies": ["lodash"],
1881 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1882 "rules": {
1883 "unused-files": "off",
1884 "unused-exports": "warn",
1885 "unused-dependencies": "error",
1886 "unused-dev-dependencies": "off",
1887 "unused-types": "warn",
1888 "unused-enum-members": "error",
1889 "unused-class-members": "off",
1890 "unresolved-imports": "warn",
1891 "unlisted-dependencies": "error",
1892 "duplicate-exports": "off"
1893 },
1894 "duplicates": {
1895 "minTokens": 100,
1896 "minLines": 10,
1897 "skipLocal": true
1898 }
1899 }"#;
1900 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1901 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1902 assert_eq!(config.rules.unused_files, Severity::Off);
1903 assert_eq!(config.rules.unused_exports, Severity::Warn);
1904 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1905 assert_eq!(config.duplicates.min_tokens, 100);
1906 assert_eq!(config.duplicates.min_lines, 10);
1907 assert!(config.duplicates.skip_local);
1908 }
1909
1910 #[test]
1911 fn extends_single_base() {
1912 let dir = test_dir("extends-single");
1913
1914 std::fs::write(
1915 dir.path().join("base.json"),
1916 r#"{"rules": {"unused-files": "warn"}}"#,
1917 )
1918 .unwrap();
1919 std::fs::write(
1920 dir.path().join(".fallowrc.json"),
1921 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1922 )
1923 .unwrap();
1924
1925 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1926 assert_eq!(config.rules.unused_files, Severity::Warn);
1927 assert_eq!(config.entry, vec!["src/index.ts"]);
1928 assert_eq!(config.rules.unused_exports, Severity::Error);
1929 }
1930
1931 #[test]
1932 fn extends_overlay_overrides_base() {
1933 let dir = test_dir("extends-overlay");
1934
1935 std::fs::write(
1936 dir.path().join("base.json"),
1937 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1938 )
1939 .unwrap();
1940 std::fs::write(
1941 dir.path().join(".fallowrc.json"),
1942 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1943 )
1944 .unwrap();
1945
1946 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1947 assert_eq!(config.rules.unused_files, Severity::Error);
1948 assert_eq!(config.rules.unused_exports, Severity::Off);
1949 }
1950
1951 #[test]
1952 fn extends_chained() {
1953 let dir = test_dir("extends-chained");
1954
1955 std::fs::write(
1956 dir.path().join("grandparent.json"),
1957 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1958 )
1959 .unwrap();
1960 std::fs::write(
1961 dir.path().join("parent.json"),
1962 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1963 )
1964 .unwrap();
1965 std::fs::write(
1966 dir.path().join(".fallowrc.json"),
1967 r#"{"extends": ["parent.json"]}"#,
1968 )
1969 .unwrap();
1970
1971 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1972 assert_eq!(config.rules.unused_files, Severity::Warn);
1973 assert_eq!(config.rules.unused_exports, Severity::Warn);
1974 }
1975
1976 #[test]
1977 fn extends_local_diamond_reuses_resolved_base() {
1978 let dir = test_dir("extends-local-diamond");
1979 std::fs::write(
1980 dir.path().join("base.json"),
1981 r#"{"ignorePatterns": ["generated/**"]}"#,
1982 )
1983 .unwrap();
1984 std::fs::write(
1985 dir.path().join("left.json"),
1986 r#"{"extends": ["base.json"], "rules": {"unused-files": "warn"}}"#,
1987 )
1988 .unwrap();
1989 std::fs::write(
1990 dir.path().join("right.json"),
1991 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1992 )
1993 .unwrap();
1994 std::fs::write(
1995 dir.path().join(".fallowrc.json"),
1996 r#"{"extends": ["left.json", "right.json"]}"#,
1997 )
1998 .unwrap();
1999
2000 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2001 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2002 assert_eq!(config.rules.unused_files, Severity::Warn);
2003 assert_eq!(config.rules.unused_exports, Severity::Off);
2004 }
2005
2006 #[test]
2007 fn extends_circular_detected() {
2008 let dir = test_dir("extends-circular");
2009
2010 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
2011 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
2012
2013 let result = FallowConfig::load(&dir.path().join("a.json"));
2014 assert!(result.is_err());
2015 let err_msg = format!("{}", result.unwrap_err());
2016 assert!(
2017 err_msg.contains("Circular extends"),
2018 "Expected circular error, got: {err_msg}"
2019 );
2020 }
2021
2022 #[test]
2023 fn remote_extends_are_denied_before_fetch_by_default() {
2024 let dir = test_dir("remote-default-denied");
2025 let url = "https://config-user:config-password@config.example:8443/base.json?token=config-token#config-fragment";
2026 std::fs::write(
2027 dir.path().join(".fallowrc.json"),
2028 format!(r#"{{"extends": "{url}"}}"#),
2029 )
2030 .unwrap();
2031 let mut fetcher = MockRemoteFetcher::default().with_response(url, serde_json::json!({}));
2032
2033 let error = load_with_fetcher(
2034 &dir.path().join(".fallowrc.json"),
2035 ConfigLoadOptions::default(),
2036 &mut fetcher,
2037 )
2038 .unwrap_err()
2039 .to_string();
2040
2041 assert!(
2042 error.contains("https://config.example:8443/base.json"),
2043 "denial must name the URL without secrets"
2044 );
2045 for secret in [
2046 "config-user",
2047 "config-password",
2048 "config-token",
2049 "config-fragment",
2050 ] {
2051 assert!(
2052 !error.contains(secret),
2053 "denial must not expose a secret value"
2054 );
2055 }
2056 assert!(
2057 error.contains("--allow-remote-extends"),
2058 "denial must name the CLI opt-in"
2059 );
2060 assert!(
2061 error.contains("ConfigLoadOptions"),
2062 "denial must name the library opt-in"
2063 );
2064 assert!(
2065 fetcher.requests.is_empty(),
2066 "denial must happen before fetch"
2067 );
2068 }
2069
2070 #[test]
2071 fn remote_parent_and_requested_url_secrets_are_redacted_in_errors() {
2072 let dir = test_dir("remote-error-redaction");
2073 let parent = "https://parent-user:parent-password@[2001:db8::1]:8443/base.json?token=parent-token#parent-fragment";
2074 let requested = "http://child-user:child-password@child.example/child.json?token=child-token#child-fragment";
2075 std::fs::write(
2076 dir.path().join(".fallowrc.json"),
2077 format!(r#"{{"extends": "{parent}"}}"#),
2078 )
2079 .unwrap();
2080 let mut fetcher = MockRemoteFetcher::default()
2081 .with_response(parent, serde_json::json!({"extends": requested}));
2082
2083 let error = load_with_fetcher(
2084 &dir.path().join(".fallowrc.json"),
2085 ConfigLoadOptions {
2086 allow_remote_extends: true,
2087 },
2088 &mut fetcher,
2089 )
2090 .unwrap_err()
2091 .to_string();
2092
2093 assert!(
2094 error.contains("http://child.example/child.json"),
2095 "error must preserve the requested host and path"
2096 );
2097 assert!(
2098 error.contains("https://[2001:db8::1]:8443/base.json"),
2099 "error must preserve the remote parent host, port, and path"
2100 );
2101 for secret in [
2102 "parent-user",
2103 "parent-password",
2104 "parent-token",
2105 "parent-fragment",
2106 "child-user",
2107 "child-password",
2108 "child-token",
2109 "child-fragment",
2110 ] {
2111 assert!(
2112 !error.contains(secret),
2113 "remote error must not expose a secret value"
2114 );
2115 }
2116 }
2117
2118 #[test]
2119 fn remote_fetch_network_error_redacts_requested_url_and_source() {
2120 let url = "https://request-user:request-password@127.0.0.1:0/config.json?token=request-token#request-fragment";
2121 let source = "https://source-user:source-password@source.example/parent.json?token=source-token#source-fragment";
2122
2123 let error = fetch_url_config(url, source)
2124 .expect_err("the reserved local port must reject the request")
2125 .to_string();
2126
2127 assert!(
2128 error.contains("https://127.0.0.1:0/config.json"),
2129 "network error must preserve the requested host, port, and path"
2130 );
2131 assert!(
2132 error.contains("https://source.example/parent.json"),
2133 "network error must preserve the source host and path"
2134 );
2135 for secret in [
2136 "request-user",
2137 "request-password",
2138 "request-token",
2139 "request-fragment",
2140 "source-user",
2141 "source-password",
2142 "source-token",
2143 "source-fragment",
2144 ] {
2145 assert!(
2146 !error.contains(secret),
2147 "network error must not expose a secret value"
2148 );
2149 }
2150 }
2151
2152 #[test]
2153 fn remote_fetch_error_detail_does_not_trust_normalized_urls() {
2154 let url = "https://request-user:request-password@config.example/config.json?token=request-token#request-fragment";
2155 let normalized_error = ureq::Error::BadUri(
2156 "https://request-user:request-password@config.example/config.json?token=request-token"
2157 .to_string(),
2158 );
2159
2160 assert!(
2161 remote_fetch_error_display(&normalized_error, url) == "request failed",
2162 "normalized network errors must not bypass URL redaction"
2163 );
2164 }
2165
2166 #[test]
2167 fn remote_fetch_error_detail_does_not_trust_redirect_like_payloads() {
2168 let original = "https://config.example/config.json";
2169 let error = ureq::Error::BadUri(
2170 "http://redirect-user:redirect-password@example.com/next.json?token=secret#anchor"
2171 .to_string(),
2172 );
2173
2174 let display = remote_fetch_error_display(&error, original);
2175 assert_eq!(display, "request failed");
2176 for secret in [
2177 "redirect-user",
2178 "redirect-password",
2179 "token=secret",
2180 "anchor",
2181 ] {
2182 assert!(
2183 !display.contains(secret),
2184 "untrusted error detail must be hidden"
2185 );
2186 }
2187 }
2188
2189 #[test]
2190 fn remote_extends_dispatch_when_explicitly_allowed() {
2191 let dir = test_dir("remote-explicitly-allowed");
2192 let url = "https://config.example/base.json";
2193 std::fs::write(
2194 dir.path().join(".fallowrc.json"),
2195 format!(r#"{{"extends": "{url}"}}"#),
2196 )
2197 .unwrap();
2198 let mut fetcher = MockRemoteFetcher::default()
2199 .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2200
2201 let config = load_with_fetcher(
2202 &dir.path().join(".fallowrc.json"),
2203 ConfigLoadOptions {
2204 allow_remote_extends: true,
2205 },
2206 &mut fetcher,
2207 )
2208 .unwrap();
2209
2210 assert_eq!(config.rules.unused_files, Severity::Warn);
2211 assert_eq!(fetcher.requests, vec![url]);
2212 }
2213
2214 #[test]
2215 fn uppercase_https_remote_extends_dispatch_when_allowed() {
2216 let dir = test_dir("remote-uppercase-scheme");
2217 let url = "HTTPS://config.example/base.json";
2218 std::fs::write(
2219 dir.path().join(".fallowrc.json"),
2220 format!(r#"{{"extends": "{url}"}}"#),
2221 )
2222 .unwrap();
2223 let mut fetcher = MockRemoteFetcher::default()
2224 .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2225
2226 let config = load_with_fetcher(
2227 &dir.path().join(".fallowrc.json"),
2228 ConfigLoadOptions {
2229 allow_remote_extends: true,
2230 },
2231 &mut fetcher,
2232 )
2233 .unwrap();
2234
2235 assert_eq!(config.rules.unused_files, Severity::Warn);
2236 assert_eq!(fetcher.requests, vec![url]);
2237 }
2238
2239 #[test]
2240 fn remote_extends_diamond_reuses_mocked_base() {
2241 let dir = test_dir("remote-diamond");
2242 let left = "https://config.example/left.json";
2243 let right = "https://config.example/right.json";
2244 let base = "https://config.example/base.json";
2245 std::fs::write(
2246 dir.path().join(".fallowrc.json"),
2247 format!(r#"{{"extends": ["{left}", "{right}"]}}"#),
2248 )
2249 .unwrap();
2250 let mut fetcher = MockRemoteFetcher::default()
2251 .with_response(
2252 left,
2253 serde_json::json!({
2254 "extends": base,
2255 "rules": {"unused-files": "warn"}
2256 }),
2257 )
2258 .with_response(
2259 right,
2260 serde_json::json!({
2261 "extends": base,
2262 "rules": {"unused-exports": "off"}
2263 }),
2264 )
2265 .with_response(
2266 base,
2267 serde_json::json!({"ignorePatterns": ["generated/**"]}),
2268 );
2269
2270 let config = load_with_fetcher(
2271 &dir.path().join(".fallowrc.json"),
2272 ConfigLoadOptions {
2273 allow_remote_extends: true,
2274 },
2275 &mut fetcher,
2276 )
2277 .unwrap();
2278
2279 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2280 assert_eq!(config.rules.unused_files, Severity::Warn);
2281 assert_eq!(config.rules.unused_exports, Severity::Off);
2282 assert_eq!(
2283 fetcher
2284 .requests
2285 .iter()
2286 .filter(|request| *request == base)
2287 .count(),
2288 1,
2289 "the shared remote base should be fetched once"
2290 );
2291 }
2292
2293 #[test]
2294 fn remote_extends_active_cycle_still_fails() {
2295 let dir = test_dir("remote-cycle");
2296 let first = "https://config.example/first.json";
2297 let second = "https://config.example/second.json";
2298 std::fs::write(
2299 dir.path().join(".fallowrc.json"),
2300 format!(r#"{{"extends": "{first}"}}"#),
2301 )
2302 .unwrap();
2303 let mut fetcher = MockRemoteFetcher::default()
2304 .with_response(first, serde_json::json!({"extends": second}))
2305 .with_response(second, serde_json::json!({"extends": first}));
2306
2307 let error = load_with_fetcher(
2308 &dir.path().join(".fallowrc.json"),
2309 ConfigLoadOptions {
2310 allow_remote_extends: true,
2311 },
2312 &mut fetcher,
2313 )
2314 .unwrap_err()
2315 .to_string();
2316
2317 assert!(
2318 error.contains("Circular extends"),
2319 "unexpected error: {error}"
2320 );
2321 }
2322
2323 #[test]
2324 fn query_distinct_remote_extends_remain_distinct() {
2325 let dir = test_dir("remote-query-distinct");
2326 let first = "https://config.example/base.json?profile=one";
2327 let second = "https://config.example/base.json?profile=two";
2328 std::fs::write(
2329 dir.path().join(".fallowrc.json"),
2330 format!(r#"{{"extends": ["{first}", "{second}"]}}"#),
2331 )
2332 .unwrap();
2333 let mut fetcher = MockRemoteFetcher::default()
2334 .with_response(
2335 first,
2336 serde_json::json!({"rules": {"unused-files": "warn"}}),
2337 )
2338 .with_response(
2339 second,
2340 serde_json::json!({"rules": {"unused-exports": "off"}}),
2341 );
2342
2343 let config = load_with_fetcher(
2344 &dir.path().join(".fallowrc.json"),
2345 ConfigLoadOptions {
2346 allow_remote_extends: true,
2347 },
2348 &mut fetcher,
2349 )
2350 .unwrap();
2351
2352 assert_eq!(config.rules.unused_files, Severity::Warn);
2353 assert_eq!(config.rules.unused_exports, Severity::Off);
2354 assert_eq!(fetcher.requests, vec![first, second]);
2355 }
2356
2357 #[test]
2358 fn extends_missing_file_errors() {
2359 let dir = test_dir("extends-missing");
2360
2361 std::fs::write(
2362 dir.path().join(".fallowrc.json"),
2363 r#"{"extends": ["nonexistent.json"]}"#,
2364 )
2365 .unwrap();
2366
2367 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2368 assert!(result.is_err());
2369 let err_msg = format!("{}", result.unwrap_err());
2370 assert!(
2371 err_msg.contains("not found"),
2372 "Expected not found error, got: {err_msg}"
2373 );
2374 }
2375
2376 #[test]
2377 fn sealed_allows_in_directory_extends() {
2378 let dir = test_dir("sealed-allows-local");
2379 std::fs::write(
2380 dir.path().join("base.json"),
2381 r#"{"ignorePatterns": ["gen/**"]}"#,
2382 )
2383 .unwrap();
2384 std::fs::write(
2385 dir.path().join(".fallowrc.json"),
2386 r#"{"sealed": true, "extends": ["./base.json"]}"#,
2387 )
2388 .unwrap();
2389
2390 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2391 assert!(config.sealed);
2392 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2393 }
2394
2395 #[test]
2396 fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
2397 let dir = test_dir("boundary-coverage-invalid-glob");
2398 std::fs::write(
2399 dir.path().join(".fallowrc.json"),
2400 r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
2401 )
2402 .unwrap();
2403
2404 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2405 assert!(result.is_err());
2406 let err_msg = format!("{}", result.unwrap_err());
2407 assert!(
2408 err_msg.contains("boundaries.coverage.allowUnmatched"),
2409 "expected coverage field in error, got: {err_msg}"
2410 );
2411 }
2412
2413 #[test]
2414 fn sealed_rejects_extends_escaping_directory() {
2415 let dir = test_dir("sealed-rejects-escape");
2416 let sub = dir.path().join("packages").join("app");
2417 std::fs::create_dir_all(&sub).unwrap();
2418
2419 std::fs::write(
2420 dir.path().join("base.json"),
2421 r#"{"ignorePatterns": ["dist/**"]}"#,
2422 )
2423 .unwrap();
2424 std::fs::write(
2425 sub.join(".fallowrc.json"),
2426 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
2427 )
2428 .unwrap();
2429
2430 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
2431 assert!(
2432 result.is_err(),
2433 "Expected sealed config to reject escaping extends"
2434 );
2435 let err_msg = format!("{}", result.unwrap_err());
2436 assert!(
2437 err_msg.contains("sealed"),
2438 "Error must mention sealed: {err_msg}"
2439 );
2440 assert!(
2441 err_msg.contains("outside the config's directory"),
2442 "Error must explain the constraint: {err_msg}"
2443 );
2444 }
2445
2446 #[test]
2447 fn sealed_rejects_https_extends() {
2448 let dir = test_dir("sealed-rejects-https");
2449 std::fs::write(
2450 dir.path().join(".fallowrc.json"),
2451 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
2452 )
2453 .unwrap();
2454
2455 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2456 assert!(result.is_err());
2457 let err_msg = format!("{}", result.unwrap_err());
2458 assert!(
2459 err_msg.contains("sealed"),
2460 "Error must mention sealed: {err_msg}"
2461 );
2462 assert!(
2463 err_msg.contains("URL extends"),
2464 "Error must mention URL: {err_msg}"
2465 );
2466 }
2467
2468 #[test]
2469 fn sealed_rejects_npm_extends() {
2470 let dir = test_dir("sealed-rejects-npm");
2471 std::fs::write(
2472 dir.path().join(".fallowrc.json"),
2473 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
2474 )
2475 .unwrap();
2476
2477 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2478 assert!(result.is_err());
2479 let err_msg = format!("{}", result.unwrap_err());
2480 assert!(
2481 err_msg.contains("sealed"),
2482 "Error must mention sealed: {err_msg}"
2483 );
2484 assert!(
2485 err_msg.contains("npm extends"),
2486 "Error must mention npm: {err_msg}"
2487 );
2488 }
2489
2490 #[test]
2491 fn sealed_default_is_false() {
2492 let dir = test_dir("sealed-default");
2493 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
2494 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2495 assert!(!config.sealed);
2496 }
2497
2498 #[test]
2499 fn sealed_false_allows_escaping_extends() {
2500 let dir = test_dir("sealed-false-allows");
2501 let sub = dir.path().join("packages").join("app");
2502 std::fs::create_dir_all(&sub).unwrap();
2503
2504 std::fs::write(
2505 dir.path().join("base.json"),
2506 r#"{"ignorePatterns": ["dist/**"]}"#,
2507 )
2508 .unwrap();
2509 std::fs::write(
2510 sub.join(".fallowrc.json"),
2511 r#"{"extends": ["../../base.json"]}"#,
2512 )
2513 .unwrap();
2514
2515 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2516 assert!(!config.sealed);
2517 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2518 }
2519
2520 #[test]
2521 fn extends_string_sugar() {
2522 let dir = test_dir("extends-string");
2523
2524 std::fs::write(
2525 dir.path().join("base.json"),
2526 r#"{"ignorePatterns": ["gen/**"]}"#,
2527 )
2528 .unwrap();
2529 std::fs::write(
2530 dir.path().join(".fallowrc.json"),
2531 r#"{"extends": "base.json"}"#,
2532 )
2533 .unwrap();
2534
2535 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2536 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2537 }
2538
2539 #[test]
2540 fn extends_deep_merge_preserves_arrays() {
2541 let dir = test_dir("extends-array");
2542
2543 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
2544 std::fs::write(
2545 dir.path().join(".fallowrc.json"),
2546 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
2547 )
2548 .unwrap();
2549
2550 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2551 assert_eq!(config.entry, vec!["src/b.ts"]);
2552 }
2553
2554 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
2555 let pkg_dir = root.join("node_modules").join(name);
2556 std::fs::create_dir_all(&pkg_dir).unwrap();
2557 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
2558 }
2559
2560 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
2561 let pkg_dir = root.join("node_modules").join(name);
2562 std::fs::create_dir_all(&pkg_dir).unwrap();
2563 std::fs::write(
2564 pkg_dir.join("package.json"),
2565 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
2566 )
2567 .unwrap();
2568 std::fs::write(pkg_dir.join(main), config_json).unwrap();
2569 }
2570
2571 #[test]
2572 fn extends_npm_basic_unscoped() {
2573 let dir = test_dir("npm-basic");
2574 create_npm_package(
2575 dir.path(),
2576 "fallow-config-acme",
2577 r#"{"rules": {"unused-files": "warn"}}"#,
2578 );
2579 std::fs::write(
2580 dir.path().join(".fallowrc.json"),
2581 r#"{"extends": "npm:fallow-config-acme"}"#,
2582 )
2583 .unwrap();
2584
2585 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2586 assert_eq!(config.rules.unused_files, Severity::Warn);
2587 }
2588
2589 #[test]
2590 fn extends_npm_scoped_package() {
2591 let dir = test_dir("npm-scoped");
2592 create_npm_package(
2593 dir.path(),
2594 "@company/fallow-config",
2595 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
2596 );
2597 std::fs::write(
2598 dir.path().join(".fallowrc.json"),
2599 r#"{"extends": "npm:@company/fallow-config"}"#,
2600 )
2601 .unwrap();
2602
2603 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2604 assert_eq!(config.rules.unused_exports, Severity::Off);
2605 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2606 }
2607
2608 #[test]
2609 fn extends_npm_with_subpath() {
2610 let dir = test_dir("npm-subpath");
2611 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2612 std::fs::create_dir_all(&pkg_dir).unwrap();
2613 std::fs::write(
2614 pkg_dir.join("strict.json"),
2615 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2616 )
2617 .unwrap();
2618
2619 std::fs::write(
2620 dir.path().join(".fallowrc.json"),
2621 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2622 )
2623 .unwrap();
2624
2625 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2626 assert_eq!(config.rules.unused_files, Severity::Error);
2627 assert_eq!(config.rules.unused_exports, Severity::Error);
2628 }
2629
2630 #[test]
2631 fn extends_npm_package_json_main() {
2632 let dir = test_dir("npm-main");
2633 create_npm_package_with_main(
2634 dir.path(),
2635 "fallow-config-acme",
2636 "config.json",
2637 r#"{"rules": {"unused-types": "off"}}"#,
2638 );
2639 std::fs::write(
2640 dir.path().join(".fallowrc.json"),
2641 r#"{"extends": "npm:fallow-config-acme"}"#,
2642 )
2643 .unwrap();
2644
2645 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2646 assert_eq!(config.rules.unused_types, Severity::Off);
2647 }
2648
2649 #[test]
2650 fn extends_npm_package_json_exports_string() {
2651 let dir = test_dir("npm-exports-str");
2652 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2653 std::fs::create_dir_all(&pkg_dir).unwrap();
2654 std::fs::write(
2655 pkg_dir.join("package.json"),
2656 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2657 )
2658 .unwrap();
2659 std::fs::write(
2660 pkg_dir.join("base.json"),
2661 r#"{"rules": {"circular-dependencies": "warn"}}"#,
2662 )
2663 .unwrap();
2664
2665 std::fs::write(
2666 dir.path().join(".fallowrc.json"),
2667 r#"{"extends": "npm:fallow-config-co"}"#,
2668 )
2669 .unwrap();
2670
2671 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2672 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2673 }
2674
2675 #[test]
2676 fn extends_npm_package_json_exports_object() {
2677 let dir = test_dir("npm-exports-obj");
2678 let pkg_dir = dir.path().join("node_modules/@co/cfg");
2679 std::fs::create_dir_all(&pkg_dir).unwrap();
2680 std::fs::write(
2681 pkg_dir.join("package.json"),
2682 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2683 )
2684 .unwrap();
2685 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2686
2687 std::fs::write(
2688 dir.path().join(".fallowrc.json"),
2689 r#"{"extends": "npm:@co/cfg"}"#,
2690 )
2691 .unwrap();
2692
2693 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2694 assert_eq!(config.entry, vec!["src/app.ts"]);
2695 }
2696
2697 #[test]
2698 fn extends_npm_exports_takes_priority_over_main() {
2699 let dir = test_dir("npm-exports-prio");
2700 let pkg_dir = dir.path().join("node_modules/my-config");
2701 std::fs::create_dir_all(&pkg_dir).unwrap();
2702 std::fs::write(
2703 pkg_dir.join("package.json"),
2704 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2705 )
2706 .unwrap();
2707 std::fs::write(
2708 pkg_dir.join("old.json"),
2709 r#"{"rules": {"unused-files": "off"}}"#,
2710 )
2711 .unwrap();
2712 std::fs::write(
2713 pkg_dir.join("new.json"),
2714 r#"{"rules": {"unused-files": "warn"}}"#,
2715 )
2716 .unwrap();
2717
2718 std::fs::write(
2719 dir.path().join(".fallowrc.json"),
2720 r#"{"extends": "npm:my-config"}"#,
2721 )
2722 .unwrap();
2723
2724 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2725 assert_eq!(config.rules.unused_files, Severity::Warn);
2726 }
2727
2728 #[test]
2729 fn extends_npm_walk_up_directories() {
2730 let dir = test_dir("npm-walkup");
2731 create_npm_package(
2732 dir.path(),
2733 "shared-config",
2734 r#"{"rules": {"unused-files": "warn"}}"#,
2735 );
2736 let sub = dir.path().join("packages/app");
2737 std::fs::create_dir_all(&sub).unwrap();
2738 std::fs::write(
2739 sub.join(".fallowrc.json"),
2740 r#"{"extends": "npm:shared-config"}"#,
2741 )
2742 .unwrap();
2743
2744 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2745 assert_eq!(config.rules.unused_files, Severity::Warn);
2746 }
2747
2748 #[test]
2749 fn extends_npm_overlay_overrides_base() {
2750 let dir = test_dir("npm-overlay");
2751 create_npm_package(
2752 dir.path(),
2753 "@company/base",
2754 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2755 );
2756 std::fs::write(
2757 dir.path().join(".fallowrc.json"),
2758 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2759 )
2760 .unwrap();
2761
2762 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2763 assert_eq!(config.rules.unused_files, Severity::Error);
2764 assert_eq!(config.rules.unused_exports, Severity::Off);
2765 assert_eq!(config.entry, vec!["src/app.ts"]);
2766 }
2767
2768 #[test]
2769 fn extends_npm_chained_with_relative() {
2770 let dir = test_dir("npm-chained");
2771 let pkg_dir = dir.path().join("node_modules/my-config");
2772 std::fs::create_dir_all(&pkg_dir).unwrap();
2773 std::fs::write(
2774 pkg_dir.join("base.json"),
2775 r#"{"rules": {"unused-files": "warn"}}"#,
2776 )
2777 .unwrap();
2778 std::fs::write(
2779 pkg_dir.join(".fallowrc.json"),
2780 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2781 )
2782 .unwrap();
2783
2784 std::fs::write(
2785 dir.path().join(".fallowrc.json"),
2786 r#"{"extends": "npm:my-config"}"#,
2787 )
2788 .unwrap();
2789
2790 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2791 assert_eq!(config.rules.unused_files, Severity::Warn);
2792 assert_eq!(config.rules.unused_exports, Severity::Off);
2793 }
2794
2795 #[test]
2796 fn extends_npm_mixed_with_relative_paths() {
2797 let dir = test_dir("npm-mixed");
2798 create_npm_package(
2799 dir.path(),
2800 "shared-base",
2801 r#"{"rules": {"unused-files": "off"}}"#,
2802 );
2803 std::fs::write(
2804 dir.path().join("local-overrides.json"),
2805 r#"{"rules": {"unused-files": "warn"}}"#,
2806 )
2807 .unwrap();
2808 std::fs::write(
2809 dir.path().join(".fallowrc.json"),
2810 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2811 )
2812 .unwrap();
2813
2814 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2815 assert_eq!(config.rules.unused_files, Severity::Warn);
2816 }
2817
2818 #[test]
2819 fn extends_npm_missing_package_errors() {
2820 let dir = test_dir("npm-missing");
2821 std::fs::write(
2822 dir.path().join(".fallowrc.json"),
2823 r#"{"extends": "npm:nonexistent-package"}"#,
2824 )
2825 .unwrap();
2826
2827 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2828 assert!(result.is_err());
2829 let err_msg = format!("{}", result.unwrap_err());
2830 assert!(
2831 err_msg.contains("not found"),
2832 "Expected 'not found' error, got: {err_msg}"
2833 );
2834 assert!(
2835 err_msg.contains("nonexistent-package"),
2836 "Expected package name in error, got: {err_msg}"
2837 );
2838 assert!(
2839 err_msg.contains("install it"),
2840 "Expected install hint in error, got: {err_msg}"
2841 );
2842 }
2843
2844 #[test]
2845 fn extends_npm_no_config_in_package_errors() {
2846 let dir = test_dir("npm-no-config");
2847 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2848 std::fs::create_dir_all(&pkg_dir).unwrap();
2849 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2850
2851 std::fs::write(
2852 dir.path().join(".fallowrc.json"),
2853 r#"{"extends": "npm:empty-pkg"}"#,
2854 )
2855 .unwrap();
2856
2857 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2858 assert!(result.is_err());
2859 let err_msg = format!("{}", result.unwrap_err());
2860 assert!(
2861 err_msg.contains("No fallow config found"),
2862 "Expected 'No fallow config found' error, got: {err_msg}"
2863 );
2864 }
2865
2866 #[test]
2867 fn extends_npm_missing_subpath_errors() {
2868 let dir = test_dir("npm-missing-sub");
2869 let pkg_dir = dir.path().join("node_modules/@co/config");
2870 std::fs::create_dir_all(&pkg_dir).unwrap();
2871
2872 std::fs::write(
2873 dir.path().join(".fallowrc.json"),
2874 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2875 )
2876 .unwrap();
2877
2878 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2879 assert!(result.is_err());
2880 let err_msg = format!("{}", result.unwrap_err());
2881 assert!(
2882 err_msg.contains("nonexistent.json"),
2883 "Expected subpath in error, got: {err_msg}"
2884 );
2885 }
2886
2887 #[test]
2888 fn extends_npm_empty_specifier_errors() {
2889 let dir = test_dir("npm-empty");
2890 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2891
2892 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2893 assert!(result.is_err());
2894 let err_msg = format!("{}", result.unwrap_err());
2895 assert!(
2896 err_msg.contains("Empty npm specifier"),
2897 "Expected 'Empty npm specifier' error, got: {err_msg}"
2898 );
2899 }
2900
2901 #[test]
2902 fn extends_npm_space_after_colon_trimmed() {
2903 let dir = test_dir("npm-space");
2904 create_npm_package(
2905 dir.path(),
2906 "fallow-config-acme",
2907 r#"{"rules": {"unused-files": "warn"}}"#,
2908 );
2909 std::fs::write(
2910 dir.path().join(".fallowrc.json"),
2911 r#"{"extends": "npm: fallow-config-acme"}"#,
2912 )
2913 .unwrap();
2914
2915 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2916 assert_eq!(config.rules.unused_files, Severity::Warn);
2917 }
2918
2919 #[test]
2920 fn extends_npm_exports_node_condition() {
2921 let dir = test_dir("npm-node-cond");
2922 let pkg_dir = dir.path().join("node_modules/node-config");
2923 std::fs::create_dir_all(&pkg_dir).unwrap();
2924 std::fs::write(
2925 pkg_dir.join("package.json"),
2926 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2927 )
2928 .unwrap();
2929 std::fs::write(
2930 pkg_dir.join("node.json"),
2931 r#"{"rules": {"unused-files": "off"}}"#,
2932 )
2933 .unwrap();
2934
2935 std::fs::write(
2936 dir.path().join(".fallowrc.json"),
2937 r#"{"extends": "npm:node-config"}"#,
2938 )
2939 .unwrap();
2940
2941 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2942 assert_eq!(config.rules.unused_files, Severity::Off);
2943 }
2944
2945 #[test]
2946 fn parse_npm_specifier_unscoped() {
2947 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2948 }
2949
2950 #[test]
2951 fn parse_npm_specifier_unscoped_with_subpath() {
2952 assert_eq!(
2953 parse_npm_specifier("my-config/strict.json"),
2954 ("my-config", Some("strict.json"))
2955 );
2956 }
2957
2958 #[test]
2959 fn parse_npm_specifier_scoped() {
2960 assert_eq!(
2961 parse_npm_specifier("@company/fallow-config"),
2962 ("@company/fallow-config", None)
2963 );
2964 }
2965
2966 #[test]
2967 fn parse_npm_specifier_scoped_with_subpath() {
2968 assert_eq!(
2969 parse_npm_specifier("@company/fallow-config/strict.json"),
2970 ("@company/fallow-config", Some("strict.json"))
2971 );
2972 }
2973
2974 #[test]
2975 fn parse_npm_specifier_scoped_with_nested_subpath() {
2976 assert_eq!(
2977 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2978 ("@company/fallow-config", Some("presets/strict.json"))
2979 );
2980 }
2981
2982 #[test]
2983 fn extends_npm_subpath_traversal_rejected() {
2984 let dir = test_dir("npm-traversal-sub");
2985 let pkg_dir = dir.path().join("node_modules/evil-pkg");
2986 std::fs::create_dir_all(&pkg_dir).unwrap();
2987 std::fs::write(
2988 dir.path().join("secret.json"),
2989 r#"{"entry": ["stolen.ts"]}"#,
2990 )
2991 .unwrap();
2992
2993 std::fs::write(
2994 dir.path().join(".fallowrc.json"),
2995 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2996 )
2997 .unwrap();
2998
2999 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3000 assert!(result.is_err());
3001 let err_msg = format!("{}", result.unwrap_err());
3002 assert!(
3003 err_msg.contains("traversal") || err_msg.contains("not found"),
3004 "Expected traversal or not-found error, got: {err_msg}"
3005 );
3006 }
3007
3008 #[test]
3009 fn extends_npm_dotdot_package_name_rejected() {
3010 let dir = test_dir("npm-dotdot-name");
3011 std::fs::write(
3012 dir.path().join(".fallowrc.json"),
3013 r#"{"extends": "npm:../relative"}"#,
3014 )
3015 .unwrap();
3016
3017 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3018 assert!(result.is_err());
3019 let err_msg = format!("{}", result.unwrap_err());
3020 assert!(
3021 err_msg.contains("path traversal"),
3022 "Expected 'path traversal' error, got: {err_msg}"
3023 );
3024 }
3025
3026 #[test]
3027 fn extends_npm_scoped_without_name_rejected() {
3028 let dir = test_dir("npm-scope-only");
3029 std::fs::write(
3030 dir.path().join(".fallowrc.json"),
3031 r#"{"extends": "npm:@scope"}"#,
3032 )
3033 .unwrap();
3034
3035 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3036 assert!(result.is_err());
3037 let err_msg = format!("{}", result.unwrap_err());
3038 assert!(
3039 err_msg.contains("@scope/name"),
3040 "Expected scoped name format error, got: {err_msg}"
3041 );
3042 }
3043
3044 #[test]
3045 fn extends_npm_malformed_package_json_errors() {
3046 let dir = test_dir("npm-bad-pkgjson");
3047 let pkg_dir = dir.path().join("node_modules/bad-pkg");
3048 std::fs::create_dir_all(&pkg_dir).unwrap();
3049 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
3050
3051 std::fs::write(
3052 dir.path().join(".fallowrc.json"),
3053 r#"{"extends": "npm:bad-pkg"}"#,
3054 )
3055 .unwrap();
3056
3057 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3058 assert!(result.is_err());
3059 let err_msg = format!("{}", result.unwrap_err());
3060 assert!(
3061 err_msg.contains("Failed to parse"),
3062 "Expected parse error, got: {err_msg}"
3063 );
3064 }
3065
3066 #[test]
3067 fn extends_npm_exports_traversal_rejected() {
3068 let dir = test_dir("npm-exports-escape");
3069 let pkg_dir = dir.path().join("node_modules/evil-exports");
3070 std::fs::create_dir_all(&pkg_dir).unwrap();
3071 std::fs::write(
3072 pkg_dir.join("package.json"),
3073 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
3074 )
3075 .unwrap();
3076 std::fs::write(
3077 dir.path().join("secret.json"),
3078 r#"{"entry": ["stolen.ts"]}"#,
3079 )
3080 .unwrap();
3081
3082 std::fs::write(
3083 dir.path().join(".fallowrc.json"),
3084 r#"{"extends": "npm:evil-exports"}"#,
3085 )
3086 .unwrap();
3087
3088 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3089 assert!(result.is_err());
3090 let err_msg = format!("{}", result.unwrap_err());
3091 assert!(
3092 err_msg.contains("traversal"),
3093 "Expected traversal error, got: {err_msg}"
3094 );
3095 }
3096
3097 #[test]
3098 fn deep_merge_scalar_overlay_replaces_base() {
3099 let mut base = serde_json::json!("hello");
3100 deep_merge_json(&mut base, serde_json::json!("world"));
3101 assert_eq!(base, serde_json::json!("world"));
3102 }
3103
3104 #[test]
3105 fn deep_merge_array_overlay_replaces_base() {
3106 let mut base = serde_json::json!(["a", "b"]);
3107 deep_merge_json(&mut base, serde_json::json!(["c"]));
3108 assert_eq!(base, serde_json::json!(["c"]));
3109 }
3110
3111 #[test]
3112 fn deep_merge_nested_object_merge() {
3113 let mut base = serde_json::json!({
3114 "level1": {
3115 "level2": {
3116 "a": 1,
3117 "b": 2
3118 }
3119 }
3120 });
3121 let overlay = serde_json::json!({
3122 "level1": {
3123 "level2": {
3124 "b": 99,
3125 "c": 3
3126 }
3127 }
3128 });
3129 deep_merge_json(&mut base, overlay);
3130 assert_eq!(base["level1"]["level2"]["a"], 1);
3131 assert_eq!(base["level1"]["level2"]["b"], 99);
3132 assert_eq!(base["level1"]["level2"]["c"], 3);
3133 }
3134
3135 #[test]
3136 fn deep_merge_overlay_adds_new_fields() {
3137 let mut base = serde_json::json!({"existing": true});
3138 let overlay = serde_json::json!({"new_field": "added", "another": 42});
3139 deep_merge_json(&mut base, overlay);
3140 assert_eq!(base["existing"], true);
3141 assert_eq!(base["new_field"], "added");
3142 assert_eq!(base["another"], 42);
3143 }
3144
3145 #[test]
3146 fn deep_merge_null_overlay_replaces_object() {
3147 let mut base = serde_json::json!({"key": "value"});
3148 deep_merge_json(&mut base, serde_json::json!(null));
3149 assert_eq!(base, serde_json::json!(null));
3150 }
3151
3152 #[test]
3153 fn deep_merge_empty_object_overlay_preserves_base() {
3154 let mut base = serde_json::json!({"a": 1, "b": 2});
3155 deep_merge_json(&mut base, serde_json::json!({}));
3156 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3157 }
3158
3159 #[test]
3160 fn rules_severity_error_warn_off_from_json() {
3161 let json_str = r#"{
3162 "rules": {
3163 "unused-files": "error",
3164 "unused-exports": "warn",
3165 "unused-types": "off"
3166 }
3167 }"#;
3168 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3169 assert_eq!(config.rules.unused_files, Severity::Error);
3170 assert_eq!(config.rules.unused_exports, Severity::Warn);
3171 assert_eq!(config.rules.unused_types, Severity::Off);
3172 }
3173
3174 #[test]
3175 fn rules_omitted_default_to_error() {
3176 let json_str = r#"{
3177 "rules": {
3178 "unused-files": "warn"
3179 }
3180 }"#;
3181 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3182 assert_eq!(config.rules.unused_files, Severity::Warn);
3183 assert_eq!(config.rules.unused_exports, Severity::Error);
3184 assert_eq!(config.rules.unused_types, Severity::Error);
3185 assert_eq!(config.rules.unused_dependencies, Severity::Error);
3186 assert_eq!(config.rules.unresolved_imports, Severity::Error);
3187 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
3188 assert_eq!(config.rules.duplicate_exports, Severity::Error);
3189 assert_eq!(config.rules.circular_dependencies, Severity::Error);
3190 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
3191 }
3192
3193 #[test]
3194 fn find_and_load_returns_none_when_no_config() {
3195 let dir = test_dir("find-none");
3196 std::fs::create_dir(dir.path().join(".git")).unwrap();
3197
3198 let result = FallowConfig::find_and_load(dir.path()).unwrap();
3199 assert!(result.is_none());
3200 }
3201
3202 #[test]
3203 fn find_and_load_finds_fallowrc_json() {
3204 let dir = test_dir("find-json");
3205 std::fs::create_dir(dir.path().join(".git")).unwrap();
3206 std::fs::write(
3207 dir.path().join(".fallowrc.json"),
3208 r#"{"entry": ["src/main.ts"]}"#,
3209 )
3210 .unwrap();
3211
3212 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3213 assert_eq!(config.entry, vec!["src/main.ts"]);
3214 assert!(path.ends_with(".fallowrc.json"));
3215 }
3216
3217 #[test]
3218 fn find_and_load_finds_fallowrc_jsonc() {
3219 let dir = test_dir("find-jsonc");
3220 std::fs::create_dir(dir.path().join(".git")).unwrap();
3221 std::fs::write(
3222 dir.path().join(".fallowrc.jsonc"),
3223 r#"{
3224 "entry": ["src/main.ts"]
3225 }"#,
3226 )
3227 .unwrap();
3228
3229 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3230 assert_eq!(config.entry, vec!["src/main.ts"]);
3231 assert!(path.ends_with(".fallowrc.jsonc"));
3232 }
3233
3234 #[test]
3235 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
3236 let dir = test_dir("find-json-vs-jsonc");
3237 std::fs::create_dir(dir.path().join(".git")).unwrap();
3238 std::fs::write(
3239 dir.path().join(".fallowrc.json"),
3240 r#"{"entry": ["from-json.ts"]}"#,
3241 )
3242 .unwrap();
3243 std::fs::write(
3244 dir.path().join(".fallowrc.jsonc"),
3245 r#"{"entry": ["from-jsonc.ts"]}"#,
3246 )
3247 .unwrap();
3248
3249 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3250 assert_eq!(config.entry, vec!["from-json.ts"]);
3251 assert!(path.ends_with(".fallowrc.json"));
3252 }
3253
3254 #[test]
3255 fn find_and_load_prefers_fallowrc_json_over_toml() {
3256 let dir = test_dir("find-priority");
3257 std::fs::create_dir(dir.path().join(".git")).unwrap();
3258 std::fs::write(
3259 dir.path().join(".fallowrc.json"),
3260 r#"{"entry": ["from-json.ts"]}"#,
3261 )
3262 .unwrap();
3263 std::fs::write(
3264 dir.path().join("fallow.toml"),
3265 "entry = [\"from-toml.ts\"]\n",
3266 )
3267 .unwrap();
3268
3269 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3270 assert_eq!(config.entry, vec!["from-json.ts"]);
3271 assert!(path.ends_with(".fallowrc.json"));
3272 }
3273
3274 #[test]
3275 fn shadowed_config_names_empty_when_single_config() {
3276 let dir = test_dir("shadow-single");
3277 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3278 assert!(shadowed_config_names(dir.path(), 0).is_empty());
3279 }
3280
3281 #[test]
3282 fn shadowed_config_names_reports_lower_precedence_toml() {
3283 let dir = test_dir("shadow-json-toml");
3284 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3285 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3286 assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
3287 }
3288
3289 #[test]
3290 fn shadowed_config_names_reports_jsonc_sibling() {
3291 let dir = test_dir("shadow-json-jsonc");
3292 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3293 std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
3294 assert_eq!(
3295 shadowed_config_names(dir.path(), 0),
3296 vec![".fallowrc.jsonc"]
3297 );
3298 }
3299
3300 #[test]
3301 fn shadowed_config_names_reports_all_lower_when_four_coexist() {
3302 let dir = test_dir("shadow-all-four");
3303 for name in CONFIG_NAMES {
3304 std::fs::write(dir.path().join(name), "").unwrap();
3305 }
3306 assert_eq!(
3307 shadowed_config_names(dir.path(), 0),
3308 vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
3309 );
3310 }
3311
3312 #[test]
3313 fn shadowed_config_names_scoped_to_indices_after_winner() {
3314 let dir = test_dir("shadow-toml-dottoml");
3315 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3316 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
3317 assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
3318 }
3319
3320 #[test]
3321 fn find_and_load_warns_when_configs_coexist() {
3322 let dir = test_dir("coexist-warn");
3323 std::fs::create_dir(dir.path().join(".git")).unwrap();
3324 std::fs::write(
3325 dir.path().join(".fallowrc.json"),
3326 r#"{"entry": ["from-json.ts"]}"#,
3327 )
3328 .unwrap();
3329 std::fs::write(
3330 dir.path().join("fallow.toml"),
3331 "entry = [\"from-toml.ts\"]\n",
3332 )
3333 .unwrap();
3334
3335 let (result, captured) =
3336 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3337
3338 let (config, path) = result.unwrap().unwrap();
3339 assert_eq!(config.entry, vec!["from-json.ts"]);
3340 assert!(path.ends_with(".fallowrc.json"));
3341
3342 assert_eq!(captured.len(), 1);
3343 let (chosen, shadowed) = &captured[0];
3344 assert_eq!(chosen, ".fallowrc.json");
3345 assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
3346 }
3347
3348 #[test]
3349 fn find_and_load_does_not_warn_for_single_config() {
3350 let dir = test_dir("coexist-none");
3351 std::fs::create_dir(dir.path().join(".git")).unwrap();
3352 std::fs::write(
3353 dir.path().join(".fallowrc.json"),
3354 r#"{"entry": ["only.ts"]}"#,
3355 )
3356 .unwrap();
3357
3358 let (result, captured) =
3359 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3360 assert!(result.unwrap().is_some());
3361 assert!(captured.is_empty());
3362 }
3363
3364 #[test]
3365 fn find_and_load_warns_per_directory_independently() {
3366 let make = |name: &str| {
3367 let dir = test_dir(name);
3368 std::fs::create_dir(dir.path().join(".git")).unwrap();
3369 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
3370 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
3371 dir
3372 };
3373 let first = make("coexist-dir-a");
3374 let second = make("coexist-dir-b");
3375
3376 let ((), captured) = capture_coexisting_config_warnings(|| {
3377 FallowConfig::find_and_load(first.path()).unwrap();
3378 FallowConfig::find_and_load(second.path()).unwrap();
3379 });
3380
3381 assert_eq!(captured.len(), 2);
3382 assert!(captured.iter().all(|(chosen, shadowed)| {
3383 chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
3384 }));
3385 }
3386
3387 #[test]
3388 fn explicit_load_does_not_warn_about_coexisting_configs() {
3389 let dir = test_dir("coexist-explicit");
3390 std::fs::write(
3391 dir.path().join(".fallowrc.json"),
3392 r#"{"entry": ["chosen.ts"]}"#,
3393 )
3394 .unwrap();
3395 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
3396
3397 let chosen = dir.path().join("fallow.toml");
3398 let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
3399 assert!(result.is_ok());
3400 assert!(captured.is_empty());
3401 }
3402
3403 #[test]
3404 fn find_and_load_finds_fallow_toml() {
3405 let dir = test_dir("find-toml");
3406 std::fs::create_dir(dir.path().join(".git")).unwrap();
3407 std::fs::write(
3408 dir.path().join("fallow.toml"),
3409 "entry = [\"src/index.ts\"]\n",
3410 )
3411 .unwrap();
3412
3413 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3414 assert_eq!(config.entry, vec!["src/index.ts"]);
3415 }
3416
3417 #[test]
3418 fn find_and_load_stops_at_git_dir() {
3419 let dir = test_dir("find-git-stop");
3420 let sub = dir.path().join("sub");
3421 std::fs::create_dir(&sub).unwrap();
3422 std::fs::create_dir(dir.path().join(".git")).unwrap();
3423 let result = FallowConfig::find_and_load(&sub).unwrap();
3424 assert!(result.is_none());
3425 }
3426
3427 #[test]
3428 fn find_and_load_walks_past_package_json_in_monorepo() {
3429 let dir = test_dir("find-monorepo");
3430 std::fs::create_dir(dir.path().join(".git")).unwrap();
3431 std::fs::write(
3432 dir.path().join(".fallowrc.json"),
3433 r#"{"entry": ["src/index.ts"]}"#,
3434 )
3435 .unwrap();
3436
3437 let sub = dir.path().join("packages").join("app");
3438 std::fs::create_dir_all(&sub).unwrap();
3439 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3440
3441 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3442 assert_eq!(config.entry, vec!["src/index.ts"]);
3443 assert_eq!(path, dir.path().join(".fallowrc.json"));
3444 }
3445
3446 #[test]
3447 fn find_and_load_sub_package_config_wins_over_root() {
3448 let dir = test_dir("find-monorepo-override");
3449 std::fs::create_dir(dir.path().join(".git")).unwrap();
3450 std::fs::write(
3451 dir.path().join(".fallowrc.json"),
3452 r#"{"entry": ["src/root.ts"]}"#,
3453 )
3454 .unwrap();
3455
3456 let sub = dir.path().join("packages").join("app");
3457 std::fs::create_dir_all(&sub).unwrap();
3458 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3459 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
3460
3461 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3462 assert_eq!(config.entry, vec!["src/sub.ts"]);
3463 assert_eq!(path, sub.join(".fallowrc.json"));
3464 }
3465
3466 #[test]
3467 fn find_and_load_stops_at_git_file_submodule() {
3468 let dir = test_dir("find-git-file");
3469 std::fs::create_dir(dir.path().join(".git")).unwrap();
3470 std::fs::write(
3471 dir.path().join(".fallowrc.json"),
3472 r#"{"entry": ["src/parent.ts"]}"#,
3473 )
3474 .unwrap();
3475
3476 let submodule = dir.path().join("vendor").join("lib");
3477 std::fs::create_dir_all(&submodule).unwrap();
3478 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
3479
3480 let result = FallowConfig::find_and_load(&submodule).unwrap();
3481 assert!(
3482 result.is_none(),
3483 "submodule boundary should stop config walk",
3484 );
3485 }
3486
3487 #[test]
3488 fn find_and_load_stops_at_hg_dir() {
3489 let dir = test_dir("find-hg-stop");
3490 let sub = dir.path().join("sub");
3491 std::fs::create_dir(&sub).unwrap();
3492 std::fs::create_dir(dir.path().join(".hg")).unwrap();
3493
3494 let result = FallowConfig::find_and_load(&sub).unwrap();
3495 assert!(result.is_none());
3496 }
3497
3498 #[test]
3499 fn find_and_load_returns_error_for_invalid_config() {
3500 let dir = test_dir("find-invalid");
3501 std::fs::create_dir(dir.path().join(".git")).unwrap();
3502 std::fs::write(
3503 dir.path().join(".fallowrc.json"),
3504 r"{ this is not valid json }",
3505 )
3506 .unwrap();
3507
3508 let result = FallowConfig::find_and_load(dir.path());
3509 assert!(result.is_err());
3510 }
3511
3512 #[test]
3513 fn load_toml_config_file() {
3514 let dir = test_dir("toml-config");
3515 let config_path = dir.path().join("fallow.toml");
3516 std::fs::write(
3517 &config_path,
3518 r#"
3519entry = ["src/index.ts"]
3520ignorePatterns = ["dist/**"]
3521
3522[rules]
3523unused-files = "warn"
3524
3525[duplicates]
3526minTokens = 100
3527"#,
3528 )
3529 .unwrap();
3530
3531 let config = FallowConfig::load(&config_path).unwrap();
3532 assert_eq!(config.entry, vec!["src/index.ts"]);
3533 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
3534 assert_eq!(config.rules.unused_files, Severity::Warn);
3535 assert_eq!(config.duplicates.min_tokens, 100);
3536 }
3537
3538 #[test]
3539 fn load_toml_config_file_with_health_threshold_override() {
3540 let dir = test_dir("toml-health-threshold-override");
3541 let config_path = dir.path().join("fallow.toml");
3542 std::fs::write(
3543 &config_path,
3544 r#"
3545[health]
3546thresholdOverrides = [
3547 { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
3548]
3549"#,
3550 )
3551 .unwrap();
3552
3553 let config = FallowConfig::load(&config_path).unwrap();
3554 let override_config = &config.health.threshold_overrides[0];
3555 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
3556 assert_eq!(override_config.functions, vec!["legacyFlow"]);
3557 assert_eq!(override_config.max_cyclomatic, Some(30));
3558 assert_eq!(override_config.max_cognitive, Some(25));
3559 assert_eq!(override_config.max_crap, Some(80.5));
3560 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
3561 }
3562
3563 #[test]
3564 fn extends_absolute_path_rejected() {
3565 let dir = test_dir("extends-absolute");
3566
3567 #[cfg(unix)]
3568 let abs_path = "/absolute/path/config.json";
3569 #[cfg(windows)]
3570 let abs_path = "C:\\absolute\\path\\config.json";
3571
3572 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
3573 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
3574
3575 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3576 assert!(result.is_err());
3577 let err_msg = format!("{}", result.unwrap_err());
3578 assert!(
3579 err_msg.contains("must be relative"),
3580 "Expected 'must be relative' error, got: {err_msg}"
3581 );
3582 }
3583
3584 #[test]
3585 fn extends_windows_drive_absolute_path_rejected_on_any_host() {
3586 let dir = test_dir("extends-windows-absolute");
3587
3588 std::fs::write(
3589 dir.path().join(".fallowrc.json"),
3590 r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
3591 )
3592 .unwrap();
3593
3594 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3595 assert!(result.is_err());
3596 let err_msg = format!("{}", result.unwrap_err());
3597 assert!(
3598 err_msg.contains("must be relative"),
3599 "Expected 'must be relative' error, got: {err_msg}"
3600 );
3601 }
3602
3603 #[cfg(windows)]
3604 #[test]
3605 fn extends_posix_rooted_absolute_path_rejected_on_windows() {
3606 let dir = test_dir("extends-posix-rooted-absolute");
3607
3608 std::fs::write(
3609 dir.path().join(".fallowrc.json"),
3610 r#"{"extends": ["/absolute/path/config.json"]}"#,
3611 )
3612 .unwrap();
3613
3614 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3615 assert!(result.is_err());
3616 let err_msg = format!("{}", result.unwrap_err());
3617 assert!(
3618 err_msg.contains("must be relative"),
3619 "Expected 'must be relative' error, got: {err_msg}"
3620 );
3621 }
3622
3623 #[test]
3624 fn resolve_production_mode_disables_dev_deps() {
3625 let config = FallowConfig {
3626 production: true.into(),
3627 ..Default::default()
3628 };
3629 let resolved = config.resolve(
3630 PathBuf::from("/tmp/test"),
3631 OutputFormat::Human,
3632 4,
3633 false,
3634 true,
3635 None,
3636 );
3637 assert!(resolved.production);
3638 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3639 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3640 assert_eq!(resolved.rules.unused_files, Severity::Error);
3641 assert_eq!(resolved.rules.unused_exports, Severity::Error);
3642 }
3643
3644 #[test]
3645 fn include_entry_exports_deserializes_from_camelcase_json() {
3646 let json = r#"{ "includeEntryExports": true }"#;
3647 let config: FallowConfig = serde_json::from_str(json).unwrap();
3648 assert!(config.include_entry_exports);
3649 }
3650
3651 #[test]
3652 fn include_entry_exports_deserializes_from_camelcase_toml() {
3653 let toml_str = "includeEntryExports = true\n";
3654 let config: FallowConfig = toml::from_str(toml_str).unwrap();
3655 assert!(config.include_entry_exports);
3656 }
3657
3658 #[test]
3659 fn include_entry_exports_default_is_false() {
3660 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3661 assert!(!config.include_entry_exports);
3662 }
3663
3664 #[test]
3665 fn include_entry_exports_propagates_through_resolve() {
3666 let config = FallowConfig {
3667 include_entry_exports: true,
3668 auto_imports: false,
3669 cache: CacheConfig::default(),
3670 ..Default::default()
3671 };
3672 let resolved = config.resolve(
3673 PathBuf::from("/tmp/test"),
3674 OutputFormat::Human,
3675 1,
3676 true,
3677 true,
3678 None,
3679 );
3680 assert!(resolved.include_entry_exports);
3681 }
3682
3683 #[test]
3684 fn config_format_defaults_to_toml_for_unknown() {
3685 assert!(matches!(
3686 ConfigFormat::from_path(Path::new("config.yaml")),
3687 ConfigFormat::Toml
3688 ));
3689 assert!(matches!(
3690 ConfigFormat::from_path(Path::new("config")),
3691 ConfigFormat::Toml
3692 ));
3693 }
3694
3695 #[test]
3696 fn deep_merge_object_over_scalar_replaces() {
3697 let mut base = serde_json::json!("just a string");
3698 let overlay = serde_json::json!({"key": "value"});
3699 deep_merge_json(&mut base, overlay);
3700 assert_eq!(base, serde_json::json!({"key": "value"}));
3701 }
3702
3703 #[test]
3704 fn deep_merge_scalar_over_object_replaces() {
3705 let mut base = serde_json::json!({"key": "value"});
3706 let overlay = serde_json::json!(42);
3707 deep_merge_json(&mut base, overlay);
3708 assert_eq!(base, serde_json::json!(42));
3709 }
3710
3711 #[test]
3712 fn extends_non_string_non_array_ignored() {
3713 let dir = test_dir("extends-numeric");
3714 std::fs::write(
3715 dir.path().join(".fallowrc.json"),
3716 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3717 )
3718 .unwrap();
3719
3720 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3721 assert_eq!(config.entry, vec!["src/index.ts"]);
3722 }
3723
3724 #[test]
3725 fn extends_multiple_bases_later_wins() {
3726 let dir = test_dir("extends-multi-base");
3727
3728 std::fs::write(
3729 dir.path().join("base-a.json"),
3730 r#"{"rules": {"unused-files": "warn"}}"#,
3731 )
3732 .unwrap();
3733 std::fs::write(
3734 dir.path().join("base-b.json"),
3735 r#"{"rules": {"unused-files": "off"}}"#,
3736 )
3737 .unwrap();
3738 std::fs::write(
3739 dir.path().join(".fallowrc.json"),
3740 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3741 )
3742 .unwrap();
3743
3744 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3745 assert_eq!(config.rules.unused_files, Severity::Off);
3746 }
3747
3748 #[test]
3749 fn load_rejects_empty_security_request_receivers() {
3750 let dir = test_dir("empty-security-request-receivers");
3751 std::fs::write(
3752 dir.path().join(".fallowrc.json"),
3753 r#"{"security": {"requestReceivers": ["req", " "]}}"#,
3754 )
3755 .unwrap();
3756
3757 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3758 let err = result.expect_err("empty receiver should be rejected");
3759 assert!(
3760 err.to_string().contains("security.requestReceivers"),
3761 "error should name security.requestReceivers: {err}"
3762 );
3763 }
3764
3765 #[test]
3766 fn resolve_normalizes_security_request_receivers() {
3767 let dir = test_dir("normalize-security-request-receivers");
3768 std::fs::write(
3769 dir.path().join(".fallowrc.json"),
3770 r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3771 )
3772 .unwrap();
3773
3774 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3775 .unwrap()
3776 .resolve(
3777 dir.path().to_path_buf(),
3778 OutputFormat::Human,
3779 1,
3780 true,
3781 true,
3782 None,
3783 );
3784 assert_eq!(
3785 config.security.request_receivers,
3786 vec!["httpreq".to_string(), "r".to_string()]
3787 );
3788 }
3789
3790 #[test]
3791 fn fallow_config_deserialize_production() {
3792 let json_str = r#"{"production": true}"#;
3793 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3794 assert!(config.production);
3795 }
3796
3797 #[test]
3798 fn fallow_config_production_defaults_false() {
3799 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3800 assert!(!config.production);
3801 }
3802
3803 #[test]
3804 fn package_json_optional_dependency_names() {
3805 let pkg: PackageJson = serde_json::from_str(
3806 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3807 )
3808 .unwrap();
3809 let opt = pkg.optional_dependency_names();
3810 assert_eq!(opt.len(), 2);
3811 assert!(opt.contains(&"fsevents".to_string()));
3812 assert!(opt.contains(&"chokidar".to_string()));
3813 }
3814
3815 #[test]
3816 fn package_json_optional_deps_empty_when_missing() {
3817 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3818 assert!(pkg.optional_dependency_names().is_empty());
3819 }
3820
3821 #[test]
3822 fn find_config_path_returns_fallowrc_json() {
3823 let dir = test_dir("find-path-json");
3824 std::fs::create_dir(dir.path().join(".git")).unwrap();
3825 std::fs::write(
3826 dir.path().join(".fallowrc.json"),
3827 r#"{"entry": ["src/main.ts"]}"#,
3828 )
3829 .unwrap();
3830
3831 let path = FallowConfig::find_config_path(dir.path());
3832 assert!(path.is_some());
3833 assert!(path.unwrap().ends_with(".fallowrc.json"));
3834 }
3835
3836 #[test]
3837 fn find_config_path_returns_fallow_toml() {
3838 let dir = test_dir("find-path-toml");
3839 std::fs::create_dir(dir.path().join(".git")).unwrap();
3840 std::fs::write(
3841 dir.path().join("fallow.toml"),
3842 "entry = [\"src/main.ts\"]\n",
3843 )
3844 .unwrap();
3845
3846 let path = FallowConfig::find_config_path(dir.path());
3847 assert!(path.is_some());
3848 assert!(path.unwrap().ends_with("fallow.toml"));
3849 }
3850
3851 #[test]
3852 fn find_config_path_returns_dot_fallow_toml() {
3853 let dir = test_dir("find-path-dot-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_prefers_json_over_toml() {
3868 let dir = test_dir("find-path-priority");
3869 std::fs::create_dir(dir.path().join(".git")).unwrap();
3870 std::fs::write(
3871 dir.path().join(".fallowrc.json"),
3872 r#"{"entry": ["json.ts"]}"#,
3873 )
3874 .unwrap();
3875 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3876
3877 let path = FallowConfig::find_config_path(dir.path());
3878 assert!(path.unwrap().ends_with(".fallowrc.json"));
3879 }
3880
3881 #[test]
3882 fn find_config_path_none_when_no_config() {
3883 let dir = test_dir("find-path-none");
3884 std::fs::create_dir(dir.path().join(".git")).unwrap();
3885
3886 let path = FallowConfig::find_config_path(dir.path());
3887 assert!(path.is_none());
3888 }
3889
3890 #[test]
3891 fn find_config_path_walks_past_package_json_in_monorepo() {
3892 let dir = test_dir("find-path-monorepo");
3893 std::fs::create_dir(dir.path().join(".git")).unwrap();
3894 std::fs::write(
3895 dir.path().join(".fallowrc.json"),
3896 r#"{"entry": ["src/index.ts"]}"#,
3897 )
3898 .unwrap();
3899
3900 let sub = dir.path().join("packages").join("app");
3901 std::fs::create_dir_all(&sub).unwrap();
3902 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3903
3904 let path = FallowConfig::find_config_path(&sub).unwrap();
3905 assert_eq!(path, dir.path().join(".fallowrc.json"));
3906 }
3907
3908 #[test]
3909 fn extends_toml_base() {
3910 let dir = test_dir("extends-toml");
3911
3912 std::fs::write(
3913 dir.path().join("base.json"),
3914 r#"{"rules": {"unused-files": "warn"}}"#,
3915 )
3916 .unwrap();
3917 std::fs::write(
3918 dir.path().join("fallow.toml"),
3919 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3920 )
3921 .unwrap();
3922
3923 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3924 assert_eq!(config.rules.unused_files, Severity::Warn);
3925 assert_eq!(config.entry, vec!["src/index.ts"]);
3926 }
3927
3928 #[test]
3929 fn deep_merge_boolean_overlay() {
3930 let mut base = serde_json::json!(true);
3931 deep_merge_json(&mut base, serde_json::json!(false));
3932 assert_eq!(base, serde_json::json!(false));
3933 }
3934
3935 #[test]
3936 fn deep_merge_number_overlay() {
3937 let mut base = serde_json::json!(42);
3938 deep_merge_json(&mut base, serde_json::json!(99));
3939 assert_eq!(base, serde_json::json!(99));
3940 }
3941
3942 #[test]
3943 fn deep_merge_disjoint_objects() {
3944 let mut base = serde_json::json!({"a": 1});
3945 let overlay = serde_json::json!({"b": 2});
3946 deep_merge_json(&mut base, overlay);
3947 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3948 }
3949
3950 #[test]
3951 fn max_extends_depth_is_reasonable() {
3952 assert_eq!(MAX_EXTENDS_DEPTH, 10);
3953 }
3954
3955 #[test]
3956 fn config_names_has_four_entries() {
3957 assert_eq!(CONFIG_NAMES.len(), 4);
3958 for name in CONFIG_NAMES {
3959 assert!(
3960 name.starts_with('.') || name.starts_with("fallow"),
3961 "unexpected config name: {name}"
3962 );
3963 }
3964 }
3965
3966 #[test]
3967 fn package_json_peer_dependency_names() {
3968 let pkg: PackageJson = serde_json::from_str(
3969 r#"{
3970 "dependencies": {"react": "^18"},
3971 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3972 }"#,
3973 )
3974 .unwrap();
3975 let all = pkg.all_dependency_names();
3976 assert!(all.contains(&"react".to_string()));
3977 assert!(all.contains(&"react-dom".to_string()));
3978 assert!(all.contains(&"react-native".to_string()));
3979 }
3980
3981 #[test]
3982 fn package_json_scripts_field() {
3983 let pkg: PackageJson = serde_json::from_str(
3984 r#"{
3985 "scripts": {
3986 "build": "tsc",
3987 "test": "vitest",
3988 "lint": "fallow check"
3989 }
3990 }"#,
3991 )
3992 .unwrap();
3993 let scripts = pkg.scripts.unwrap();
3994 assert_eq!(scripts.len(), 3);
3995 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3996 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3997 }
3998
3999 #[test]
4000 fn extends_toml_chain() {
4001 let dir = test_dir("extends-toml-chain");
4002
4003 std::fs::write(
4004 dir.path().join("base.json"),
4005 r#"{"entry": ["src/base.ts"]}"#,
4006 )
4007 .unwrap();
4008 std::fs::write(
4009 dir.path().join("middle.json"),
4010 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4011 )
4012 .unwrap();
4013 std::fs::write(
4014 dir.path().join("fallow.toml"),
4015 "extends = [\"middle.json\"]\n",
4016 )
4017 .unwrap();
4018
4019 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4020 assert_eq!(config.entry, vec!["src/base.ts"]);
4021 assert_eq!(config.rules.unused_files, Severity::Off);
4022 }
4023
4024 #[test]
4025 fn find_and_load_walks_up_directories() {
4026 let dir = test_dir("find-walk-up");
4027 let sub = dir.path().join("src").join("deep");
4028 std::fs::create_dir_all(&sub).unwrap();
4029 std::fs::write(
4030 dir.path().join(".fallowrc.json"),
4031 r#"{"entry": ["src/main.ts"]}"#,
4032 )
4033 .unwrap();
4034 std::fs::create_dir(dir.path().join(".git")).unwrap();
4035
4036 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4037 assert_eq!(config.entry, vec!["src/main.ts"]);
4038 assert!(path.ends_with(".fallowrc.json"));
4039 }
4040
4041 #[test]
4042 fn json_schema_contains_entry_field() {
4043 let schema = FallowConfig::json_schema();
4044 let obj = schema.as_object().unwrap();
4045 let props = obj.get("properties").and_then(|v| v.as_object());
4046 assert!(props.is_some(), "schema should have properties");
4047 assert!(
4048 props.unwrap().contains_key("entry"),
4049 "schema should contain entry property"
4050 );
4051 }
4052
4053 #[test]
4054 fn fallow_config_json_duplicates_all_fields() {
4055 let json = r#"{
4056 "duplicates": {
4057 "enabled": true,
4058 "mode": "semantic",
4059 "minTokens": 200,
4060 "minLines": 20,
4061 "threshold": 10.5,
4062 "ignore": ["**/*.test.ts"],
4063 "skipLocal": true,
4064 "crossLanguage": true,
4065 "normalization": {
4066 "ignoreIdentifiers": true,
4067 "ignoreStringValues": false
4068 }
4069 }
4070 }"#;
4071 let config: FallowConfig = serde_json::from_str(json).unwrap();
4072 assert!(config.duplicates.enabled);
4073 assert_eq!(
4074 config.duplicates.mode,
4075 crate::config::DetectionMode::Semantic
4076 );
4077 assert_eq!(config.duplicates.min_tokens, 200);
4078 assert_eq!(config.duplicates.min_lines, 20);
4079 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4080 assert!(config.duplicates.skip_local);
4081 assert!(config.duplicates.cross_language);
4082 assert_eq!(
4083 config.duplicates.normalization.ignore_identifiers,
4084 Some(true)
4085 );
4086 assert_eq!(
4087 config.duplicates.normalization.ignore_string_values,
4088 Some(false)
4089 );
4090 }
4091
4092 #[test]
4093 fn normalize_url_basic() {
4094 assert_eq!(
4095 normalize_url_for_dedup("https://example.com/config.json"),
4096 "https://example.com/config.json"
4097 );
4098 }
4099
4100 #[test]
4101 fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4102 let cases = [
4103 (
4104 "https://user:password@example.com/config.json",
4105 "https://example.com/config.json",
4106 ),
4107 (
4108 "https://example.com/config.json?token=query-secret#fragment-secret",
4109 "https://example.com/config.json",
4110 ),
4111 (
4112 "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4113 "https://[2001:db8::1]:8443/config.json",
4114 ),
4115 (
4116 "/workspace/configs/base.json?literal-query#literal-fragment",
4117 "/workspace/configs/base.json?literal-query#literal-fragment",
4118 ),
4119 ];
4120
4121 for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4122 assert!(
4123 remote_config_display(input) == expected,
4124 "remote config display case {case_index} must be sanitized"
4125 );
4126 }
4127 }
4128
4129 #[test]
4130 fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4131 let redirect_target =
4132 "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4133 let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4134
4135 let display =
4136 remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4137
4138 assert_eq!(
4139 display,
4140 "configured for https only: http://example.com/config.json"
4141 );
4142 for secret in [
4143 "redirect-user",
4144 "redirect-password",
4145 "token=secret",
4146 "anchor",
4147 ] {
4148 assert!(
4149 !display.contains(secret),
4150 "redirect secret must be redacted"
4151 );
4152 }
4153 }
4154
4155 #[test]
4156 fn normalize_url_preserves_non_root_trailing_slash() {
4157 assert_eq!(
4158 normalize_url_for_dedup("https://example.com/config/"),
4159 "https://example.com/config/"
4160 );
4161 }
4162
4163 #[test]
4164 fn normalize_url_uppercase_scheme_and_host() {
4165 assert_eq!(
4166 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4167 "https://example.com/Config.json"
4168 );
4169 }
4170
4171 #[test]
4172 fn normalize_url_root_path() {
4173 assert_eq!(
4174 normalize_url_for_dedup("https://example.com/"),
4175 "https://example.com"
4176 );
4177 assert_eq!(
4178 normalize_url_for_dedup("https://example.com"),
4179 "https://example.com"
4180 );
4181 }
4182
4183 #[test]
4184 fn normalize_url_preserves_path_case() {
4185 assert_eq!(
4186 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4187 "https://github.com/Org/Repo/Fallow.json"
4188 );
4189 }
4190
4191 #[test]
4192 fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4193 assert_eq!(
4194 normalize_url_for_dedup(
4195 "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4196 ),
4197 "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4198 );
4199 }
4200
4201 #[test]
4202 fn normalize_url_preserves_query_string() {
4203 assert_eq!(
4204 normalize_url_for_dedup("https://example.com/config.json?v=1"),
4205 "https://example.com/config.json?v=1"
4206 );
4207 }
4208
4209 #[test]
4210 fn normalize_url_strips_fragment() {
4211 assert_eq!(
4212 normalize_url_for_dedup("https://example.com/config.json#section"),
4213 "https://example.com/config.json"
4214 );
4215 }
4216
4217 #[test]
4218 fn normalize_url_preserves_query_and_strips_fragment() {
4219 assert_eq!(
4220 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4221 "https://example.com/config.json?v=1"
4222 );
4223 }
4224
4225 #[test]
4226 fn normalize_url_query_selects_resource_but_fragment_does_not() {
4227 assert_ne!(
4228 normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4229 normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4230 );
4231 assert_eq!(
4232 normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4233 normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4234 );
4235 }
4236
4237 #[test]
4238 fn normalize_url_default_https_port() {
4239 assert_eq!(
4240 normalize_url_for_dedup("https://example.com:443/config.json"),
4241 "https://example.com/config.json"
4242 );
4243 assert_eq!(
4244 normalize_url_for_dedup("https://example.com:8443/config.json"),
4245 "https://example.com:8443/config.json"
4246 );
4247 }
4248
4249 #[test]
4250 fn normalize_url_only_strips_the_scheme_default_port() {
4251 assert_eq!(
4252 normalize_url_for_dedup("http://example.com:80/config.json"),
4253 "http://example.com/config.json"
4254 );
4255 assert_eq!(
4256 normalize_url_for_dedup("http://example.com:443/config.json"),
4257 "http://example.com:443/config.json"
4258 );
4259 assert_eq!(
4260 normalize_url_for_dedup("https://example.com:80/config.json"),
4261 "https://example.com:80/config.json"
4262 );
4263 }
4264
4265 #[test]
4266 fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4267 assert_eq!(
4268 normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4269 "https://example.com?profile=one"
4270 );
4271 assert_eq!(
4272 normalize_url_for_dedup("https://example.com:080/config.json"),
4273 "https://example.com:80/config.json"
4274 );
4275 }
4276
4277 #[test]
4278 fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4279 assert_eq!(
4280 normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4281 "https://[2001:db8::1]/config.json"
4282 );
4283 }
4284
4285 #[test]
4286 fn extends_http_rejected() {
4287 let dir = test_dir("http-rejected");
4288 std::fs::write(
4289 dir.path().join(".fallowrc.json"),
4290 r#"{"extends": "http://example.com/config.json"}"#,
4291 )
4292 .unwrap();
4293
4294 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4295 assert!(result.is_err());
4296 let err_msg = format!("{}", result.unwrap_err());
4297 assert!(
4298 err_msg.contains("https://"),
4299 "Expected https hint in error, got: {err_msg}"
4300 );
4301 assert!(
4302 err_msg.contains("http://"),
4303 "Expected http:// mention in error, got: {err_msg}"
4304 );
4305 }
4306
4307 #[test]
4308 fn extends_url_circular_detection() {
4309 let mut visited = FxHashSet::default();
4310 let url = "https://example.com/config.json";
4311 let normalized = normalize_url_for_dedup(url);
4312 visited.insert(normalized.clone());
4313
4314 assert!(
4315 !visited.insert(normalized),
4316 "Same URL should be detected as duplicate"
4317 );
4318 }
4319
4320 #[test]
4321 fn extends_url_circular_case_insensitive() {
4322 let mut visited = FxHashSet::default();
4323 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4324
4325 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4326 assert!(
4327 !visited.insert(normalized),
4328 "Case-different URLs should normalize to the same key"
4329 );
4330 }
4331
4332 #[test]
4333 fn extract_extends_array() {
4334 let mut value = serde_json::json!({
4335 "extends": ["a.json", "b.json"],
4336 "entry": ["src/index.ts"]
4337 });
4338 let extends = extract_extends(&mut value);
4339 assert_eq!(extends, vec!["a.json", "b.json"]);
4340 assert!(value.get("extends").is_none());
4341 assert!(value.get("entry").is_some());
4342 }
4343
4344 #[test]
4345 fn extract_extends_string_sugar() {
4346 let mut value = serde_json::json!({
4347 "extends": "base.json",
4348 "entry": ["src/index.ts"]
4349 });
4350 let extends = extract_extends(&mut value);
4351 assert_eq!(extends, vec!["base.json"]);
4352 }
4353
4354 #[test]
4355 fn extract_extends_none() {
4356 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4357 let extends = extract_extends(&mut value);
4358 assert!(extends.is_empty());
4359 }
4360
4361 #[test]
4362 fn url_timeout_default() {
4363 let timeout = url_timeout();
4364 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4365 }
4366
4367 #[test]
4368 fn extends_url_mixed_with_file_and_npm() {
4369 let dir = test_dir("url-mixed");
4370 std::fs::write(
4371 dir.path().join("local.json"),
4372 r#"{"rules": {"unused-files": "warn"}}"#,
4373 )
4374 .unwrap();
4375 std::fs::write(
4376 dir.path().join(".fallowrc.json"),
4377 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4378 )
4379 .unwrap();
4380
4381 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4382 assert!(result.is_err());
4383 let err_msg = format!("{}", result.unwrap_err());
4384 assert!(
4385 err_msg.contains("unreachable.invalid"),
4386 "Expected URL in error message, got: {err_msg}"
4387 );
4388 }
4389
4390 #[test]
4391 fn extends_https_url_default_denial_has_opt_in_hint() {
4392 let dir = test_dir("url-unreachable");
4393 std::fs::write(
4394 dir.path().join(".fallowrc.json"),
4395 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4396 )
4397 .unwrap();
4398
4399 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4400 assert!(result.is_err());
4401 let err_msg = format!("{}", result.unwrap_err());
4402 assert!(
4403 err_msg.contains("unreachable.invalid"),
4404 "Expected URL in error, got: {err_msg}"
4405 );
4406 assert!(
4407 err_msg.contains("--allow-remote-extends"),
4408 "Expected remediation hint, got: {err_msg}"
4409 );
4410 }
4411
4412 #[test]
4413 fn collect_unknown_rule_keys_flags_top_level_typo() {
4414 let merged = serde_json::json!({
4415 "rules": {
4416 "unsued-files": "warn",
4417 "unused-exports": "off"
4418 }
4419 });
4420 let findings = collect_unknown_rule_keys(&merged);
4421 assert_eq!(findings.len(), 1);
4422 assert_eq!(findings[0].context, "rules");
4423 assert_eq!(findings[0].key, "unsued-files");
4424 assert_eq!(findings[0].suggestion, Some("unused-files"));
4425 }
4426
4427 #[test]
4428 fn collect_unknown_rule_keys_flags_overrides_typo() {
4429 let merged = serde_json::json!({
4430 "overrides": [
4431 {
4432 "files": ["src/**/*.ts"],
4433 "rules": {
4434 "unsued-files": "warn"
4435 }
4436 },
4437 {
4438 "files": ["tests/**/*.ts"],
4439 "rules": {
4440 "circular-dependnecy": "off"
4441 }
4442 }
4443 ]
4444 });
4445 let findings = collect_unknown_rule_keys(&merged);
4446 assert_eq!(findings.len(), 2);
4447 assert_eq!(findings[0].context, "overrides[0].rules");
4448 assert_eq!(findings[1].context, "overrides[1].rules");
4449 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4450 }
4451
4452 #[test]
4453 fn collect_unknown_rule_keys_empty_for_valid_config() {
4454 let merged = serde_json::json!({
4455 "rules": {
4456 "unused-files": "warn",
4457 "unused-file": "off",
4458 "circular-dependency": "off",
4459 "boundary-violations": "warn"
4460 },
4461 "overrides": [
4462 {
4463 "files": ["src/**"],
4464 "rules": {
4465 "unused-exports": "warn"
4466 }
4467 }
4468 ]
4469 });
4470 let findings = collect_unknown_rule_keys(&merged);
4471 assert!(
4472 findings.is_empty(),
4473 "valid rule names and aliases must not be flagged: {findings:?}"
4474 );
4475 }
4476
4477 #[test]
4478 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
4479 let merged = serde_json::json!({
4480 "entry": ["src/main.ts"]
4481 });
4482 let findings = collect_unknown_rule_keys(&merged);
4483 assert!(findings.is_empty());
4484 }
4485
4486 #[test]
4487 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
4488 let dir = test_dir("wiring");
4489 let path = dir.path().join(".fallowrc.json");
4490 let typo = format!(
4491 "wiring-probe-{}-{}",
4492 std::process::id(),
4493 std::time::SystemTime::now()
4494 .duration_since(std::time::UNIX_EPOCH)
4495 .map_or(0, |d| d.as_nanos())
4496 );
4497 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
4498
4499 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
4500
4501 assert!(
4502 config_res.is_ok(),
4503 "load should succeed in phase 1: {:?}",
4504 config_res.err()
4505 );
4506 assert_eq!(
4507 captured.len(),
4508 1,
4509 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
4510 );
4511 assert_eq!(captured[0].key, typo);
4512 assert_eq!(captured[0].context, "rules");
4513 }
4514
4515 #[test]
4516 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
4517 let dir = test_dir("misspelled-rule");
4518 std::fs::write(
4519 dir.path().join(".fallowrc.json"),
4520 r#"{"rules": {"unsued-files": "warn"}}"#,
4521 )
4522 .unwrap();
4523
4524 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4525 .expect("load should succeed in phase 1");
4526
4527 assert_eq!(config.rules.unused_files, Severity::Error);
4528 }
4529
4530 #[test]
4531 fn validate_resolved_boundaries_passes_on_valid_config() {
4532 let dir = test_dir("boundaries-valid");
4533 let config = FallowConfig {
4534 boundaries: crate::BoundaryConfig {
4535 coverage: crate::BoundaryCoverageConfig::default(),
4536 calls: crate::BoundaryCallsConfig::default(),
4537 preset: None,
4538 zones: vec![
4539 crate::BoundaryZone {
4540 name: "ui".to_string(),
4541 patterns: vec!["src/components/**".to_string()],
4542 auto_discover: vec![],
4543 root: None,
4544 },
4545 crate::BoundaryZone {
4546 name: "db".to_string(),
4547 patterns: vec!["src/db/**".to_string()],
4548 auto_discover: vec![],
4549 root: None,
4550 },
4551 ],
4552 rules: vec![crate::BoundaryRule {
4553 from: "ui".to_string(),
4554 allow: vec!["db".to_string()],
4555 allow_type_only: vec![],
4556 }],
4557 },
4558 ..FallowConfig::default()
4559 };
4560 config
4561 .validate_resolved_boundaries(dir.path())
4562 .expect("valid config should pass");
4563 }
4564
4565 #[test]
4566 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
4567 let dir = test_dir("boundaries-unknown-zones");
4568 let config = FallowConfig {
4569 boundaries: crate::BoundaryConfig {
4570 coverage: crate::BoundaryCoverageConfig::default(),
4571 calls: crate::BoundaryCallsConfig::default(),
4572 preset: None,
4573 zones: vec![crate::BoundaryZone {
4574 name: "ui".to_string(),
4575 patterns: vec!["src/ui/**".to_string()],
4576 auto_discover: vec![],
4577 root: None,
4578 }],
4579 rules: vec![
4580 crate::BoundaryRule {
4581 from: "typo-from".to_string(),
4582 allow: vec!["typo-allow".to_string()],
4583 allow_type_only: vec!["typo-type-only".to_string()],
4584 },
4585 crate::BoundaryRule {
4586 from: "ui".to_string(),
4587 allow: vec!["another-typo".to_string()],
4588 allow_type_only: vec![],
4589 },
4590 ],
4591 },
4592 ..FallowConfig::default()
4593 };
4594
4595 let errors = config
4596 .validate_resolved_boundaries(dir.path())
4597 .expect_err("invalid zone refs should fail");
4598
4599 assert_eq!(errors.len(), 4, "got: {errors:?}");
4600
4601 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4602 assert!(
4603 rendered
4604 .iter()
4605 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
4606 );
4607 assert!(
4608 rendered
4609 .iter()
4610 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
4611 );
4612 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
4613 && m.contains("rules[0]")
4614 && m.contains("allowTypeOnly")));
4615 assert!(
4616 rendered.iter().any(|m| m.contains("another-typo")
4617 && m.contains("rules[1]")
4618 && m.contains("allow"))
4619 );
4620 }
4621
4622 #[test]
4623 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
4624 let dir = test_dir("boundaries-redundant-prefix");
4625 let config = FallowConfig {
4626 boundaries: crate::BoundaryConfig {
4627 coverage: crate::BoundaryCoverageConfig::default(),
4628 calls: crate::BoundaryCallsConfig::default(),
4629 preset: None,
4630 zones: vec![crate::BoundaryZone {
4631 name: "ui".to_string(),
4632 patterns: vec!["packages/app/src/**".to_string()],
4633 auto_discover: vec![],
4634 root: Some("packages/app/".to_string()),
4635 }],
4636 rules: vec![],
4637 },
4638 ..FallowConfig::default()
4639 };
4640
4641 let errors = config
4642 .validate_resolved_boundaries(dir.path())
4643 .expect_err("redundant root prefix should fail");
4644 assert_eq!(errors.len(), 1, "got: {errors:?}");
4645 let rendered = errors[0].to_string();
4646 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
4647 assert!(rendered.contains("zone 'ui'"));
4648 }
4649
4650 #[test]
4651 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
4652 let dir = test_dir("boundaries-mixed-errors");
4653 let config = FallowConfig {
4654 boundaries: crate::BoundaryConfig {
4655 coverage: crate::BoundaryCoverageConfig::default(),
4656 calls: crate::BoundaryCallsConfig::default(),
4657 preset: None,
4658 zones: vec![crate::BoundaryZone {
4659 name: "ui".to_string(),
4660 patterns: vec!["packages/app/src/**".to_string()],
4661 auto_discover: vec![],
4662 root: Some("packages/app/".to_string()),
4663 }],
4664 rules: vec![crate::BoundaryRule {
4665 from: "ui".to_string(),
4666 allow: vec!["typo-zone".to_string()],
4667 allow_type_only: vec![],
4668 }],
4669 },
4670 ..FallowConfig::default()
4671 };
4672 let errors = config
4673 .validate_resolved_boundaries(dir.path())
4674 .expect_err("mixed errors should fail");
4675 assert_eq!(errors.len(), 2, "got: {errors:?}");
4676 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4677 assert!(
4678 rendered
4679 .iter()
4680 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
4681 );
4682 assert!(
4683 rendered
4684 .iter()
4685 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
4686 );
4687 }
4688
4689 #[test]
4690 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
4691 let dir = test_dir("boundaries-bulletproof");
4692 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4693 let config = FallowConfig {
4694 boundaries: crate::BoundaryConfig {
4695 coverage: crate::BoundaryCoverageConfig::default(),
4696 calls: crate::BoundaryCallsConfig::default(),
4697 preset: Some(crate::BoundaryPreset::Bulletproof),
4698 zones: vec![],
4699 rules: vec![],
4700 },
4701 ..FallowConfig::default()
4702 };
4703 config
4704 .validate_resolved_boundaries(dir.path())
4705 .expect("Bulletproof with discoverable features should pass");
4706 }
4707
4708 #[test]
4713 #[cfg_attr(miri, ignore)]
4714 fn parse_config_to_value_strips_utf8_bom() {
4715 let dir = test_dir("parse-bom");
4716 let path = dir.path().join("fallow.toml");
4717 let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4719 std::fs::write(&path, content_with_bom).unwrap();
4720
4721 let value = parse_config_to_value(&path).unwrap();
4722 assert!(
4723 value.get("entry").is_some(),
4724 "BOM should be stripped before TOML parsing"
4725 );
4726 }
4727
4728 #[test]
4729 #[cfg_attr(miri, ignore)]
4730 fn parse_config_to_value_toml_parse_error() {
4731 let dir = test_dir("parse-toml-error");
4732 let path = dir.path().join("fallow.toml");
4733 std::fs::write(&path, "entry = [unquoted\n").unwrap();
4734
4735 let result = parse_config_to_value(&path);
4736 assert!(result.is_err());
4737 let err = result.unwrap_err().to_string();
4738 assert!(
4739 err.contains("Failed to parse config file"),
4740 "error should mention parse failure: {err}"
4741 );
4742 }
4743
4744 #[test]
4745 #[cfg_attr(miri, ignore)]
4746 fn parse_config_to_value_json_parse_error() {
4747 let dir = test_dir("parse-json-error");
4748 let path = dir.path().join(".fallowrc.json");
4749 std::fs::write(&path, "{ this is not json }").unwrap();
4750
4751 let result = parse_config_to_value(&path);
4752 assert!(result.is_err());
4753 let err = result.unwrap_err().to_string();
4754 assert!(
4755 err.contains("Failed to parse config file"),
4756 "error should mention parse failure: {err}"
4757 );
4758 }
4759
4760 #[test]
4761 #[cfg_attr(miri, ignore)]
4762 fn parse_config_to_value_missing_file_error() {
4763 let dir = test_dir("parse-missing");
4764 let path = dir.path().join("nonexistent.toml");
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 read config file"),
4771 "error should mention read failure: {err}"
4772 );
4773 }
4774
4775 #[test]
4780 #[cfg_attr(miri, ignore)]
4781 fn find_and_load_stops_at_svn_dir() {
4782 let dir = test_dir("find-svn-stop");
4783 let sub = dir.path().join("sub");
4784 std::fs::create_dir(&sub).unwrap();
4785 std::fs::create_dir(dir.path().join(".svn")).unwrap();
4786
4787 let result = FallowConfig::find_and_load(&sub).unwrap();
4788 assert!(result.is_none(), "svn boundary should stop config walk");
4789 }
4790
4791 #[test]
4797 #[cfg_attr(miri, ignore)]
4798 fn extends_npm_single_dot_package_name_rejected() {
4799 let dir = test_dir("npm-dot-name");
4800 std::fs::write(
4801 dir.path().join(".fallowrc.json"),
4802 r#"{"extends": "npm:./relative"}"#,
4803 )
4804 .unwrap();
4805
4806 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4807 assert!(result.is_err());
4808 let err = result.unwrap_err().to_string();
4809 assert!(
4810 err.contains("path traversal"),
4811 "single-dot component should be rejected as path traversal: {err}"
4812 );
4813 }
4814
4815 #[test]
4821 #[cfg_attr(miri, ignore)]
4822 fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4823 let dir = test_dir("npm-main-missing");
4824 let pkg_dir = dir.path().join("node_modules/my-config");
4825 std::fs::create_dir_all(&pkg_dir).unwrap();
4826 std::fs::write(
4828 pkg_dir.join("package.json"),
4829 r#"{"name": "my-config", "main": "./missing.json"}"#,
4830 )
4831 .unwrap();
4832 std::fs::write(
4834 pkg_dir.join(".fallowrc.json"),
4835 r#"{"rules": {"unused-files": "warn"}}"#,
4836 )
4837 .unwrap();
4838
4839 std::fs::write(
4840 dir.path().join(".fallowrc.json"),
4841 r#"{"extends": "npm:my-config"}"#,
4842 )
4843 .unwrap();
4844
4845 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4846 assert_eq!(config.rules.unused_files, Severity::Warn);
4847 }
4848
4849 #[test]
4855 #[cfg_attr(miri, ignore)]
4856 fn extends_npm_exports_nonexistent_falls_through_to_main() {
4857 let dir = test_dir("npm-exports-missing-file");
4858 let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4859 std::fs::create_dir_all(&pkg_dir).unwrap();
4860 std::fs::write(
4862 pkg_dir.join("package.json"),
4863 r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4864 )
4865 .unwrap();
4866 std::fs::write(
4867 pkg_dir.join("real.json"),
4868 r#"{"rules": {"unused-types": "off"}}"#,
4869 )
4870 .unwrap();
4871
4872 std::fs::write(
4873 dir.path().join(".fallowrc.json"),
4874 r#"{"extends": "npm:cfg-pkg"}"#,
4875 )
4876 .unwrap();
4877
4878 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4879 assert_eq!(config.rules.unused_types, Severity::Off);
4880 }
4881
4882 #[test]
4887 fn normalize_url_no_scheme_returns_raw() {
4888 assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
4890 assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
4891 }
4892
4893 #[test]
4898 fn normalize_url_fragment_only_stripped() {
4899 assert_eq!(
4901 normalize_url_for_dedup("https://example.com/file.json#anchor"),
4902 "https://example.com/file.json"
4903 );
4904 }
4905
4906 #[test]
4914 fn url_timeout_uses_env_var_when_set() {
4915 assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
4916 }
4917
4918 #[test]
4919 fn url_timeout_zero_falls_back_to_default() {
4920 assert_eq!(
4921 url_timeout_from(Some("0")),
4922 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4923 "zero should fall back to the hardcoded default"
4924 );
4925 }
4926
4927 #[test]
4928 fn url_timeout_non_numeric_falls_back_to_default() {
4929 assert_eq!(
4930 url_timeout_from(Some("not-a-number")),
4931 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4932 "non-numeric value should fall back to the hardcoded default"
4933 );
4934 }
4935
4936 #[test]
4937 fn url_timeout_absent_uses_default() {
4938 assert_eq!(
4939 url_timeout_from(None),
4940 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
4941 );
4942 }
4943
4944 #[test]
4949 fn resolve_url_extends_depth_limit_error() {
4950 let mut visited = FxHashSet::default();
4951 let result = resolve_url_extends(
4952 "https://example.invalid/config.json",
4953 &mut visited,
4954 MAX_EXTENDS_DEPTH, );
4956 assert!(result.is_err());
4957 let err = result.unwrap_err().to_string();
4958 assert!(
4959 err.contains("too deep"),
4960 "error should mention depth limit: {err}"
4961 );
4962 }
4963
4964 #[test]
4969 #[cfg_attr(miri, ignore)]
4970 fn resolve_extends_file_depth_limit_error() {
4971 let dir = test_dir("extends-file-depth");
4972 let path = dir.path().join(".fallowrc.json");
4973 std::fs::write(&path, r#"{"entry": []}"#).unwrap();
4974
4975 let mut visited = FxHashSet::default();
4976 let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
4977 assert!(result.is_err());
4978 let err = result.unwrap_err().to_string();
4979 assert!(
4980 err.contains("too deep"),
4981 "error should mention depth limit: {err}"
4982 );
4983 }
4984
4985 #[test]
4990 #[cfg_attr(miri, ignore)]
4991 fn extends_http_url_in_file_extends_rejected() {
4992 let dir = test_dir("file-extends-http");
4993 std::fs::write(
4994 dir.path().join(".fallowrc.json"),
4995 r#"{"extends": ["http://example.com/config.json"]}"#,
4996 )
4997 .unwrap();
4998
4999 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5000 assert!(result.is_err());
5001 let err = result.unwrap_err().to_string();
5002 assert!(
5003 err.contains("https://"),
5004 "error should suggest https: {err}"
5005 );
5006 }
5007
5008 #[test]
5013 #[cfg_attr(miri, ignore)]
5014 fn sealed_config_dir_returns_some_when_sealed() {
5015 let dir = test_dir("sealed-dir");
5016 let result = sealed_config_dir(dir.path(), true);
5017 assert!(result.is_ok());
5018 assert!(
5019 result.unwrap().is_some(),
5020 "sealed=true must return Some(canonicalized path)"
5021 );
5022 }
5023
5024 #[test]
5025 fn sealed_config_dir_returns_none_when_not_sealed() {
5026 let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5027 assert!(result.is_ok());
5028 assert!(result.unwrap().is_none(), "sealed=false must return None");
5029 }
5030
5031 #[test]
5037 fn collect_unknown_rule_keys_override_without_rules_key() {
5038 let merged = serde_json::json!({
5039 "overrides": [
5040 {
5041 "files": ["src/**/*.ts"]
5042 },
5044 {
5045 "files": ["tests/**"],
5046 "rules": {
5047 "unsued-exports": "off"
5048 }
5049 }
5050 ]
5051 });
5052 let findings = collect_unknown_rule_keys(&merged);
5053 assert_eq!(
5054 findings.len(),
5055 1,
5056 "only the entry with rules should produce a finding"
5057 );
5058 assert_eq!(findings[0].context, "overrides[1].rules");
5059 }
5060
5061 #[test]
5066 #[cfg_attr(miri, ignore)]
5067 fn load_fails_on_deserialization_error() {
5068 let dir = test_dir("deser-error");
5069 let path = dir.path().join(".fallowrc.json");
5070 std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5072
5073 let result = FallowConfig::load(&path);
5074 assert!(result.is_err());
5075 let err = result.unwrap_err().to_string();
5076 assert!(
5077 err.contains("Failed to deserialize"),
5078 "error should mention deserialization: {err}"
5079 );
5080 }
5081
5082 #[test]
5088 #[cfg_attr(miri, ignore)]
5089 fn load_rejects_threshold_override_with_empty_files() {
5090 let dir = test_dir("threshold-empty-files");
5091 let path = dir.path().join(".fallowrc.json");
5092 std::fs::write(
5093 &path,
5094 r#"{
5095 "health": {
5096 "thresholdOverrides": [
5097 {"files": [], "maxCyclomatic": 30}
5098 ]
5099 }
5100 }"#,
5101 )
5102 .unwrap();
5103
5104 let result = FallowConfig::load(&path);
5105 assert!(result.is_err());
5106 let err = result.unwrap_err().to_string();
5107 assert!(
5108 err.contains("thresholdOverrides"),
5109 "error should mention thresholdOverrides: {err}"
5110 );
5111 assert!(
5112 err.contains("files"),
5113 "error should name the files field: {err}"
5114 );
5115 }
5116
5117 #[test]
5118 #[cfg_attr(miri, ignore)]
5119 fn load_rejects_threshold_override_with_no_threshold_set() {
5120 let dir = test_dir("threshold-no-threshold");
5121 let path = dir.path().join(".fallowrc.json");
5122 std::fs::write(
5123 &path,
5124 r#"{
5125 "health": {
5126 "thresholdOverrides": [
5127 {"files": ["src/legacy.ts"]}
5128 ]
5129 }
5130 }"#,
5131 )
5132 .unwrap();
5133
5134 let result = FallowConfig::load(&path);
5135 assert!(result.is_err());
5136 let err = result.unwrap_err().to_string();
5137 assert!(
5138 err.contains("maxCyclomatic")
5139 || err.contains("maxCognitive")
5140 || err.contains("maxCrap"),
5141 "error should name at least one threshold field: {err}"
5142 );
5143 }
5144
5145 #[test]
5151 #[cfg_attr(miri, ignore)]
5152 fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5153 let dir = test_dir("invalid-catalog-consumer-glob");
5154 let path = dir.path().join(".fallowrc.json");
5155 std::fs::write(
5156 &path,
5157 r#"{
5158 "ignoreCatalogReferences": [
5159 {"package": "react", "consumer": "[invalid-glob"}
5160 ]
5161 }"#,
5162 )
5163 .unwrap();
5164
5165 let result = FallowConfig::load(&path);
5166 assert!(result.is_err());
5167 let err = result.unwrap_err().to_string();
5168 assert!(
5169 err.contains("ignoreCatalogReferences"),
5170 "error should mention the field: {err}"
5171 );
5172 }
5173
5174 #[test]
5175 #[cfg_attr(miri, ignore)]
5176 fn load_accepts_ignore_catalog_references_without_consumer() {
5177 let dir = test_dir("catalog-ref-no-consumer");
5178 let path = dir.path().join(".fallowrc.json");
5179 std::fs::write(
5180 &path,
5181 r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5182 )
5183 .unwrap();
5184
5185 let config = FallowConfig::load(&path).unwrap();
5186 assert_eq!(config.ignore_catalog_references.len(), 1);
5187 assert!(config.ignore_catalog_references[0].consumer.is_none());
5188 }
5189
5190 #[test]
5191 #[cfg_attr(miri, ignore)]
5192 fn load_accepts_unused_component_props_ignore_pattern() {
5193 let dir = test_dir("unused-component-props-ignore-pattern");
5194 let path = dir.path().join(".fallowrc.json");
5195 std::fs::write(
5196 &path,
5197 r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5198 )
5199 .unwrap();
5200
5201 let config = FallowConfig::load(&path).unwrap();
5202 assert_eq!(
5203 config.unused_component_props.ignore_pattern.as_deref(),
5204 Some("^_")
5205 );
5206 }
5207
5208 #[test]
5209 #[cfg_attr(miri, ignore)]
5210 fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5211 let dir = test_dir("unused-component-props-bad-regex");
5212 let path = dir.path().join(".fallowrc.json");
5213 std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5215
5216 let result = FallowConfig::load(&path);
5217 assert!(result.is_err());
5218 let err = result.unwrap_err().to_string();
5219 assert!(
5220 err.contains("unusedComponentProps.ignorePattern"),
5221 "error should mention the field: {err}"
5222 );
5223 }
5224
5225 #[test]
5226 #[cfg_attr(miri, ignore)]
5227 fn load_rejects_unknown_unused_component_props_field() {
5228 let dir = test_dir("unused-component-props-unknown-field");
5229 let path = dir.path().join(".fallowrc.json");
5230 std::fs::write(
5231 &path,
5232 r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5233 )
5234 .unwrap();
5235
5236 assert!(FallowConfig::load(&path).is_err());
5238 }
5239
5240 #[test]
5247 #[cfg_attr(miri, ignore)]
5248 fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
5249 let dir = test_dir("boundaries-preset-no-tsconfig");
5252 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5253 let config = FallowConfig {
5254 boundaries: crate::BoundaryConfig {
5255 coverage: crate::BoundaryCoverageConfig::default(),
5256 calls: crate::BoundaryCallsConfig::default(),
5257 preset: Some(crate::BoundaryPreset::Bulletproof),
5258 zones: vec![],
5259 rules: vec![],
5260 },
5261 ..FallowConfig::default()
5262 };
5263 let _ = config.validate_resolved_boundaries(dir.path());
5265 }
5266
5267 #[test]
5273 fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5274 use crate::ExternalPluginDef;
5275 use crate::external_plugin::EntryPointRole;
5276 let config = FallowConfig {
5277 framework: vec![ExternalPluginDef {
5278 schema: None,
5279 name: "test-plugin".to_owned(),
5280 detection: None,
5281 enablers: vec![],
5282 entry_points: vec!["[invalid-glob".to_owned()],
5283 entry_point_role: EntryPointRole::Support,
5284 manifest_entries: vec![],
5285 config_patterns: vec![],
5286 always_used: vec![],
5287 tooling_dependencies: vec![],
5288 used_exports: vec![],
5289 used_class_members: vec![],
5290 }],
5291 ..FallowConfig::default()
5292 };
5293
5294 let result = config.validate_user_globs();
5295 assert!(
5296 result.is_err(),
5297 "invalid entry_points glob should fail validation"
5298 );
5299 let errors = result.unwrap_err();
5300 assert!(!errors.is_empty());
5301 }
5302
5303 #[test]
5308 #[cfg_attr(miri, ignore)]
5309 fn shadowed_config_names_empty_when_last_config_wins() {
5310 let dir = test_dir("shadow-last");
5311 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5312 assert!(shadowed_config_names(dir.path(), 3).is_empty());
5314 }
5315
5316 #[test]
5322 fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5323 let ((), captured) = capture_coexisting_config_warnings(|| {
5324 warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5325 });
5326 assert!(
5327 captured.is_empty(),
5328 "empty shadowed list must produce no warning"
5329 );
5330 }
5331
5332 #[test]
5337 fn extract_extends_array_filters_non_strings() {
5338 let mut value = serde_json::json!({
5339 "extends": ["a.json", 42, null, "b.json", true]
5340 });
5341 let extends = extract_extends(&mut value);
5342 assert_eq!(extends, vec!["a.json", "b.json"]);
5343 }
5344
5345 #[test]
5350 #[cfg_attr(miri, ignore)]
5351 fn config_resource_identity_cannot_collide_across_kinds() {
5352 let dir = test_dir("visit-circular");
5353 let path = dir.path().join("config.json");
5354 std::fs::write(&path, "{}").unwrap();
5355
5356 let canonical = dunce::canonicalize(path).unwrap();
5357 let local = ConfigResourceId::Local(canonical.clone());
5358 let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
5359 assert_ne!(local, remote);
5360 }
5361
5362 #[test]
5367 #[cfg_attr(miri, ignore)]
5368 fn find_config_path_stops_at_svn_dir() {
5369 let dir = test_dir("find-path-svn");
5370 let sub = dir.path().join("sub");
5371 std::fs::create_dir(&sub).unwrap();
5372 std::fs::create_dir(dir.path().join(".svn")).unwrap();
5373
5374 let path = FallowConfig::find_config_path(&sub);
5375 assert!(path.is_none(), "svn root should stop config search");
5376 }
5377
5378 #[test]
5383 fn deep_merge_array_over_object_replaces() {
5384 let mut base = serde_json::json!({"key": "value"});
5385 deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
5386 assert_eq!(base, serde_json::json!(["a", "b"]));
5387 }
5388
5389 #[test]
5394 #[cfg_attr(miri, ignore)]
5395 fn find_and_load_returns_error_for_invalid_glob_in_config() {
5396 let dir = test_dir("find-invalid-glob");
5397 std::fs::create_dir(dir.path().join(".git")).unwrap();
5398 std::fs::write(
5399 dir.path().join(".fallowrc.json"),
5400 r#"{"entry": ["[invalid-glob"]}"#,
5401 )
5402 .unwrap();
5403
5404 let result = FallowConfig::find_and_load(dir.path());
5405 assert!(
5406 result.is_err(),
5407 "invalid glob should surface as an error from find_and_load"
5408 );
5409 }
5410
5411 #[test]
5417 fn resolve_package_exports_dot_key_array_returns_none() {
5418 let pkg = serde_json::json!({
5420 "exports": {".": ["array-value"]}
5421 });
5422 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5423 assert!(result.is_none(), "array dot-export should return None");
5424 }
5425
5426 #[test]
5427 fn resolve_package_exports_exports_is_array_returns_none() {
5428 let pkg = serde_json::json!({
5430 "exports": ["./index.js"]
5431 });
5432 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5433 assert!(result.is_none(), "array-form exports should return None");
5434 }
5435
5436 #[test]
5437 fn resolve_package_exports_object_no_dot_key_returns_none() {
5438 let pkg = serde_json::json!({
5440 "exports": {"./sub": "./sub.js"}
5441 });
5442 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5443 assert!(result.is_none(), "no dot key should return None");
5444 }
5445
5446 #[test]
5447 fn resolve_package_exports_conditions_without_known_key_returns_none() {
5448 let pkg = serde_json::json!({
5450 "exports": {".": {"browser": "./browser.js"}}
5451 });
5452 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5453 assert!(result.is_none(), "unknown condition key should return None");
5454 }
5455
5456 #[test]
5461 #[cfg_attr(miri, ignore)]
5462 fn extends_npm_exports_import_condition() {
5463 let dir = test_dir("npm-import-cond");
5464 let pkg_dir = dir.path().join("node_modules/import-config");
5465 std::fs::create_dir_all(&pkg_dir).unwrap();
5466 std::fs::write(
5467 pkg_dir.join("package.json"),
5468 r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
5469 )
5470 .unwrap();
5471 std::fs::write(
5472 pkg_dir.join("esm.json"),
5473 r#"{"rules": {"unused-types": "warn"}}"#,
5474 )
5475 .unwrap();
5476
5477 std::fs::write(
5478 dir.path().join(".fallowrc.json"),
5479 r#"{"extends": "npm:import-config"}"#,
5480 )
5481 .unwrap();
5482
5483 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5484 assert_eq!(config.rules.unused_types, Severity::Warn);
5485 }
5486
5487 #[test]
5492 #[cfg_attr(miri, ignore)]
5493 fn extends_npm_exports_require_condition() {
5494 let dir = test_dir("npm-require-cond");
5495 let pkg_dir = dir.path().join("node_modules/require-config");
5496 std::fs::create_dir_all(&pkg_dir).unwrap();
5497 std::fs::write(
5498 pkg_dir.join("package.json"),
5499 r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
5500 )
5501 .unwrap();
5502 std::fs::write(
5503 pkg_dir.join("cjs.json"),
5504 r#"{"rules": {"unused-class-members": "warn"}}"#,
5505 )
5506 .unwrap();
5507
5508 std::fs::write(
5509 dir.path().join(".fallowrc.json"),
5510 r#"{"extends": "npm:require-config"}"#,
5511 )
5512 .unwrap();
5513
5514 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5515 assert_eq!(config.rules.unused_class_members, Severity::Warn);
5516 }
5517}