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