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