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