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