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