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