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