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