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