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