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
934 Ok(config)
935 }
936
937 pub fn validate_user_globs(
967 &self,
968 ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
969 let mut errors = Vec::new();
970
971 self.validate_top_level_globs(&mut errors);
972 self.validate_ignore_rule_globs(&mut errors);
973 self.validate_boundary_globs(&mut errors);
974
975 for plugin in &self.framework {
976 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
977 errors.append(&mut plugin_errors);
978 }
979 }
980
981 if errors.is_empty() {
982 Ok(())
983 } else {
984 Err(errors)
985 }
986 }
987
988 fn validate_top_level_globs(
991 &self,
992 errors: &mut Vec<super::glob_validation::GlobValidationError>,
993 ) {
994 use super::glob_validation::{validate_user_globs, validate_user_specifier_globs};
995
996 validate_user_globs(&self.entry, "entry", errors);
997 validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
998 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
999 validate_user_specifier_globs(
1000 &self.ignore_unresolved_imports,
1001 "ignoreUnresolvedImports",
1002 errors,
1003 );
1004 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1005 validate_user_globs(&self.health.ignore, "health.ignore", errors);
1006 for override_entry in &self.health.threshold_overrides {
1007 validate_user_globs(
1008 &override_entry.files,
1009 "health.thresholdOverrides[].files",
1010 errors,
1011 );
1012 }
1013 for override_entry in &self.overrides {
1014 validate_user_globs(&override_entry.files, "overrides[].files", errors);
1015 }
1016 }
1017
1018 fn validate_ignore_rule_globs(
1020 &self,
1021 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1022 ) {
1023 use super::glob_validation::compile_user_glob;
1024
1025 for rule in &self.ignore_exports {
1026 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1027 errors.push(e);
1028 }
1029 }
1030
1031 for rule in &self.ignore_catalog_references {
1032 if let Some(consumer) = &rule.consumer
1033 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1034 {
1035 errors.push(e);
1036 }
1037 }
1038 }
1039
1040 fn validate_boundary_globs(
1043 &self,
1044 errors: &mut Vec<super::glob_validation::GlobValidationError>,
1045 ) {
1046 use super::glob_validation::{
1047 validate_user_globs, validate_user_path, validate_user_paths,
1048 };
1049
1050 for zone in &self.boundaries.zones {
1051 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1052 if let Some(root) = &zone.root
1053 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1054 {
1055 errors.push(e);
1056 }
1057 validate_user_paths(
1058 &zone.auto_discover,
1059 "boundaries.zones[].autoDiscover",
1060 errors,
1061 );
1062 }
1063 validate_user_globs(
1064 &self.boundaries.coverage.allow_unmatched,
1065 "boundaries.coverage.allowUnmatched",
1066 errors,
1067 );
1068 }
1069
1070 #[must_use]
1073 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1074 let mut dir = start;
1075 loop {
1076 for name in CONFIG_NAMES {
1077 let candidate = dir.join(name);
1078 if candidate.exists() {
1079 return Some(candidate);
1080 }
1081 }
1082 if is_repo_root(dir) {
1083 break;
1084 }
1085 dir = dir.parent()?;
1086 }
1087 None
1088 }
1089
1090 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1096 let mut dir = start;
1097 loop {
1098 for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1099 let candidate = dir.join(name);
1100 if candidate.exists() {
1101 warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1102 match Self::load(&candidate) {
1103 Ok(config) => return Ok(Some((config, candidate))),
1104 Err(e) => {
1105 return Err(format!("Failed to parse {}: {e}", candidate.display()));
1106 }
1107 }
1108 }
1109 }
1110 if is_repo_root(dir) {
1111 break;
1112 }
1113 dir = match dir.parent() {
1114 Some(parent) => parent,
1115 None => break,
1116 };
1117 }
1118 Ok(None)
1119 }
1120
1121 #[must_use]
1123 pub fn json_schema() -> serde_json::Value {
1124 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1125 }
1126
1127 pub fn validate_resolved_boundaries(
1156 &self,
1157 root: &Path,
1158 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1159 use super::boundaries::ZoneValidationError;
1160
1161 let mut boundaries = self.boundaries.clone();
1162 if boundaries.preset.is_some() {
1163 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1164 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1165 .unwrap_or_else(|| "src".to_owned());
1166 boundaries.expand(&source_root);
1167 }
1168 let _logical_groups = boundaries.expand_auto_discover(root);
1169
1170 let mut errors: Vec<ZoneValidationError> = boundaries
1171 .validate_zone_references()
1172 .into_iter()
1173 .map(ZoneValidationError::UnknownZoneReference)
1174 .collect();
1175 errors.extend(
1176 boundaries
1177 .validate_root_prefixes()
1178 .into_iter()
1179 .map(ZoneValidationError::RedundantRootPrefix),
1180 );
1181 errors.extend(
1182 boundaries
1183 .validate_call_rules()
1184 .into_iter()
1185 .map(ZoneValidationError::InvalidForbiddenCallee),
1186 );
1187
1188 if errors.is_empty() {
1189 Ok(())
1190 } else {
1191 Err(errors)
1192 }
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199 use crate::CacheConfig;
1200 use crate::PackageJson;
1201 use crate::config::format::OutputFormat;
1202 use crate::config::rules::Severity;
1203
1204 fn test_dir(_name: &str) -> tempfile::TempDir {
1206 tempfile::tempdir().expect("create temp dir")
1207 }
1208
1209 #[test]
1210 fn fallow_config_deserialize_minimal() {
1211 let toml_str = r#"
1212entry = ["src/main.ts"]
1213"#;
1214 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1215 assert_eq!(config.entry, vec!["src/main.ts"]);
1216 assert!(config.ignore_patterns.is_empty());
1217 }
1218
1219 #[test]
1220 fn fallow_config_deserialize_ignore_exports() {
1221 let toml_str = r#"
1222[[ignoreExports]]
1223file = "src/types/*.ts"
1224exports = ["*"]
1225
1226[[ignoreExports]]
1227file = "src/constants.ts"
1228exports = ["FOO", "BAR"]
1229"#;
1230 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1231 assert_eq!(config.ignore_exports.len(), 2);
1232 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1233 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1234 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1235 }
1236
1237 #[test]
1238 fn fallow_config_deserialize_ignore_dependencies() {
1239 let toml_str = r#"
1240ignoreDependencies = ["autoprefixer", "postcss"]
1241"#;
1242 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1243 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1244 }
1245
1246 #[test]
1247 fn fallow_config_deserialize_ignore_unresolved_imports() {
1248 let toml_str = r#"
1249ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1250"#;
1251 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1252 assert_eq!(
1253 config.ignore_unresolved_imports,
1254 vec!["@example/icons", "@example/icons/**", "../generated/**"]
1255 );
1256 }
1257
1258 #[test]
1259 fn fallow_config_resolve_default_ignores() {
1260 let config = FallowConfig::default();
1261 let resolved = config.resolve(
1262 PathBuf::from("/tmp/test"),
1263 OutputFormat::Human,
1264 4,
1265 true,
1266 true,
1267 None,
1268 );
1269
1270 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1271 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1272 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1273 assert!(resolved.ignore_patterns.is_match(".git/config"));
1274 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1275 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1276 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1277 }
1278
1279 #[test]
1280 fn fallow_config_resolve_custom_ignores() {
1281 let config = FallowConfig {
1282 entry: vec!["src/**/*.ts".to_string()],
1283 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1284 ..Default::default()
1285 };
1286 let resolved = config.resolve(
1287 PathBuf::from("/tmp/test"),
1288 OutputFormat::Json,
1289 4,
1290 false,
1291 true,
1292 None,
1293 );
1294
1295 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1296 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1297 assert!(matches!(resolved.output, OutputFormat::Json));
1298 assert!(!resolved.no_cache);
1299 }
1300
1301 #[test]
1302 fn fallow_config_resolve_cache_dir() {
1303 let config = FallowConfig::default();
1304 let resolved = config.resolve(
1305 PathBuf::from("/tmp/project"),
1306 OutputFormat::Human,
1307 4,
1308 true,
1309 true,
1310 None,
1311 );
1312 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1313 assert!(resolved.no_cache);
1314 }
1315
1316 #[test]
1317 fn package_json_entry_points_main() {
1318 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1319 let entries = pkg.entry_points();
1320 assert!(entries.contains(&"dist/index.js".to_string()));
1321 }
1322
1323 #[test]
1324 fn package_json_entry_points_module() {
1325 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1326 let entries = pkg.entry_points();
1327 assert!(entries.contains(&"dist/index.mjs".to_string()));
1328 }
1329
1330 #[test]
1331 fn package_json_entry_points_types() {
1332 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1333 let entries = pkg.entry_points();
1334 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1335 }
1336
1337 #[test]
1338 fn package_json_entry_points_bin_string() {
1339 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1340 let entries = pkg.entry_points();
1341 assert!(entries.contains(&"bin/cli.js".to_string()));
1342 }
1343
1344 #[test]
1345 fn package_json_entry_points_bin_object() {
1346 let pkg: PackageJson =
1347 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1348 .unwrap();
1349 let entries = pkg.entry_points();
1350 assert!(entries.contains(&"bin/cli.js".to_string()));
1351 assert!(entries.contains(&"bin/serve.js".to_string()));
1352 }
1353
1354 #[test]
1355 fn package_json_entry_points_exports_string() {
1356 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1357 let entries = pkg.entry_points();
1358 assert!(entries.contains(&"./dist/index.js".to_string()));
1359 }
1360
1361 #[test]
1362 fn package_json_entry_points_exports_object() {
1363 let pkg: PackageJson = serde_json::from_str(
1364 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1365 )
1366 .unwrap();
1367 let entries = pkg.entry_points();
1368 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1369 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1370 }
1371
1372 #[test]
1373 fn package_json_dependency_names() {
1374 let pkg: PackageJson = serde_json::from_str(
1375 r#"{
1376 "dependencies": {"react": "^18", "lodash": "^4"},
1377 "devDependencies": {"typescript": "^5"},
1378 "peerDependencies": {"react-dom": "^18"}
1379 }"#,
1380 )
1381 .unwrap();
1382
1383 let all = pkg.all_dependency_names();
1384 assert!(all.contains(&"react".to_string()));
1385 assert!(all.contains(&"lodash".to_string()));
1386 assert!(all.contains(&"typescript".to_string()));
1387 assert!(all.contains(&"react-dom".to_string()));
1388
1389 let prod = pkg.production_dependency_names();
1390 assert!(prod.contains(&"react".to_string()));
1391 assert!(!prod.contains(&"typescript".to_string()));
1392
1393 let dev = pkg.dev_dependency_names();
1394 assert!(dev.contains(&"typescript".to_string()));
1395 assert!(!dev.contains(&"react".to_string()));
1396 }
1397
1398 #[test]
1399 fn package_json_no_dependencies() {
1400 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1401 assert!(pkg.all_dependency_names().is_empty());
1402 assert!(pkg.production_dependency_names().is_empty());
1403 assert!(pkg.dev_dependency_names().is_empty());
1404 assert!(pkg.entry_points().is_empty());
1405 }
1406
1407 #[test]
1408 fn rules_deserialize_toml_kebab_case() {
1409 let toml_str = r#"
1410[rules]
1411unused-files = "error"
1412unused-exports = "warn"
1413unused-types = "off"
1414"#;
1415 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1416 assert_eq!(config.rules.unused_files, Severity::Error);
1417 assert_eq!(config.rules.unused_exports, Severity::Warn);
1418 assert_eq!(config.rules.unused_types, Severity::Off);
1419 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1420 }
1421
1422 #[test]
1423 fn config_without_rules_defaults_to_error() {
1424 let toml_str = r#"
1425entry = ["src/main.ts"]
1426"#;
1427 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1428 assert_eq!(config.rules.unused_files, Severity::Error);
1429 assert_eq!(config.rules.unused_exports, Severity::Error);
1430 }
1431
1432 #[test]
1433 fn fallow_config_denies_unknown_fields() {
1434 let toml_str = r"
1435unknown_field = true
1436";
1437 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1438 assert!(result.is_err());
1439 }
1440
1441 #[test]
1442 fn fallow_config_deserialize_json() {
1443 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1444 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1445 assert_eq!(config.entry, vec!["src/main.ts"]);
1446 }
1447
1448 #[test]
1449 fn fallow_config_deserialize_jsonc() {
1450 let jsonc_str = r#"{
1451 "entry": ["src/main.ts"],
1452 "rules": {
1453 "unused-files": "warn"
1454 }
1455 }"#;
1456 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1457 assert_eq!(config.entry, vec!["src/main.ts"]);
1458 assert_eq!(config.rules.unused_files, Severity::Warn);
1459 }
1460
1461 #[test]
1462 fn fallow_config_json_with_schema_field() {
1463 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1464 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1465 assert_eq!(config.entry, vec!["src/main.ts"]);
1466 }
1467
1468 #[test]
1469 fn fallow_config_json_schema_generation() {
1470 let schema = FallowConfig::json_schema();
1471 assert!(schema.is_object());
1472 let obj = schema.as_object().unwrap();
1473 assert!(obj.contains_key("properties"));
1474 }
1475
1476 #[test]
1477 fn config_format_detection() {
1478 assert!(matches!(
1479 ConfigFormat::from_path(Path::new("fallow.toml")),
1480 ConfigFormat::Toml
1481 ));
1482 assert!(matches!(
1483 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1484 ConfigFormat::Json
1485 ));
1486 assert!(matches!(
1487 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1488 ConfigFormat::Json
1489 ));
1490 assert!(matches!(
1491 ConfigFormat::from_path(Path::new(".fallow.toml")),
1492 ConfigFormat::Toml
1493 ));
1494 }
1495
1496 #[test]
1497 fn config_names_priority_order() {
1498 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1499 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1500 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1501 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1502 }
1503
1504 #[test]
1505 fn load_json_config_file() {
1506 let dir = test_dir("json-config");
1507 let config_path = dir.path().join(".fallowrc.json");
1508 std::fs::write(
1509 &config_path,
1510 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1511 )
1512 .unwrap();
1513
1514 let config = FallowConfig::load(&config_path).unwrap();
1515 assert_eq!(config.entry, vec!["src/index.ts"]);
1516 assert_eq!(config.rules.unused_exports, Severity::Warn);
1517 }
1518
1519 #[test]
1520 fn load_json_config_file_with_health_threshold_override() {
1521 let dir = test_dir("json-health-threshold-override");
1522 let config_path = dir.path().join(".fallowrc.json");
1523 std::fs::write(
1524 &config_path,
1525 r#"{
1526 "health": {
1527 "thresholdOverrides": [
1528 {
1529 "files": ["src/legacy.ts"],
1530 "functions": ["legacyFlow"],
1531 "maxCyclomatic": 30,
1532 "maxCognitive": 25,
1533 "maxCrap": 80.5,
1534 "reason": "legacy migration"
1535 }
1536 ]
1537 }
1538 }"#,
1539 )
1540 .unwrap();
1541
1542 let config = FallowConfig::load(&config_path).unwrap();
1543 let override_config = &config.health.threshold_overrides[0];
1544 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1545 assert_eq!(override_config.functions, vec!["legacyFlow"]);
1546 assert_eq!(override_config.max_cyclomatic, Some(30));
1547 assert_eq!(override_config.max_cognitive, Some(25));
1548 assert_eq!(override_config.max_crap, Some(80.5));
1549 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1550 }
1551
1552 #[test]
1553 fn load_jsonc_config_file() {
1554 let dir = test_dir("jsonc-config");
1555 let config_path = dir.path().join(".fallowrc.json");
1556 std::fs::write(
1557 &config_path,
1558 r#"{
1559 "entry": ["src/index.ts"],
1560 /* Block comment */
1561 "rules": {
1562 "unused-exports": "warn"
1563 }
1564 }"#,
1565 )
1566 .unwrap();
1567
1568 let config = FallowConfig::load(&config_path).unwrap();
1569 assert_eq!(config.entry, vec!["src/index.ts"]);
1570 assert_eq!(config.rules.unused_exports, Severity::Warn);
1571 }
1572
1573 #[test]
1574 fn load_jsonc_config_file_with_health_threshold_override() {
1575 let dir = test_dir("jsonc-health-threshold-override");
1576 let config_path = dir.path().join(".fallowrc.jsonc");
1577 std::fs::write(
1578 &config_path,
1579 r#"{
1580 "health": {
1581 // Empty functions means every function in matching files.
1582 "thresholdOverrides": [
1583 { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1584 ]
1585 }
1586 }"#,
1587 )
1588 .unwrap();
1589
1590 let config = FallowConfig::load(&config_path).unwrap();
1591 let override_config = &config.health.threshold_overrides[0];
1592 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1593 assert!(override_config.functions.is_empty());
1594 assert_eq!(override_config.max_cognitive, Some(25));
1595 }
1596
1597 #[test]
1598 fn load_fallowrc_jsonc_extension() {
1599 let dir = test_dir("jsonc-extension");
1600 let config_path = dir.path().join(".fallowrc.jsonc");
1601 std::fs::write(
1602 &config_path,
1603 r#"{
1604 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1605 "entry": ["src/index.ts"]
1606 }"#,
1607 )
1608 .unwrap();
1609
1610 let config = FallowConfig::load(&config_path).unwrap();
1611 assert_eq!(config.entry, vec!["src/index.ts"]);
1612 assert_eq!(
1613 config.ignore_dependencies,
1614 vec!["tailwindcss-react-aria-components"]
1615 );
1616 }
1617
1618 #[test]
1619 fn json_config_ignore_dependencies_camel_case() {
1620 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1621 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1622 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1623 }
1624
1625 #[test]
1626 fn json_config_ignore_unresolved_imports_camel_case() {
1627 let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1628 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1629 assert_eq!(
1630 config.ignore_unresolved_imports,
1631 vec!["@example/icons", "@example/icons/**"]
1632 );
1633 }
1634
1635 #[test]
1636 fn json_config_all_fields() {
1637 let json_str = r#"{
1638 "ignoreDependencies": ["lodash"],
1639 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1640 "rules": {
1641 "unused-files": "off",
1642 "unused-exports": "warn",
1643 "unused-dependencies": "error",
1644 "unused-dev-dependencies": "off",
1645 "unused-types": "warn",
1646 "unused-enum-members": "error",
1647 "unused-class-members": "off",
1648 "unresolved-imports": "warn",
1649 "unlisted-dependencies": "error",
1650 "duplicate-exports": "off"
1651 },
1652 "duplicates": {
1653 "minTokens": 100,
1654 "minLines": 10,
1655 "skipLocal": true
1656 }
1657 }"#;
1658 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1659 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1660 assert_eq!(config.rules.unused_files, Severity::Off);
1661 assert_eq!(config.rules.unused_exports, Severity::Warn);
1662 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1663 assert_eq!(config.duplicates.min_tokens, 100);
1664 assert_eq!(config.duplicates.min_lines, 10);
1665 assert!(config.duplicates.skip_local);
1666 }
1667
1668 #[test]
1669 fn extends_single_base() {
1670 let dir = test_dir("extends-single");
1671
1672 std::fs::write(
1673 dir.path().join("base.json"),
1674 r#"{"rules": {"unused-files": "warn"}}"#,
1675 )
1676 .unwrap();
1677 std::fs::write(
1678 dir.path().join(".fallowrc.json"),
1679 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1680 )
1681 .unwrap();
1682
1683 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1684 assert_eq!(config.rules.unused_files, Severity::Warn);
1685 assert_eq!(config.entry, vec!["src/index.ts"]);
1686 assert_eq!(config.rules.unused_exports, Severity::Error);
1687 }
1688
1689 #[test]
1690 fn extends_overlay_overrides_base() {
1691 let dir = test_dir("extends-overlay");
1692
1693 std::fs::write(
1694 dir.path().join("base.json"),
1695 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1696 )
1697 .unwrap();
1698 std::fs::write(
1699 dir.path().join(".fallowrc.json"),
1700 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1701 )
1702 .unwrap();
1703
1704 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1705 assert_eq!(config.rules.unused_files, Severity::Error);
1706 assert_eq!(config.rules.unused_exports, Severity::Off);
1707 }
1708
1709 #[test]
1710 fn extends_chained() {
1711 let dir = test_dir("extends-chained");
1712
1713 std::fs::write(
1714 dir.path().join("grandparent.json"),
1715 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1716 )
1717 .unwrap();
1718 std::fs::write(
1719 dir.path().join("parent.json"),
1720 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1721 )
1722 .unwrap();
1723 std::fs::write(
1724 dir.path().join(".fallowrc.json"),
1725 r#"{"extends": ["parent.json"]}"#,
1726 )
1727 .unwrap();
1728
1729 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1730 assert_eq!(config.rules.unused_files, Severity::Warn);
1731 assert_eq!(config.rules.unused_exports, Severity::Warn);
1732 }
1733
1734 #[test]
1735 fn extends_circular_detected() {
1736 let dir = test_dir("extends-circular");
1737
1738 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1739 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1740
1741 let result = FallowConfig::load(&dir.path().join("a.json"));
1742 assert!(result.is_err());
1743 let err_msg = format!("{}", result.unwrap_err());
1744 assert!(
1745 err_msg.contains("Circular extends"),
1746 "Expected circular error, got: {err_msg}"
1747 );
1748 }
1749
1750 #[test]
1751 fn extends_missing_file_errors() {
1752 let dir = test_dir("extends-missing");
1753
1754 std::fs::write(
1755 dir.path().join(".fallowrc.json"),
1756 r#"{"extends": ["nonexistent.json"]}"#,
1757 )
1758 .unwrap();
1759
1760 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1761 assert!(result.is_err());
1762 let err_msg = format!("{}", result.unwrap_err());
1763 assert!(
1764 err_msg.contains("not found"),
1765 "Expected not found error, got: {err_msg}"
1766 );
1767 }
1768
1769 #[test]
1770 fn sealed_allows_in_directory_extends() {
1771 let dir = test_dir("sealed-allows-local");
1772 std::fs::write(
1773 dir.path().join("base.json"),
1774 r#"{"ignorePatterns": ["gen/**"]}"#,
1775 )
1776 .unwrap();
1777 std::fs::write(
1778 dir.path().join(".fallowrc.json"),
1779 r#"{"sealed": true, "extends": ["./base.json"]}"#,
1780 )
1781 .unwrap();
1782
1783 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1784 assert!(config.sealed);
1785 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1786 }
1787
1788 #[test]
1789 fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
1790 let dir = test_dir("boundary-coverage-invalid-glob");
1791 std::fs::write(
1792 dir.path().join(".fallowrc.json"),
1793 r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
1794 )
1795 .unwrap();
1796
1797 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1798 assert!(result.is_err());
1799 let err_msg = format!("{}", result.unwrap_err());
1800 assert!(
1801 err_msg.contains("boundaries.coverage.allowUnmatched"),
1802 "expected coverage field in error, got: {err_msg}"
1803 );
1804 }
1805
1806 #[test]
1807 fn sealed_rejects_extends_escaping_directory() {
1808 let dir = test_dir("sealed-rejects-escape");
1809 let sub = dir.path().join("packages").join("app");
1810 std::fs::create_dir_all(&sub).unwrap();
1811
1812 std::fs::write(
1813 dir.path().join("base.json"),
1814 r#"{"ignorePatterns": ["dist/**"]}"#,
1815 )
1816 .unwrap();
1817 std::fs::write(
1818 sub.join(".fallowrc.json"),
1819 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1820 )
1821 .unwrap();
1822
1823 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1824 assert!(
1825 result.is_err(),
1826 "Expected sealed config to reject escaping extends"
1827 );
1828 let err_msg = format!("{}", result.unwrap_err());
1829 assert!(
1830 err_msg.contains("sealed"),
1831 "Error must mention sealed: {err_msg}"
1832 );
1833 assert!(
1834 err_msg.contains("outside the config's directory"),
1835 "Error must explain the constraint: {err_msg}"
1836 );
1837 }
1838
1839 #[test]
1840 fn sealed_rejects_https_extends() {
1841 let dir = test_dir("sealed-rejects-https");
1842 std::fs::write(
1843 dir.path().join(".fallowrc.json"),
1844 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1845 )
1846 .unwrap();
1847
1848 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1849 assert!(result.is_err());
1850 let err_msg = format!("{}", result.unwrap_err());
1851 assert!(
1852 err_msg.contains("sealed"),
1853 "Error must mention sealed: {err_msg}"
1854 );
1855 assert!(
1856 err_msg.contains("URL extends"),
1857 "Error must mention URL: {err_msg}"
1858 );
1859 }
1860
1861 #[test]
1862 fn sealed_rejects_npm_extends() {
1863 let dir = test_dir("sealed-rejects-npm");
1864 std::fs::write(
1865 dir.path().join(".fallowrc.json"),
1866 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1867 )
1868 .unwrap();
1869
1870 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1871 assert!(result.is_err());
1872 let err_msg = format!("{}", result.unwrap_err());
1873 assert!(
1874 err_msg.contains("sealed"),
1875 "Error must mention sealed: {err_msg}"
1876 );
1877 assert!(
1878 err_msg.contains("npm extends"),
1879 "Error must mention npm: {err_msg}"
1880 );
1881 }
1882
1883 #[test]
1884 fn sealed_default_is_false() {
1885 let dir = test_dir("sealed-default");
1886 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1887 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1888 assert!(!config.sealed);
1889 }
1890
1891 #[test]
1892 fn sealed_false_allows_escaping_extends() {
1893 let dir = test_dir("sealed-false-allows");
1894 let sub = dir.path().join("packages").join("app");
1895 std::fs::create_dir_all(&sub).unwrap();
1896
1897 std::fs::write(
1898 dir.path().join("base.json"),
1899 r#"{"ignorePatterns": ["dist/**"]}"#,
1900 )
1901 .unwrap();
1902 std::fs::write(
1903 sub.join(".fallowrc.json"),
1904 r#"{"extends": ["../../base.json"]}"#,
1905 )
1906 .unwrap();
1907
1908 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1909 assert!(!config.sealed);
1910 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1911 }
1912
1913 #[test]
1914 fn extends_string_sugar() {
1915 let dir = test_dir("extends-string");
1916
1917 std::fs::write(
1918 dir.path().join("base.json"),
1919 r#"{"ignorePatterns": ["gen/**"]}"#,
1920 )
1921 .unwrap();
1922 std::fs::write(
1923 dir.path().join(".fallowrc.json"),
1924 r#"{"extends": "base.json"}"#,
1925 )
1926 .unwrap();
1927
1928 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1929 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1930 }
1931
1932 #[test]
1933 fn extends_deep_merge_preserves_arrays() {
1934 let dir = test_dir("extends-array");
1935
1936 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1937 std::fs::write(
1938 dir.path().join(".fallowrc.json"),
1939 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1940 )
1941 .unwrap();
1942
1943 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1944 assert_eq!(config.entry, vec!["src/b.ts"]);
1945 }
1946
1947 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1948 let pkg_dir = root.join("node_modules").join(name);
1949 std::fs::create_dir_all(&pkg_dir).unwrap();
1950 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1951 }
1952
1953 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1954 let pkg_dir = root.join("node_modules").join(name);
1955 std::fs::create_dir_all(&pkg_dir).unwrap();
1956 std::fs::write(
1957 pkg_dir.join("package.json"),
1958 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1959 )
1960 .unwrap();
1961 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1962 }
1963
1964 #[test]
1965 fn extends_npm_basic_unscoped() {
1966 let dir = test_dir("npm-basic");
1967 create_npm_package(
1968 dir.path(),
1969 "fallow-config-acme",
1970 r#"{"rules": {"unused-files": "warn"}}"#,
1971 );
1972 std::fs::write(
1973 dir.path().join(".fallowrc.json"),
1974 r#"{"extends": "npm:fallow-config-acme"}"#,
1975 )
1976 .unwrap();
1977
1978 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1979 assert_eq!(config.rules.unused_files, Severity::Warn);
1980 }
1981
1982 #[test]
1983 fn extends_npm_scoped_package() {
1984 let dir = test_dir("npm-scoped");
1985 create_npm_package(
1986 dir.path(),
1987 "@company/fallow-config",
1988 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1989 );
1990 std::fs::write(
1991 dir.path().join(".fallowrc.json"),
1992 r#"{"extends": "npm:@company/fallow-config"}"#,
1993 )
1994 .unwrap();
1995
1996 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1997 assert_eq!(config.rules.unused_exports, Severity::Off);
1998 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1999 }
2000
2001 #[test]
2002 fn extends_npm_with_subpath() {
2003 let dir = test_dir("npm-subpath");
2004 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2005 std::fs::create_dir_all(&pkg_dir).unwrap();
2006 std::fs::write(
2007 pkg_dir.join("strict.json"),
2008 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2009 )
2010 .unwrap();
2011
2012 std::fs::write(
2013 dir.path().join(".fallowrc.json"),
2014 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2015 )
2016 .unwrap();
2017
2018 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2019 assert_eq!(config.rules.unused_files, Severity::Error);
2020 assert_eq!(config.rules.unused_exports, Severity::Error);
2021 }
2022
2023 #[test]
2024 fn extends_npm_package_json_main() {
2025 let dir = test_dir("npm-main");
2026 create_npm_package_with_main(
2027 dir.path(),
2028 "fallow-config-acme",
2029 "config.json",
2030 r#"{"rules": {"unused-types": "off"}}"#,
2031 );
2032 std::fs::write(
2033 dir.path().join(".fallowrc.json"),
2034 r#"{"extends": "npm:fallow-config-acme"}"#,
2035 )
2036 .unwrap();
2037
2038 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2039 assert_eq!(config.rules.unused_types, Severity::Off);
2040 }
2041
2042 #[test]
2043 fn extends_npm_package_json_exports_string() {
2044 let dir = test_dir("npm-exports-str");
2045 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2046 std::fs::create_dir_all(&pkg_dir).unwrap();
2047 std::fs::write(
2048 pkg_dir.join("package.json"),
2049 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2050 )
2051 .unwrap();
2052 std::fs::write(
2053 pkg_dir.join("base.json"),
2054 r#"{"rules": {"circular-dependencies": "warn"}}"#,
2055 )
2056 .unwrap();
2057
2058 std::fs::write(
2059 dir.path().join(".fallowrc.json"),
2060 r#"{"extends": "npm:fallow-config-co"}"#,
2061 )
2062 .unwrap();
2063
2064 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2065 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2066 }
2067
2068 #[test]
2069 fn extends_npm_package_json_exports_object() {
2070 let dir = test_dir("npm-exports-obj");
2071 let pkg_dir = dir.path().join("node_modules/@co/cfg");
2072 std::fs::create_dir_all(&pkg_dir).unwrap();
2073 std::fs::write(
2074 pkg_dir.join("package.json"),
2075 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2076 )
2077 .unwrap();
2078 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2079
2080 std::fs::write(
2081 dir.path().join(".fallowrc.json"),
2082 r#"{"extends": "npm:@co/cfg"}"#,
2083 )
2084 .unwrap();
2085
2086 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2087 assert_eq!(config.entry, vec!["src/app.ts"]);
2088 }
2089
2090 #[test]
2091 fn extends_npm_exports_takes_priority_over_main() {
2092 let dir = test_dir("npm-exports-prio");
2093 let pkg_dir = dir.path().join("node_modules/my-config");
2094 std::fs::create_dir_all(&pkg_dir).unwrap();
2095 std::fs::write(
2096 pkg_dir.join("package.json"),
2097 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2098 )
2099 .unwrap();
2100 std::fs::write(
2101 pkg_dir.join("old.json"),
2102 r#"{"rules": {"unused-files": "off"}}"#,
2103 )
2104 .unwrap();
2105 std::fs::write(
2106 pkg_dir.join("new.json"),
2107 r#"{"rules": {"unused-files": "warn"}}"#,
2108 )
2109 .unwrap();
2110
2111 std::fs::write(
2112 dir.path().join(".fallowrc.json"),
2113 r#"{"extends": "npm:my-config"}"#,
2114 )
2115 .unwrap();
2116
2117 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2118 assert_eq!(config.rules.unused_files, Severity::Warn);
2119 }
2120
2121 #[test]
2122 fn extends_npm_walk_up_directories() {
2123 let dir = test_dir("npm-walkup");
2124 create_npm_package(
2125 dir.path(),
2126 "shared-config",
2127 r#"{"rules": {"unused-files": "warn"}}"#,
2128 );
2129 let sub = dir.path().join("packages/app");
2130 std::fs::create_dir_all(&sub).unwrap();
2131 std::fs::write(
2132 sub.join(".fallowrc.json"),
2133 r#"{"extends": "npm:shared-config"}"#,
2134 )
2135 .unwrap();
2136
2137 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2138 assert_eq!(config.rules.unused_files, Severity::Warn);
2139 }
2140
2141 #[test]
2142 fn extends_npm_overlay_overrides_base() {
2143 let dir = test_dir("npm-overlay");
2144 create_npm_package(
2145 dir.path(),
2146 "@company/base",
2147 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2148 );
2149 std::fs::write(
2150 dir.path().join(".fallowrc.json"),
2151 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2152 )
2153 .unwrap();
2154
2155 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2156 assert_eq!(config.rules.unused_files, Severity::Error);
2157 assert_eq!(config.rules.unused_exports, Severity::Off);
2158 assert_eq!(config.entry, vec!["src/app.ts"]);
2159 }
2160
2161 #[test]
2162 fn extends_npm_chained_with_relative() {
2163 let dir = test_dir("npm-chained");
2164 let pkg_dir = dir.path().join("node_modules/my-config");
2165 std::fs::create_dir_all(&pkg_dir).unwrap();
2166 std::fs::write(
2167 pkg_dir.join("base.json"),
2168 r#"{"rules": {"unused-files": "warn"}}"#,
2169 )
2170 .unwrap();
2171 std::fs::write(
2172 pkg_dir.join(".fallowrc.json"),
2173 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2174 )
2175 .unwrap();
2176
2177 std::fs::write(
2178 dir.path().join(".fallowrc.json"),
2179 r#"{"extends": "npm:my-config"}"#,
2180 )
2181 .unwrap();
2182
2183 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2184 assert_eq!(config.rules.unused_files, Severity::Warn);
2185 assert_eq!(config.rules.unused_exports, Severity::Off);
2186 }
2187
2188 #[test]
2189 fn extends_npm_mixed_with_relative_paths() {
2190 let dir = test_dir("npm-mixed");
2191 create_npm_package(
2192 dir.path(),
2193 "shared-base",
2194 r#"{"rules": {"unused-files": "off"}}"#,
2195 );
2196 std::fs::write(
2197 dir.path().join("local-overrides.json"),
2198 r#"{"rules": {"unused-files": "warn"}}"#,
2199 )
2200 .unwrap();
2201 std::fs::write(
2202 dir.path().join(".fallowrc.json"),
2203 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2204 )
2205 .unwrap();
2206
2207 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2208 assert_eq!(config.rules.unused_files, Severity::Warn);
2209 }
2210
2211 #[test]
2212 fn extends_npm_missing_package_errors() {
2213 let dir = test_dir("npm-missing");
2214 std::fs::write(
2215 dir.path().join(".fallowrc.json"),
2216 r#"{"extends": "npm:nonexistent-package"}"#,
2217 )
2218 .unwrap();
2219
2220 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2221 assert!(result.is_err());
2222 let err_msg = format!("{}", result.unwrap_err());
2223 assert!(
2224 err_msg.contains("not found"),
2225 "Expected 'not found' error, got: {err_msg}"
2226 );
2227 assert!(
2228 err_msg.contains("nonexistent-package"),
2229 "Expected package name in error, got: {err_msg}"
2230 );
2231 assert!(
2232 err_msg.contains("install it"),
2233 "Expected install hint in error, got: {err_msg}"
2234 );
2235 }
2236
2237 #[test]
2238 fn extends_npm_no_config_in_package_errors() {
2239 let dir = test_dir("npm-no-config");
2240 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2241 std::fs::create_dir_all(&pkg_dir).unwrap();
2242 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2243
2244 std::fs::write(
2245 dir.path().join(".fallowrc.json"),
2246 r#"{"extends": "npm:empty-pkg"}"#,
2247 )
2248 .unwrap();
2249
2250 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2251 assert!(result.is_err());
2252 let err_msg = format!("{}", result.unwrap_err());
2253 assert!(
2254 err_msg.contains("No fallow config found"),
2255 "Expected 'No fallow config found' error, got: {err_msg}"
2256 );
2257 }
2258
2259 #[test]
2260 fn extends_npm_missing_subpath_errors() {
2261 let dir = test_dir("npm-missing-sub");
2262 let pkg_dir = dir.path().join("node_modules/@co/config");
2263 std::fs::create_dir_all(&pkg_dir).unwrap();
2264
2265 std::fs::write(
2266 dir.path().join(".fallowrc.json"),
2267 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2268 )
2269 .unwrap();
2270
2271 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2272 assert!(result.is_err());
2273 let err_msg = format!("{}", result.unwrap_err());
2274 assert!(
2275 err_msg.contains("nonexistent.json"),
2276 "Expected subpath in error, got: {err_msg}"
2277 );
2278 }
2279
2280 #[test]
2281 fn extends_npm_empty_specifier_errors() {
2282 let dir = test_dir("npm-empty");
2283 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2284
2285 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2286 assert!(result.is_err());
2287 let err_msg = format!("{}", result.unwrap_err());
2288 assert!(
2289 err_msg.contains("Empty npm specifier"),
2290 "Expected 'Empty npm specifier' error, got: {err_msg}"
2291 );
2292 }
2293
2294 #[test]
2295 fn extends_npm_space_after_colon_trimmed() {
2296 let dir = test_dir("npm-space");
2297 create_npm_package(
2298 dir.path(),
2299 "fallow-config-acme",
2300 r#"{"rules": {"unused-files": "warn"}}"#,
2301 );
2302 std::fs::write(
2303 dir.path().join(".fallowrc.json"),
2304 r#"{"extends": "npm: fallow-config-acme"}"#,
2305 )
2306 .unwrap();
2307
2308 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2309 assert_eq!(config.rules.unused_files, Severity::Warn);
2310 }
2311
2312 #[test]
2313 fn extends_npm_exports_node_condition() {
2314 let dir = test_dir("npm-node-cond");
2315 let pkg_dir = dir.path().join("node_modules/node-config");
2316 std::fs::create_dir_all(&pkg_dir).unwrap();
2317 std::fs::write(
2318 pkg_dir.join("package.json"),
2319 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2320 )
2321 .unwrap();
2322 std::fs::write(
2323 pkg_dir.join("node.json"),
2324 r#"{"rules": {"unused-files": "off"}}"#,
2325 )
2326 .unwrap();
2327
2328 std::fs::write(
2329 dir.path().join(".fallowrc.json"),
2330 r#"{"extends": "npm:node-config"}"#,
2331 )
2332 .unwrap();
2333
2334 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2335 assert_eq!(config.rules.unused_files, Severity::Off);
2336 }
2337
2338 #[test]
2339 fn parse_npm_specifier_unscoped() {
2340 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2341 }
2342
2343 #[test]
2344 fn parse_npm_specifier_unscoped_with_subpath() {
2345 assert_eq!(
2346 parse_npm_specifier("my-config/strict.json"),
2347 ("my-config", Some("strict.json"))
2348 );
2349 }
2350
2351 #[test]
2352 fn parse_npm_specifier_scoped() {
2353 assert_eq!(
2354 parse_npm_specifier("@company/fallow-config"),
2355 ("@company/fallow-config", None)
2356 );
2357 }
2358
2359 #[test]
2360 fn parse_npm_specifier_scoped_with_subpath() {
2361 assert_eq!(
2362 parse_npm_specifier("@company/fallow-config/strict.json"),
2363 ("@company/fallow-config", Some("strict.json"))
2364 );
2365 }
2366
2367 #[test]
2368 fn parse_npm_specifier_scoped_with_nested_subpath() {
2369 assert_eq!(
2370 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2371 ("@company/fallow-config", Some("presets/strict.json"))
2372 );
2373 }
2374
2375 #[test]
2376 fn extends_npm_subpath_traversal_rejected() {
2377 let dir = test_dir("npm-traversal-sub");
2378 let pkg_dir = dir.path().join("node_modules/evil-pkg");
2379 std::fs::create_dir_all(&pkg_dir).unwrap();
2380 std::fs::write(
2381 dir.path().join("secret.json"),
2382 r#"{"entry": ["stolen.ts"]}"#,
2383 )
2384 .unwrap();
2385
2386 std::fs::write(
2387 dir.path().join(".fallowrc.json"),
2388 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2389 )
2390 .unwrap();
2391
2392 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2393 assert!(result.is_err());
2394 let err_msg = format!("{}", result.unwrap_err());
2395 assert!(
2396 err_msg.contains("traversal") || err_msg.contains("not found"),
2397 "Expected traversal or not-found error, got: {err_msg}"
2398 );
2399 }
2400
2401 #[test]
2402 fn extends_npm_dotdot_package_name_rejected() {
2403 let dir = test_dir("npm-dotdot-name");
2404 std::fs::write(
2405 dir.path().join(".fallowrc.json"),
2406 r#"{"extends": "npm:../relative"}"#,
2407 )
2408 .unwrap();
2409
2410 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2411 assert!(result.is_err());
2412 let err_msg = format!("{}", result.unwrap_err());
2413 assert!(
2414 err_msg.contains("path traversal"),
2415 "Expected 'path traversal' error, got: {err_msg}"
2416 );
2417 }
2418
2419 #[test]
2420 fn extends_npm_scoped_without_name_rejected() {
2421 let dir = test_dir("npm-scope-only");
2422 std::fs::write(
2423 dir.path().join(".fallowrc.json"),
2424 r#"{"extends": "npm:@scope"}"#,
2425 )
2426 .unwrap();
2427
2428 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2429 assert!(result.is_err());
2430 let err_msg = format!("{}", result.unwrap_err());
2431 assert!(
2432 err_msg.contains("@scope/name"),
2433 "Expected scoped name format error, got: {err_msg}"
2434 );
2435 }
2436
2437 #[test]
2438 fn extends_npm_malformed_package_json_errors() {
2439 let dir = test_dir("npm-bad-pkgjson");
2440 let pkg_dir = dir.path().join("node_modules/bad-pkg");
2441 std::fs::create_dir_all(&pkg_dir).unwrap();
2442 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2443
2444 std::fs::write(
2445 dir.path().join(".fallowrc.json"),
2446 r#"{"extends": "npm:bad-pkg"}"#,
2447 )
2448 .unwrap();
2449
2450 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2451 assert!(result.is_err());
2452 let err_msg = format!("{}", result.unwrap_err());
2453 assert!(
2454 err_msg.contains("Failed to parse"),
2455 "Expected parse error, got: {err_msg}"
2456 );
2457 }
2458
2459 #[test]
2460 fn extends_npm_exports_traversal_rejected() {
2461 let dir = test_dir("npm-exports-escape");
2462 let pkg_dir = dir.path().join("node_modules/evil-exports");
2463 std::fs::create_dir_all(&pkg_dir).unwrap();
2464 std::fs::write(
2465 pkg_dir.join("package.json"),
2466 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2467 )
2468 .unwrap();
2469 std::fs::write(
2470 dir.path().join("secret.json"),
2471 r#"{"entry": ["stolen.ts"]}"#,
2472 )
2473 .unwrap();
2474
2475 std::fs::write(
2476 dir.path().join(".fallowrc.json"),
2477 r#"{"extends": "npm:evil-exports"}"#,
2478 )
2479 .unwrap();
2480
2481 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2482 assert!(result.is_err());
2483 let err_msg = format!("{}", result.unwrap_err());
2484 assert!(
2485 err_msg.contains("traversal"),
2486 "Expected traversal error, got: {err_msg}"
2487 );
2488 }
2489
2490 #[test]
2491 fn deep_merge_scalar_overlay_replaces_base() {
2492 let mut base = serde_json::json!("hello");
2493 deep_merge_json(&mut base, serde_json::json!("world"));
2494 assert_eq!(base, serde_json::json!("world"));
2495 }
2496
2497 #[test]
2498 fn deep_merge_array_overlay_replaces_base() {
2499 let mut base = serde_json::json!(["a", "b"]);
2500 deep_merge_json(&mut base, serde_json::json!(["c"]));
2501 assert_eq!(base, serde_json::json!(["c"]));
2502 }
2503
2504 #[test]
2505 fn deep_merge_nested_object_merge() {
2506 let mut base = serde_json::json!({
2507 "level1": {
2508 "level2": {
2509 "a": 1,
2510 "b": 2
2511 }
2512 }
2513 });
2514 let overlay = serde_json::json!({
2515 "level1": {
2516 "level2": {
2517 "b": 99,
2518 "c": 3
2519 }
2520 }
2521 });
2522 deep_merge_json(&mut base, overlay);
2523 assert_eq!(base["level1"]["level2"]["a"], 1);
2524 assert_eq!(base["level1"]["level2"]["b"], 99);
2525 assert_eq!(base["level1"]["level2"]["c"], 3);
2526 }
2527
2528 #[test]
2529 fn deep_merge_overlay_adds_new_fields() {
2530 let mut base = serde_json::json!({"existing": true});
2531 let overlay = serde_json::json!({"new_field": "added", "another": 42});
2532 deep_merge_json(&mut base, overlay);
2533 assert_eq!(base["existing"], true);
2534 assert_eq!(base["new_field"], "added");
2535 assert_eq!(base["another"], 42);
2536 }
2537
2538 #[test]
2539 fn deep_merge_null_overlay_replaces_object() {
2540 let mut base = serde_json::json!({"key": "value"});
2541 deep_merge_json(&mut base, serde_json::json!(null));
2542 assert_eq!(base, serde_json::json!(null));
2543 }
2544
2545 #[test]
2546 fn deep_merge_empty_object_overlay_preserves_base() {
2547 let mut base = serde_json::json!({"a": 1, "b": 2});
2548 deep_merge_json(&mut base, serde_json::json!({}));
2549 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2550 }
2551
2552 #[test]
2553 fn rules_severity_error_warn_off_from_json() {
2554 let json_str = r#"{
2555 "rules": {
2556 "unused-files": "error",
2557 "unused-exports": "warn",
2558 "unused-types": "off"
2559 }
2560 }"#;
2561 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2562 assert_eq!(config.rules.unused_files, Severity::Error);
2563 assert_eq!(config.rules.unused_exports, Severity::Warn);
2564 assert_eq!(config.rules.unused_types, Severity::Off);
2565 }
2566
2567 #[test]
2568 fn rules_omitted_default_to_error() {
2569 let json_str = r#"{
2570 "rules": {
2571 "unused-files": "warn"
2572 }
2573 }"#;
2574 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2575 assert_eq!(config.rules.unused_files, Severity::Warn);
2576 assert_eq!(config.rules.unused_exports, Severity::Error);
2577 assert_eq!(config.rules.unused_types, Severity::Error);
2578 assert_eq!(config.rules.unused_dependencies, Severity::Error);
2579 assert_eq!(config.rules.unresolved_imports, Severity::Error);
2580 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2581 assert_eq!(config.rules.duplicate_exports, Severity::Error);
2582 assert_eq!(config.rules.circular_dependencies, Severity::Error);
2583 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2584 }
2585
2586 #[test]
2587 fn find_and_load_returns_none_when_no_config() {
2588 let dir = test_dir("find-none");
2589 std::fs::create_dir(dir.path().join(".git")).unwrap();
2590
2591 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2592 assert!(result.is_none());
2593 }
2594
2595 #[test]
2596 fn find_and_load_finds_fallowrc_json() {
2597 let dir = test_dir("find-json");
2598 std::fs::create_dir(dir.path().join(".git")).unwrap();
2599 std::fs::write(
2600 dir.path().join(".fallowrc.json"),
2601 r#"{"entry": ["src/main.ts"]}"#,
2602 )
2603 .unwrap();
2604
2605 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2606 assert_eq!(config.entry, vec!["src/main.ts"]);
2607 assert!(path.ends_with(".fallowrc.json"));
2608 }
2609
2610 #[test]
2611 fn find_and_load_finds_fallowrc_jsonc() {
2612 let dir = test_dir("find-jsonc");
2613 std::fs::create_dir(dir.path().join(".git")).unwrap();
2614 std::fs::write(
2615 dir.path().join(".fallowrc.jsonc"),
2616 r#"{
2617 "entry": ["src/main.ts"]
2618 }"#,
2619 )
2620 .unwrap();
2621
2622 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2623 assert_eq!(config.entry, vec!["src/main.ts"]);
2624 assert!(path.ends_with(".fallowrc.jsonc"));
2625 }
2626
2627 #[test]
2628 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2629 let dir = test_dir("find-json-vs-jsonc");
2630 std::fs::create_dir(dir.path().join(".git")).unwrap();
2631 std::fs::write(
2632 dir.path().join(".fallowrc.json"),
2633 r#"{"entry": ["from-json.ts"]}"#,
2634 )
2635 .unwrap();
2636 std::fs::write(
2637 dir.path().join(".fallowrc.jsonc"),
2638 r#"{"entry": ["from-jsonc.ts"]}"#,
2639 )
2640 .unwrap();
2641
2642 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2643 assert_eq!(config.entry, vec!["from-json.ts"]);
2644 assert!(path.ends_with(".fallowrc.json"));
2645 }
2646
2647 #[test]
2648 fn find_and_load_prefers_fallowrc_json_over_toml() {
2649 let dir = test_dir("find-priority");
2650 std::fs::create_dir(dir.path().join(".git")).unwrap();
2651 std::fs::write(
2652 dir.path().join(".fallowrc.json"),
2653 r#"{"entry": ["from-json.ts"]}"#,
2654 )
2655 .unwrap();
2656 std::fs::write(
2657 dir.path().join("fallow.toml"),
2658 "entry = [\"from-toml.ts\"]\n",
2659 )
2660 .unwrap();
2661
2662 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2663 assert_eq!(config.entry, vec!["from-json.ts"]);
2664 assert!(path.ends_with(".fallowrc.json"));
2665 }
2666
2667 #[test]
2668 fn shadowed_config_names_empty_when_single_config() {
2669 let dir = test_dir("shadow-single");
2670 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2671 assert!(shadowed_config_names(dir.path(), 0).is_empty());
2672 }
2673
2674 #[test]
2675 fn shadowed_config_names_reports_lower_precedence_toml() {
2676 let dir = test_dir("shadow-json-toml");
2677 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2678 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2679 assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2680 }
2681
2682 #[test]
2683 fn shadowed_config_names_reports_jsonc_sibling() {
2684 let dir = test_dir("shadow-json-jsonc");
2685 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2686 std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2687 assert_eq!(
2688 shadowed_config_names(dir.path(), 0),
2689 vec![".fallowrc.jsonc"]
2690 );
2691 }
2692
2693 #[test]
2694 fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2695 let dir = test_dir("shadow-all-four");
2696 for name in CONFIG_NAMES {
2697 std::fs::write(dir.path().join(name), "").unwrap();
2698 }
2699 assert_eq!(
2700 shadowed_config_names(dir.path(), 0),
2701 vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2702 );
2703 }
2704
2705 #[test]
2706 fn shadowed_config_names_scoped_to_indices_after_winner() {
2707 let dir = test_dir("shadow-toml-dottoml");
2708 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2709 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2710 assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2711 }
2712
2713 #[test]
2714 fn find_and_load_warns_when_configs_coexist() {
2715 let dir = test_dir("coexist-warn");
2716 std::fs::create_dir(dir.path().join(".git")).unwrap();
2717 std::fs::write(
2718 dir.path().join(".fallowrc.json"),
2719 r#"{"entry": ["from-json.ts"]}"#,
2720 )
2721 .unwrap();
2722 std::fs::write(
2723 dir.path().join("fallow.toml"),
2724 "entry = [\"from-toml.ts\"]\n",
2725 )
2726 .unwrap();
2727
2728 let (result, captured) =
2729 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2730
2731 let (config, path) = result.unwrap().unwrap();
2732 assert_eq!(config.entry, vec!["from-json.ts"]);
2733 assert!(path.ends_with(".fallowrc.json"));
2734
2735 assert_eq!(captured.len(), 1);
2736 let (chosen, shadowed) = &captured[0];
2737 assert_eq!(chosen, ".fallowrc.json");
2738 assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2739 }
2740
2741 #[test]
2742 fn find_and_load_does_not_warn_for_single_config() {
2743 let dir = test_dir("coexist-none");
2744 std::fs::create_dir(dir.path().join(".git")).unwrap();
2745 std::fs::write(
2746 dir.path().join(".fallowrc.json"),
2747 r#"{"entry": ["only.ts"]}"#,
2748 )
2749 .unwrap();
2750
2751 let (result, captured) =
2752 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2753 assert!(result.unwrap().is_some());
2754 assert!(captured.is_empty());
2755 }
2756
2757 #[test]
2758 fn find_and_load_warns_per_directory_independently() {
2759 let make = |name: &str| {
2760 let dir = test_dir(name);
2761 std::fs::create_dir(dir.path().join(".git")).unwrap();
2762 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2763 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2764 dir
2765 };
2766 let first = make("coexist-dir-a");
2767 let second = make("coexist-dir-b");
2768
2769 let ((), captured) = capture_coexisting_config_warnings(|| {
2770 FallowConfig::find_and_load(first.path()).unwrap();
2771 FallowConfig::find_and_load(second.path()).unwrap();
2772 });
2773
2774 assert_eq!(captured.len(), 2);
2775 assert!(captured.iter().all(|(chosen, shadowed)| {
2776 chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2777 }));
2778 }
2779
2780 #[test]
2781 fn explicit_load_does_not_warn_about_coexisting_configs() {
2782 let dir = test_dir("coexist-explicit");
2783 std::fs::write(
2784 dir.path().join(".fallowrc.json"),
2785 r#"{"entry": ["chosen.ts"]}"#,
2786 )
2787 .unwrap();
2788 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2789
2790 let chosen = dir.path().join("fallow.toml");
2791 let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2792 assert!(result.is_ok());
2793 assert!(captured.is_empty());
2794 }
2795
2796 #[test]
2797 fn find_and_load_finds_fallow_toml() {
2798 let dir = test_dir("find-toml");
2799 std::fs::create_dir(dir.path().join(".git")).unwrap();
2800 std::fs::write(
2801 dir.path().join("fallow.toml"),
2802 "entry = [\"src/index.ts\"]\n",
2803 )
2804 .unwrap();
2805
2806 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2807 assert_eq!(config.entry, vec!["src/index.ts"]);
2808 }
2809
2810 #[test]
2811 fn find_and_load_stops_at_git_dir() {
2812 let dir = test_dir("find-git-stop");
2813 let sub = dir.path().join("sub");
2814 std::fs::create_dir(&sub).unwrap();
2815 std::fs::create_dir(dir.path().join(".git")).unwrap();
2816 let result = FallowConfig::find_and_load(&sub).unwrap();
2817 assert!(result.is_none());
2818 }
2819
2820 #[test]
2821 fn find_and_load_walks_past_package_json_in_monorepo() {
2822 let dir = test_dir("find-monorepo");
2823 std::fs::create_dir(dir.path().join(".git")).unwrap();
2824 std::fs::write(
2825 dir.path().join(".fallowrc.json"),
2826 r#"{"entry": ["src/index.ts"]}"#,
2827 )
2828 .unwrap();
2829
2830 let sub = dir.path().join("packages").join("app");
2831 std::fs::create_dir_all(&sub).unwrap();
2832 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2833
2834 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2835 assert_eq!(config.entry, vec!["src/index.ts"]);
2836 assert_eq!(path, dir.path().join(".fallowrc.json"));
2837 }
2838
2839 #[test]
2840 fn find_and_load_sub_package_config_wins_over_root() {
2841 let dir = test_dir("find-monorepo-override");
2842 std::fs::create_dir(dir.path().join(".git")).unwrap();
2843 std::fs::write(
2844 dir.path().join(".fallowrc.json"),
2845 r#"{"entry": ["src/root.ts"]}"#,
2846 )
2847 .unwrap();
2848
2849 let sub = dir.path().join("packages").join("app");
2850 std::fs::create_dir_all(&sub).unwrap();
2851 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2852 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2853
2854 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2855 assert_eq!(config.entry, vec!["src/sub.ts"]);
2856 assert_eq!(path, sub.join(".fallowrc.json"));
2857 }
2858
2859 #[test]
2860 fn find_and_load_stops_at_git_file_submodule() {
2861 let dir = test_dir("find-git-file");
2862 std::fs::create_dir(dir.path().join(".git")).unwrap();
2863 std::fs::write(
2864 dir.path().join(".fallowrc.json"),
2865 r#"{"entry": ["src/parent.ts"]}"#,
2866 )
2867 .unwrap();
2868
2869 let submodule = dir.path().join("vendor").join("lib");
2870 std::fs::create_dir_all(&submodule).unwrap();
2871 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2872
2873 let result = FallowConfig::find_and_load(&submodule).unwrap();
2874 assert!(
2875 result.is_none(),
2876 "submodule boundary should stop config walk",
2877 );
2878 }
2879
2880 #[test]
2881 fn find_and_load_stops_at_hg_dir() {
2882 let dir = test_dir("find-hg-stop");
2883 let sub = dir.path().join("sub");
2884 std::fs::create_dir(&sub).unwrap();
2885 std::fs::create_dir(dir.path().join(".hg")).unwrap();
2886
2887 let result = FallowConfig::find_and_load(&sub).unwrap();
2888 assert!(result.is_none());
2889 }
2890
2891 #[test]
2892 fn find_and_load_returns_error_for_invalid_config() {
2893 let dir = test_dir("find-invalid");
2894 std::fs::create_dir(dir.path().join(".git")).unwrap();
2895 std::fs::write(
2896 dir.path().join(".fallowrc.json"),
2897 r"{ this is not valid json }",
2898 )
2899 .unwrap();
2900
2901 let result = FallowConfig::find_and_load(dir.path());
2902 assert!(result.is_err());
2903 }
2904
2905 #[test]
2906 fn load_toml_config_file() {
2907 let dir = test_dir("toml-config");
2908 let config_path = dir.path().join("fallow.toml");
2909 std::fs::write(
2910 &config_path,
2911 r#"
2912entry = ["src/index.ts"]
2913ignorePatterns = ["dist/**"]
2914
2915[rules]
2916unused-files = "warn"
2917
2918[duplicates]
2919minTokens = 100
2920"#,
2921 )
2922 .unwrap();
2923
2924 let config = FallowConfig::load(&config_path).unwrap();
2925 assert_eq!(config.entry, vec!["src/index.ts"]);
2926 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2927 assert_eq!(config.rules.unused_files, Severity::Warn);
2928 assert_eq!(config.duplicates.min_tokens, 100);
2929 }
2930
2931 #[test]
2932 fn load_toml_config_file_with_health_threshold_override() {
2933 let dir = test_dir("toml-health-threshold-override");
2934 let config_path = dir.path().join("fallow.toml");
2935 std::fs::write(
2936 &config_path,
2937 r#"
2938[health]
2939thresholdOverrides = [
2940 { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
2941]
2942"#,
2943 )
2944 .unwrap();
2945
2946 let config = FallowConfig::load(&config_path).unwrap();
2947 let override_config = &config.health.threshold_overrides[0];
2948 assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2949 assert_eq!(override_config.functions, vec!["legacyFlow"]);
2950 assert_eq!(override_config.max_cyclomatic, Some(30));
2951 assert_eq!(override_config.max_cognitive, Some(25));
2952 assert_eq!(override_config.max_crap, Some(80.5));
2953 assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
2954 }
2955
2956 #[test]
2957 fn extends_absolute_path_rejected() {
2958 let dir = test_dir("extends-absolute");
2959
2960 #[cfg(unix)]
2961 let abs_path = "/absolute/path/config.json";
2962 #[cfg(windows)]
2963 let abs_path = "C:\\absolute\\path\\config.json";
2964
2965 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2966 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2967
2968 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2969 assert!(result.is_err());
2970 let err_msg = format!("{}", result.unwrap_err());
2971 assert!(
2972 err_msg.contains("must be relative"),
2973 "Expected 'must be relative' error, got: {err_msg}"
2974 );
2975 }
2976
2977 #[test]
2978 fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2979 let dir = test_dir("extends-windows-absolute");
2980
2981 std::fs::write(
2982 dir.path().join(".fallowrc.json"),
2983 r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2984 )
2985 .unwrap();
2986
2987 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2988 assert!(result.is_err());
2989 let err_msg = format!("{}", result.unwrap_err());
2990 assert!(
2991 err_msg.contains("must be relative"),
2992 "Expected 'must be relative' error, got: {err_msg}"
2993 );
2994 }
2995
2996 #[cfg(windows)]
2997 #[test]
2998 fn extends_posix_rooted_absolute_path_rejected_on_windows() {
2999 let dir = test_dir("extends-posix-rooted-absolute");
3000
3001 std::fs::write(
3002 dir.path().join(".fallowrc.json"),
3003 r#"{"extends": ["/absolute/path/config.json"]}"#,
3004 )
3005 .unwrap();
3006
3007 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3008 assert!(result.is_err());
3009 let err_msg = format!("{}", result.unwrap_err());
3010 assert!(
3011 err_msg.contains("must be relative"),
3012 "Expected 'must be relative' error, got: {err_msg}"
3013 );
3014 }
3015
3016 #[test]
3017 fn resolve_production_mode_disables_dev_deps() {
3018 let config = FallowConfig {
3019 production: true.into(),
3020 ..Default::default()
3021 };
3022 let resolved = config.resolve(
3023 PathBuf::from("/tmp/test"),
3024 OutputFormat::Human,
3025 4,
3026 false,
3027 true,
3028 None,
3029 );
3030 assert!(resolved.production);
3031 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3032 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3033 assert_eq!(resolved.rules.unused_files, Severity::Error);
3034 assert_eq!(resolved.rules.unused_exports, Severity::Error);
3035 }
3036
3037 #[test]
3038 fn include_entry_exports_deserializes_from_camelcase_json() {
3039 let json = r#"{ "includeEntryExports": true }"#;
3040 let config: FallowConfig = serde_json::from_str(json).unwrap();
3041 assert!(config.include_entry_exports);
3042 }
3043
3044 #[test]
3045 fn include_entry_exports_deserializes_from_camelcase_toml() {
3046 let toml_str = "includeEntryExports = true\n";
3047 let config: FallowConfig = toml::from_str(toml_str).unwrap();
3048 assert!(config.include_entry_exports);
3049 }
3050
3051 #[test]
3052 fn include_entry_exports_default_is_false() {
3053 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3054 assert!(!config.include_entry_exports);
3055 }
3056
3057 #[test]
3058 fn include_entry_exports_propagates_through_resolve() {
3059 let config = FallowConfig {
3060 include_entry_exports: true,
3061 auto_imports: false,
3062 cache: CacheConfig::default(),
3063 ..Default::default()
3064 };
3065 let resolved = config.resolve(
3066 PathBuf::from("/tmp/test"),
3067 OutputFormat::Human,
3068 1,
3069 true,
3070 true,
3071 None,
3072 );
3073 assert!(resolved.include_entry_exports);
3074 }
3075
3076 #[test]
3077 fn config_format_defaults_to_toml_for_unknown() {
3078 assert!(matches!(
3079 ConfigFormat::from_path(Path::new("config.yaml")),
3080 ConfigFormat::Toml
3081 ));
3082 assert!(matches!(
3083 ConfigFormat::from_path(Path::new("config")),
3084 ConfigFormat::Toml
3085 ));
3086 }
3087
3088 #[test]
3089 fn deep_merge_object_over_scalar_replaces() {
3090 let mut base = serde_json::json!("just a string");
3091 let overlay = serde_json::json!({"key": "value"});
3092 deep_merge_json(&mut base, overlay);
3093 assert_eq!(base, serde_json::json!({"key": "value"}));
3094 }
3095
3096 #[test]
3097 fn deep_merge_scalar_over_object_replaces() {
3098 let mut base = serde_json::json!({"key": "value"});
3099 let overlay = serde_json::json!(42);
3100 deep_merge_json(&mut base, overlay);
3101 assert_eq!(base, serde_json::json!(42));
3102 }
3103
3104 #[test]
3105 fn extends_non_string_non_array_ignored() {
3106 let dir = test_dir("extends-numeric");
3107 std::fs::write(
3108 dir.path().join(".fallowrc.json"),
3109 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3110 )
3111 .unwrap();
3112
3113 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3114 assert_eq!(config.entry, vec!["src/index.ts"]);
3115 }
3116
3117 #[test]
3118 fn extends_multiple_bases_later_wins() {
3119 let dir = test_dir("extends-multi-base");
3120
3121 std::fs::write(
3122 dir.path().join("base-a.json"),
3123 r#"{"rules": {"unused-files": "warn"}}"#,
3124 )
3125 .unwrap();
3126 std::fs::write(
3127 dir.path().join("base-b.json"),
3128 r#"{"rules": {"unused-files": "off"}}"#,
3129 )
3130 .unwrap();
3131 std::fs::write(
3132 dir.path().join(".fallowrc.json"),
3133 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3134 )
3135 .unwrap();
3136
3137 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3138 assert_eq!(config.rules.unused_files, Severity::Off);
3139 }
3140
3141 #[test]
3142 fn load_rejects_empty_security_request_receivers() {
3143 let dir = test_dir("empty-security-request-receivers");
3144 std::fs::write(
3145 dir.path().join(".fallowrc.json"),
3146 r#"{"security": {"requestReceivers": ["req", " "]}}"#,
3147 )
3148 .unwrap();
3149
3150 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3151 let err = result.expect_err("empty receiver should be rejected");
3152 assert!(
3153 err.to_string().contains("security.requestReceivers"),
3154 "error should name security.requestReceivers: {err}"
3155 );
3156 }
3157
3158 #[test]
3159 fn resolve_normalizes_security_request_receivers() {
3160 let dir = test_dir("normalize-security-request-receivers");
3161 std::fs::write(
3162 dir.path().join(".fallowrc.json"),
3163 r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3164 )
3165 .unwrap();
3166
3167 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3168 .unwrap()
3169 .resolve(
3170 dir.path().to_path_buf(),
3171 OutputFormat::Human,
3172 1,
3173 true,
3174 true,
3175 None,
3176 );
3177 assert_eq!(
3178 config.security.request_receivers,
3179 vec!["httpreq".to_string(), "r".to_string()]
3180 );
3181 }
3182
3183 #[test]
3184 fn fallow_config_deserialize_production() {
3185 let json_str = r#"{"production": true}"#;
3186 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3187 assert!(config.production);
3188 }
3189
3190 #[test]
3191 fn fallow_config_production_defaults_false() {
3192 let config: FallowConfig = serde_json::from_str("{}").unwrap();
3193 assert!(!config.production);
3194 }
3195
3196 #[test]
3197 fn package_json_optional_dependency_names() {
3198 let pkg: PackageJson = serde_json::from_str(
3199 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3200 )
3201 .unwrap();
3202 let opt = pkg.optional_dependency_names();
3203 assert_eq!(opt.len(), 2);
3204 assert!(opt.contains(&"fsevents".to_string()));
3205 assert!(opt.contains(&"chokidar".to_string()));
3206 }
3207
3208 #[test]
3209 fn package_json_optional_deps_empty_when_missing() {
3210 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3211 assert!(pkg.optional_dependency_names().is_empty());
3212 }
3213
3214 #[test]
3215 fn find_config_path_returns_fallowrc_json() {
3216 let dir = test_dir("find-path-json");
3217 std::fs::create_dir(dir.path().join(".git")).unwrap();
3218 std::fs::write(
3219 dir.path().join(".fallowrc.json"),
3220 r#"{"entry": ["src/main.ts"]}"#,
3221 )
3222 .unwrap();
3223
3224 let path = FallowConfig::find_config_path(dir.path());
3225 assert!(path.is_some());
3226 assert!(path.unwrap().ends_with(".fallowrc.json"));
3227 }
3228
3229 #[test]
3230 fn find_config_path_returns_fallow_toml() {
3231 let dir = test_dir("find-path-toml");
3232 std::fs::create_dir(dir.path().join(".git")).unwrap();
3233 std::fs::write(
3234 dir.path().join("fallow.toml"),
3235 "entry = [\"src/main.ts\"]\n",
3236 )
3237 .unwrap();
3238
3239 let path = FallowConfig::find_config_path(dir.path());
3240 assert!(path.is_some());
3241 assert!(path.unwrap().ends_with("fallow.toml"));
3242 }
3243
3244 #[test]
3245 fn find_config_path_returns_dot_fallow_toml() {
3246 let dir = test_dir("find-path-dot-toml");
3247 std::fs::create_dir(dir.path().join(".git")).unwrap();
3248 std::fs::write(
3249 dir.path().join(".fallow.toml"),
3250 "entry = [\"src/main.ts\"]\n",
3251 )
3252 .unwrap();
3253
3254 let path = FallowConfig::find_config_path(dir.path());
3255 assert!(path.is_some());
3256 assert!(path.unwrap().ends_with(".fallow.toml"));
3257 }
3258
3259 #[test]
3260 fn find_config_path_prefers_json_over_toml() {
3261 let dir = test_dir("find-path-priority");
3262 std::fs::create_dir(dir.path().join(".git")).unwrap();
3263 std::fs::write(
3264 dir.path().join(".fallowrc.json"),
3265 r#"{"entry": ["json.ts"]}"#,
3266 )
3267 .unwrap();
3268 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3269
3270 let path = FallowConfig::find_config_path(dir.path());
3271 assert!(path.unwrap().ends_with(".fallowrc.json"));
3272 }
3273
3274 #[test]
3275 fn find_config_path_none_when_no_config() {
3276 let dir = test_dir("find-path-none");
3277 std::fs::create_dir(dir.path().join(".git")).unwrap();
3278
3279 let path = FallowConfig::find_config_path(dir.path());
3280 assert!(path.is_none());
3281 }
3282
3283 #[test]
3284 fn find_config_path_walks_past_package_json_in_monorepo() {
3285 let dir = test_dir("find-path-monorepo");
3286 std::fs::create_dir(dir.path().join(".git")).unwrap();
3287 std::fs::write(
3288 dir.path().join(".fallowrc.json"),
3289 r#"{"entry": ["src/index.ts"]}"#,
3290 )
3291 .unwrap();
3292
3293 let sub = dir.path().join("packages").join("app");
3294 std::fs::create_dir_all(&sub).unwrap();
3295 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3296
3297 let path = FallowConfig::find_config_path(&sub).unwrap();
3298 assert_eq!(path, dir.path().join(".fallowrc.json"));
3299 }
3300
3301 #[test]
3302 fn extends_toml_base() {
3303 let dir = test_dir("extends-toml");
3304
3305 std::fs::write(
3306 dir.path().join("base.json"),
3307 r#"{"rules": {"unused-files": "warn"}}"#,
3308 )
3309 .unwrap();
3310 std::fs::write(
3311 dir.path().join("fallow.toml"),
3312 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3313 )
3314 .unwrap();
3315
3316 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3317 assert_eq!(config.rules.unused_files, Severity::Warn);
3318 assert_eq!(config.entry, vec!["src/index.ts"]);
3319 }
3320
3321 #[test]
3322 fn deep_merge_boolean_overlay() {
3323 let mut base = serde_json::json!(true);
3324 deep_merge_json(&mut base, serde_json::json!(false));
3325 assert_eq!(base, serde_json::json!(false));
3326 }
3327
3328 #[test]
3329 fn deep_merge_number_overlay() {
3330 let mut base = serde_json::json!(42);
3331 deep_merge_json(&mut base, serde_json::json!(99));
3332 assert_eq!(base, serde_json::json!(99));
3333 }
3334
3335 #[test]
3336 fn deep_merge_disjoint_objects() {
3337 let mut base = serde_json::json!({"a": 1});
3338 let overlay = serde_json::json!({"b": 2});
3339 deep_merge_json(&mut base, overlay);
3340 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3341 }
3342
3343 #[test]
3344 fn max_extends_depth_is_reasonable() {
3345 assert_eq!(MAX_EXTENDS_DEPTH, 10);
3346 }
3347
3348 #[test]
3349 fn config_names_has_four_entries() {
3350 assert_eq!(CONFIG_NAMES.len(), 4);
3351 for name in CONFIG_NAMES {
3352 assert!(
3353 name.starts_with('.') || name.starts_with("fallow"),
3354 "unexpected config name: {name}"
3355 );
3356 }
3357 }
3358
3359 #[test]
3360 fn package_json_peer_dependency_names() {
3361 let pkg: PackageJson = serde_json::from_str(
3362 r#"{
3363 "dependencies": {"react": "^18"},
3364 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3365 }"#,
3366 )
3367 .unwrap();
3368 let all = pkg.all_dependency_names();
3369 assert!(all.contains(&"react".to_string()));
3370 assert!(all.contains(&"react-dom".to_string()));
3371 assert!(all.contains(&"react-native".to_string()));
3372 }
3373
3374 #[test]
3375 fn package_json_scripts_field() {
3376 let pkg: PackageJson = serde_json::from_str(
3377 r#"{
3378 "scripts": {
3379 "build": "tsc",
3380 "test": "vitest",
3381 "lint": "fallow check"
3382 }
3383 }"#,
3384 )
3385 .unwrap();
3386 let scripts = pkg.scripts.unwrap();
3387 assert_eq!(scripts.len(), 3);
3388 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3389 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3390 }
3391
3392 #[test]
3393 fn extends_toml_chain() {
3394 let dir = test_dir("extends-toml-chain");
3395
3396 std::fs::write(
3397 dir.path().join("base.json"),
3398 r#"{"entry": ["src/base.ts"]}"#,
3399 )
3400 .unwrap();
3401 std::fs::write(
3402 dir.path().join("middle.json"),
3403 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3404 )
3405 .unwrap();
3406 std::fs::write(
3407 dir.path().join("fallow.toml"),
3408 "extends = [\"middle.json\"]\n",
3409 )
3410 .unwrap();
3411
3412 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3413 assert_eq!(config.entry, vec!["src/base.ts"]);
3414 assert_eq!(config.rules.unused_files, Severity::Off);
3415 }
3416
3417 #[test]
3418 fn find_and_load_walks_up_directories() {
3419 let dir = test_dir("find-walk-up");
3420 let sub = dir.path().join("src").join("deep");
3421 std::fs::create_dir_all(&sub).unwrap();
3422 std::fs::write(
3423 dir.path().join(".fallowrc.json"),
3424 r#"{"entry": ["src/main.ts"]}"#,
3425 )
3426 .unwrap();
3427 std::fs::create_dir(dir.path().join(".git")).unwrap();
3428
3429 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3430 assert_eq!(config.entry, vec!["src/main.ts"]);
3431 assert!(path.ends_with(".fallowrc.json"));
3432 }
3433
3434 #[test]
3435 fn json_schema_contains_entry_field() {
3436 let schema = FallowConfig::json_schema();
3437 let obj = schema.as_object().unwrap();
3438 let props = obj.get("properties").and_then(|v| v.as_object());
3439 assert!(props.is_some(), "schema should have properties");
3440 assert!(
3441 props.unwrap().contains_key("entry"),
3442 "schema should contain entry property"
3443 );
3444 }
3445
3446 #[test]
3447 fn fallow_config_json_duplicates_all_fields() {
3448 let json = r#"{
3449 "duplicates": {
3450 "enabled": true,
3451 "mode": "semantic",
3452 "minTokens": 200,
3453 "minLines": 20,
3454 "threshold": 10.5,
3455 "ignore": ["**/*.test.ts"],
3456 "skipLocal": true,
3457 "crossLanguage": true,
3458 "normalization": {
3459 "ignoreIdentifiers": true,
3460 "ignoreStringValues": false
3461 }
3462 }
3463 }"#;
3464 let config: FallowConfig = serde_json::from_str(json).unwrap();
3465 assert!(config.duplicates.enabled);
3466 assert_eq!(
3467 config.duplicates.mode,
3468 crate::config::DetectionMode::Semantic
3469 );
3470 assert_eq!(config.duplicates.min_tokens, 200);
3471 assert_eq!(config.duplicates.min_lines, 20);
3472 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3473 assert!(config.duplicates.skip_local);
3474 assert!(config.duplicates.cross_language);
3475 assert_eq!(
3476 config.duplicates.normalization.ignore_identifiers,
3477 Some(true)
3478 );
3479 assert_eq!(
3480 config.duplicates.normalization.ignore_string_values,
3481 Some(false)
3482 );
3483 }
3484
3485 #[test]
3486 fn normalize_url_basic() {
3487 assert_eq!(
3488 normalize_url_for_dedup("https://example.com/config.json"),
3489 "https://example.com/config.json"
3490 );
3491 }
3492
3493 #[test]
3494 fn normalize_url_trailing_slash() {
3495 assert_eq!(
3496 normalize_url_for_dedup("https://example.com/config/"),
3497 "https://example.com/config"
3498 );
3499 }
3500
3501 #[test]
3502 fn normalize_url_uppercase_scheme_and_host() {
3503 assert_eq!(
3504 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3505 "https://example.com/Config.json"
3506 );
3507 }
3508
3509 #[test]
3510 fn normalize_url_root_path() {
3511 assert_eq!(
3512 normalize_url_for_dedup("https://example.com/"),
3513 "https://example.com"
3514 );
3515 assert_eq!(
3516 normalize_url_for_dedup("https://example.com"),
3517 "https://example.com"
3518 );
3519 }
3520
3521 #[test]
3522 fn normalize_url_preserves_path_case() {
3523 assert_eq!(
3524 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3525 "https://github.com/Org/Repo/Fallow.json"
3526 );
3527 }
3528
3529 #[test]
3530 fn normalize_url_strips_query_string() {
3531 assert_eq!(
3532 normalize_url_for_dedup("https://example.com/config.json?v=1"),
3533 "https://example.com/config.json"
3534 );
3535 }
3536
3537 #[test]
3538 fn normalize_url_strips_fragment() {
3539 assert_eq!(
3540 normalize_url_for_dedup("https://example.com/config.json#section"),
3541 "https://example.com/config.json"
3542 );
3543 }
3544
3545 #[test]
3546 fn normalize_url_strips_query_and_fragment() {
3547 assert_eq!(
3548 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3549 "https://example.com/config.json"
3550 );
3551 }
3552
3553 #[test]
3554 fn normalize_url_default_https_port() {
3555 assert_eq!(
3556 normalize_url_for_dedup("https://example.com:443/config.json"),
3557 "https://example.com/config.json"
3558 );
3559 assert_eq!(
3560 normalize_url_for_dedup("https://example.com:8443/config.json"),
3561 "https://example.com:8443/config.json"
3562 );
3563 }
3564
3565 #[test]
3566 fn extends_http_rejected() {
3567 let dir = test_dir("http-rejected");
3568 std::fs::write(
3569 dir.path().join(".fallowrc.json"),
3570 r#"{"extends": "http://example.com/config.json"}"#,
3571 )
3572 .unwrap();
3573
3574 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3575 assert!(result.is_err());
3576 let err_msg = format!("{}", result.unwrap_err());
3577 assert!(
3578 err_msg.contains("https://"),
3579 "Expected https hint in error, got: {err_msg}"
3580 );
3581 assert!(
3582 err_msg.contains("http://"),
3583 "Expected http:// mention in error, got: {err_msg}"
3584 );
3585 }
3586
3587 #[test]
3588 fn extends_url_circular_detection() {
3589 let mut visited = FxHashSet::default();
3590 let url = "https://example.com/config.json";
3591 let normalized = normalize_url_for_dedup(url);
3592 visited.insert(normalized.clone());
3593
3594 assert!(
3595 !visited.insert(normalized),
3596 "Same URL should be detected as duplicate"
3597 );
3598 }
3599
3600 #[test]
3601 fn extends_url_circular_case_insensitive() {
3602 let mut visited = FxHashSet::default();
3603 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3604
3605 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3606 assert!(
3607 !visited.insert(normalized),
3608 "Case-different URLs should normalize to the same key"
3609 );
3610 }
3611
3612 #[test]
3613 fn extract_extends_array() {
3614 let mut value = serde_json::json!({
3615 "extends": ["a.json", "b.json"],
3616 "entry": ["src/index.ts"]
3617 });
3618 let extends = extract_extends(&mut value);
3619 assert_eq!(extends, vec!["a.json", "b.json"]);
3620 assert!(value.get("extends").is_none());
3621 assert!(value.get("entry").is_some());
3622 }
3623
3624 #[test]
3625 fn extract_extends_string_sugar() {
3626 let mut value = serde_json::json!({
3627 "extends": "base.json",
3628 "entry": ["src/index.ts"]
3629 });
3630 let extends = extract_extends(&mut value);
3631 assert_eq!(extends, vec!["base.json"]);
3632 }
3633
3634 #[test]
3635 fn extract_extends_none() {
3636 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3637 let extends = extract_extends(&mut value);
3638 assert!(extends.is_empty());
3639 }
3640
3641 #[test]
3642 fn url_timeout_default() {
3643 let timeout = url_timeout();
3644 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3645 }
3646
3647 #[test]
3648 fn extends_url_mixed_with_file_and_npm() {
3649 let dir = test_dir("url-mixed");
3650 std::fs::write(
3651 dir.path().join("local.json"),
3652 r#"{"rules": {"unused-files": "warn"}}"#,
3653 )
3654 .unwrap();
3655 std::fs::write(
3656 dir.path().join(".fallowrc.json"),
3657 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3658 )
3659 .unwrap();
3660
3661 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3662 assert!(result.is_err());
3663 let err_msg = format!("{}", result.unwrap_err());
3664 assert!(
3665 err_msg.contains("unreachable.invalid"),
3666 "Expected URL in error message, got: {err_msg}"
3667 );
3668 }
3669
3670 #[test]
3671 fn extends_https_url_unreachable_errors() {
3672 let dir = test_dir("url-unreachable");
3673 std::fs::write(
3674 dir.path().join(".fallowrc.json"),
3675 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3676 )
3677 .unwrap();
3678
3679 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3680 assert!(result.is_err());
3681 let err_msg = format!("{}", result.unwrap_err());
3682 assert!(
3683 err_msg.contains("unreachable.invalid"),
3684 "Expected URL in error, got: {err_msg}"
3685 );
3686 assert!(
3687 err_msg.contains("local path or npm:"),
3688 "Expected remediation hint, got: {err_msg}"
3689 );
3690 }
3691
3692 #[test]
3693 fn collect_unknown_rule_keys_flags_top_level_typo() {
3694 let merged = serde_json::json!({
3695 "rules": {
3696 "unsued-files": "warn",
3697 "unused-exports": "off"
3698 }
3699 });
3700 let findings = collect_unknown_rule_keys(&merged);
3701 assert_eq!(findings.len(), 1);
3702 assert_eq!(findings[0].context, "rules");
3703 assert_eq!(findings[0].key, "unsued-files");
3704 assert_eq!(findings[0].suggestion, Some("unused-files"));
3705 }
3706
3707 #[test]
3708 fn collect_unknown_rule_keys_flags_overrides_typo() {
3709 let merged = serde_json::json!({
3710 "overrides": [
3711 {
3712 "files": ["src/**/*.ts"],
3713 "rules": {
3714 "unsued-files": "warn"
3715 }
3716 },
3717 {
3718 "files": ["tests/**/*.ts"],
3719 "rules": {
3720 "circular-dependnecy": "off"
3721 }
3722 }
3723 ]
3724 });
3725 let findings = collect_unknown_rule_keys(&merged);
3726 assert_eq!(findings.len(), 2);
3727 assert_eq!(findings[0].context, "overrides[0].rules");
3728 assert_eq!(findings[1].context, "overrides[1].rules");
3729 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3730 }
3731
3732 #[test]
3733 fn collect_unknown_rule_keys_empty_for_valid_config() {
3734 let merged = serde_json::json!({
3735 "rules": {
3736 "unused-files": "warn",
3737 "unused-file": "off",
3738 "circular-dependency": "off",
3739 "boundary-violations": "warn"
3740 },
3741 "overrides": [
3742 {
3743 "files": ["src/**"],
3744 "rules": {
3745 "unused-exports": "warn"
3746 }
3747 }
3748 ]
3749 });
3750 let findings = collect_unknown_rule_keys(&merged);
3751 assert!(
3752 findings.is_empty(),
3753 "valid rule names and aliases must not be flagged: {findings:?}"
3754 );
3755 }
3756
3757 #[test]
3758 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3759 let merged = serde_json::json!({
3760 "entry": ["src/main.ts"]
3761 });
3762 let findings = collect_unknown_rule_keys(&merged);
3763 assert!(findings.is_empty());
3764 }
3765
3766 #[test]
3767 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3768 let dir = test_dir("wiring");
3769 let path = dir.path().join(".fallowrc.json");
3770 let typo = format!(
3771 "wiring-probe-{}-{}",
3772 std::process::id(),
3773 std::time::SystemTime::now()
3774 .duration_since(std::time::UNIX_EPOCH)
3775 .map_or(0, |d| d.as_nanos())
3776 );
3777 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3778
3779 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3780
3781 assert!(
3782 config_res.is_ok(),
3783 "load should succeed in phase 1: {:?}",
3784 config_res.err()
3785 );
3786 assert_eq!(
3787 captured.len(),
3788 1,
3789 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3790 );
3791 assert_eq!(captured[0].key, typo);
3792 assert_eq!(captured[0].context, "rules");
3793 }
3794
3795 #[test]
3796 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3797 let dir = test_dir("misspelled-rule");
3798 std::fs::write(
3799 dir.path().join(".fallowrc.json"),
3800 r#"{"rules": {"unsued-files": "warn"}}"#,
3801 )
3802 .unwrap();
3803
3804 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3805 .expect("load should succeed in phase 1");
3806
3807 assert_eq!(config.rules.unused_files, Severity::Error);
3808 }
3809
3810 #[test]
3811 fn validate_resolved_boundaries_passes_on_valid_config() {
3812 let dir = test_dir("boundaries-valid");
3813 let config = FallowConfig {
3814 boundaries: crate::BoundaryConfig {
3815 coverage: crate::BoundaryCoverageConfig::default(),
3816 calls: crate::BoundaryCallsConfig::default(),
3817 preset: None,
3818 zones: vec![
3819 crate::BoundaryZone {
3820 name: "ui".to_string(),
3821 patterns: vec!["src/components/**".to_string()],
3822 auto_discover: vec![],
3823 root: None,
3824 },
3825 crate::BoundaryZone {
3826 name: "db".to_string(),
3827 patterns: vec!["src/db/**".to_string()],
3828 auto_discover: vec![],
3829 root: None,
3830 },
3831 ],
3832 rules: vec![crate::BoundaryRule {
3833 from: "ui".to_string(),
3834 allow: vec!["db".to_string()],
3835 allow_type_only: vec![],
3836 }],
3837 },
3838 ..FallowConfig::default()
3839 };
3840 config
3841 .validate_resolved_boundaries(dir.path())
3842 .expect("valid config should pass");
3843 }
3844
3845 #[test]
3846 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3847 let dir = test_dir("boundaries-unknown-zones");
3848 let config = FallowConfig {
3849 boundaries: crate::BoundaryConfig {
3850 coverage: crate::BoundaryCoverageConfig::default(),
3851 calls: crate::BoundaryCallsConfig::default(),
3852 preset: None,
3853 zones: vec![crate::BoundaryZone {
3854 name: "ui".to_string(),
3855 patterns: vec!["src/ui/**".to_string()],
3856 auto_discover: vec![],
3857 root: None,
3858 }],
3859 rules: vec![
3860 crate::BoundaryRule {
3861 from: "typo-from".to_string(),
3862 allow: vec!["typo-allow".to_string()],
3863 allow_type_only: vec!["typo-type-only".to_string()],
3864 },
3865 crate::BoundaryRule {
3866 from: "ui".to_string(),
3867 allow: vec!["another-typo".to_string()],
3868 allow_type_only: vec![],
3869 },
3870 ],
3871 },
3872 ..FallowConfig::default()
3873 };
3874
3875 let errors = config
3876 .validate_resolved_boundaries(dir.path())
3877 .expect_err("invalid zone refs should fail");
3878
3879 assert_eq!(errors.len(), 4, "got: {errors:?}");
3880
3881 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3882 assert!(
3883 rendered
3884 .iter()
3885 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3886 );
3887 assert!(
3888 rendered
3889 .iter()
3890 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3891 );
3892 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3893 && m.contains("rules[0]")
3894 && m.contains("allowTypeOnly")));
3895 assert!(
3896 rendered.iter().any(|m| m.contains("another-typo")
3897 && m.contains("rules[1]")
3898 && m.contains("allow"))
3899 );
3900 }
3901
3902 #[test]
3903 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3904 let dir = test_dir("boundaries-redundant-prefix");
3905 let config = FallowConfig {
3906 boundaries: crate::BoundaryConfig {
3907 coverage: crate::BoundaryCoverageConfig::default(),
3908 calls: crate::BoundaryCallsConfig::default(),
3909 preset: None,
3910 zones: vec![crate::BoundaryZone {
3911 name: "ui".to_string(),
3912 patterns: vec!["packages/app/src/**".to_string()],
3913 auto_discover: vec![],
3914 root: Some("packages/app/".to_string()),
3915 }],
3916 rules: vec![],
3917 },
3918 ..FallowConfig::default()
3919 };
3920
3921 let errors = config
3922 .validate_resolved_boundaries(dir.path())
3923 .expect_err("redundant root prefix should fail");
3924 assert_eq!(errors.len(), 1, "got: {errors:?}");
3925 let rendered = errors[0].to_string();
3926 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3927 assert!(rendered.contains("zone 'ui'"));
3928 }
3929
3930 #[test]
3931 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3932 let dir = test_dir("boundaries-mixed-errors");
3933 let config = FallowConfig {
3934 boundaries: crate::BoundaryConfig {
3935 coverage: crate::BoundaryCoverageConfig::default(),
3936 calls: crate::BoundaryCallsConfig::default(),
3937 preset: None,
3938 zones: vec![crate::BoundaryZone {
3939 name: "ui".to_string(),
3940 patterns: vec!["packages/app/src/**".to_string()],
3941 auto_discover: vec![],
3942 root: Some("packages/app/".to_string()),
3943 }],
3944 rules: vec![crate::BoundaryRule {
3945 from: "ui".to_string(),
3946 allow: vec!["typo-zone".to_string()],
3947 allow_type_only: vec![],
3948 }],
3949 },
3950 ..FallowConfig::default()
3951 };
3952 let errors = config
3953 .validate_resolved_boundaries(dir.path())
3954 .expect_err("mixed errors should fail");
3955 assert_eq!(errors.len(), 2, "got: {errors:?}");
3956 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3957 assert!(
3958 rendered
3959 .iter()
3960 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3961 );
3962 assert!(
3963 rendered
3964 .iter()
3965 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3966 );
3967 }
3968
3969 #[test]
3970 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3971 let dir = test_dir("boundaries-bulletproof");
3972 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3973 let config = FallowConfig {
3974 boundaries: crate::BoundaryConfig {
3975 coverage: crate::BoundaryCoverageConfig::default(),
3976 calls: crate::BoundaryCallsConfig::default(),
3977 preset: Some(crate::BoundaryPreset::Bulletproof),
3978 zones: vec![],
3979 rules: vec![],
3980 },
3981 ..FallowConfig::default()
3982 };
3983 config
3984 .validate_resolved_boundaries(dir.path())
3985 .expect("Bulletproof with discoverable features should pass");
3986 }
3987
3988 #[test]
3993 #[cfg_attr(miri, ignore)]
3994 fn parse_config_to_value_strips_utf8_bom() {
3995 let dir = test_dir("parse-bom");
3996 let path = dir.path().join("fallow.toml");
3997 let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
3999 std::fs::write(&path, content_with_bom).unwrap();
4000
4001 let value = parse_config_to_value(&path).unwrap();
4002 assert!(
4003 value.get("entry").is_some(),
4004 "BOM should be stripped before TOML parsing"
4005 );
4006 }
4007
4008 #[test]
4009 #[cfg_attr(miri, ignore)]
4010 fn parse_config_to_value_toml_parse_error() {
4011 let dir = test_dir("parse-toml-error");
4012 let path = dir.path().join("fallow.toml");
4013 std::fs::write(&path, "entry = [unquoted\n").unwrap();
4014
4015 let result = parse_config_to_value(&path);
4016 assert!(result.is_err());
4017 let err = result.unwrap_err().to_string();
4018 assert!(
4019 err.contains("Failed to parse config file"),
4020 "error should mention parse failure: {err}"
4021 );
4022 }
4023
4024 #[test]
4025 #[cfg_attr(miri, ignore)]
4026 fn parse_config_to_value_json_parse_error() {
4027 let dir = test_dir("parse-json-error");
4028 let path = dir.path().join(".fallowrc.json");
4029 std::fs::write(&path, "{ this is not json }").unwrap();
4030
4031 let result = parse_config_to_value(&path);
4032 assert!(result.is_err());
4033 let err = result.unwrap_err().to_string();
4034 assert!(
4035 err.contains("Failed to parse config file"),
4036 "error should mention parse failure: {err}"
4037 );
4038 }
4039
4040 #[test]
4041 #[cfg_attr(miri, ignore)]
4042 fn parse_config_to_value_missing_file_error() {
4043 let dir = test_dir("parse-missing");
4044 let path = dir.path().join("nonexistent.toml");
4045
4046 let result = parse_config_to_value(&path);
4047 assert!(result.is_err());
4048 let err = result.unwrap_err().to_string();
4049 assert!(
4050 err.contains("Failed to read config file"),
4051 "error should mention read failure: {err}"
4052 );
4053 }
4054
4055 #[test]
4060 #[cfg_attr(miri, ignore)]
4061 fn find_and_load_stops_at_svn_dir() {
4062 let dir = test_dir("find-svn-stop");
4063 let sub = dir.path().join("sub");
4064 std::fs::create_dir(&sub).unwrap();
4065 std::fs::create_dir(dir.path().join(".svn")).unwrap();
4066
4067 let result = FallowConfig::find_and_load(&sub).unwrap();
4068 assert!(result.is_none(), "svn boundary should stop config walk");
4069 }
4070
4071 #[test]
4077 #[cfg_attr(miri, ignore)]
4078 fn extends_npm_single_dot_package_name_rejected() {
4079 let dir = test_dir("npm-dot-name");
4080 std::fs::write(
4081 dir.path().join(".fallowrc.json"),
4082 r#"{"extends": "npm:./relative"}"#,
4083 )
4084 .unwrap();
4085
4086 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4087 assert!(result.is_err());
4088 let err = result.unwrap_err().to_string();
4089 assert!(
4090 err.contains("path traversal"),
4091 "single-dot component should be rejected as path traversal: {err}"
4092 );
4093 }
4094
4095 #[test]
4101 #[cfg_attr(miri, ignore)]
4102 fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4103 let dir = test_dir("npm-main-missing");
4104 let pkg_dir = dir.path().join("node_modules/my-config");
4105 std::fs::create_dir_all(&pkg_dir).unwrap();
4106 std::fs::write(
4108 pkg_dir.join("package.json"),
4109 r#"{"name": "my-config", "main": "./missing.json"}"#,
4110 )
4111 .unwrap();
4112 std::fs::write(
4114 pkg_dir.join(".fallowrc.json"),
4115 r#"{"rules": {"unused-files": "warn"}}"#,
4116 )
4117 .unwrap();
4118
4119 std::fs::write(
4120 dir.path().join(".fallowrc.json"),
4121 r#"{"extends": "npm:my-config"}"#,
4122 )
4123 .unwrap();
4124
4125 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4126 assert_eq!(config.rules.unused_files, Severity::Warn);
4127 }
4128
4129 #[test]
4135 #[cfg_attr(miri, ignore)]
4136 fn extends_npm_exports_nonexistent_falls_through_to_main() {
4137 let dir = test_dir("npm-exports-missing-file");
4138 let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4139 std::fs::create_dir_all(&pkg_dir).unwrap();
4140 std::fs::write(
4142 pkg_dir.join("package.json"),
4143 r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4144 )
4145 .unwrap();
4146 std::fs::write(
4147 pkg_dir.join("real.json"),
4148 r#"{"rules": {"unused-types": "off"}}"#,
4149 )
4150 .unwrap();
4151
4152 std::fs::write(
4153 dir.path().join(".fallowrc.json"),
4154 r#"{"extends": "npm:cfg-pkg"}"#,
4155 )
4156 .unwrap();
4157
4158 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4159 assert_eq!(config.rules.unused_types, Severity::Off);
4160 }
4161
4162 #[test]
4167 fn normalize_url_no_scheme_returns_raw() {
4168 assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
4170 assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
4171 }
4172
4173 #[test]
4178 fn normalize_url_fragment_only_stripped() {
4179 assert_eq!(
4181 normalize_url_for_dedup("https://example.com/file.json#anchor"),
4182 "https://example.com/file.json"
4183 );
4184 }
4185
4186 #[test]
4194 fn url_timeout_uses_env_var_when_set() {
4195 assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
4196 }
4197
4198 #[test]
4199 fn url_timeout_zero_falls_back_to_default() {
4200 assert_eq!(
4201 url_timeout_from(Some("0")),
4202 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4203 "zero should fall back to the hardcoded default"
4204 );
4205 }
4206
4207 #[test]
4208 fn url_timeout_non_numeric_falls_back_to_default() {
4209 assert_eq!(
4210 url_timeout_from(Some("not-a-number")),
4211 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4212 "non-numeric value should fall back to the hardcoded default"
4213 );
4214 }
4215
4216 #[test]
4217 fn url_timeout_absent_uses_default() {
4218 assert_eq!(
4219 url_timeout_from(None),
4220 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
4221 );
4222 }
4223
4224 #[test]
4229 fn resolve_url_extends_depth_limit_error() {
4230 let mut visited = FxHashSet::default();
4231 let result = resolve_url_extends(
4232 "https://example.invalid/config.json",
4233 &mut visited,
4234 MAX_EXTENDS_DEPTH, );
4236 assert!(result.is_err());
4237 let err = result.unwrap_err().to_string();
4238 assert!(
4239 err.contains("too deep"),
4240 "error should mention depth limit: {err}"
4241 );
4242 }
4243
4244 #[test]
4249 #[cfg_attr(miri, ignore)]
4250 fn resolve_extends_file_depth_limit_error() {
4251 let dir = test_dir("extends-file-depth");
4252 let path = dir.path().join(".fallowrc.json");
4253 std::fs::write(&path, r#"{"entry": []}"#).unwrap();
4254
4255 let mut visited = FxHashSet::default();
4256 let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
4257 assert!(result.is_err());
4258 let err = result.unwrap_err().to_string();
4259 assert!(
4260 err.contains("too deep"),
4261 "error should mention depth limit: {err}"
4262 );
4263 }
4264
4265 #[test]
4270 #[cfg_attr(miri, ignore)]
4271 fn extends_http_url_in_file_extends_rejected() {
4272 let dir = test_dir("file-extends-http");
4273 std::fs::write(
4274 dir.path().join(".fallowrc.json"),
4275 r#"{"extends": ["http://example.com/config.json"]}"#,
4276 )
4277 .unwrap();
4278
4279 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4280 assert!(result.is_err());
4281 let err = result.unwrap_err().to_string();
4282 assert!(
4283 err.contains("https://"),
4284 "error should suggest https: {err}"
4285 );
4286 }
4287
4288 #[test]
4293 #[cfg_attr(miri, ignore)]
4294 fn sealed_config_dir_returns_some_when_sealed() {
4295 let dir = test_dir("sealed-dir");
4296 let result = sealed_config_dir(dir.path(), true);
4297 assert!(result.is_ok());
4298 assert!(
4299 result.unwrap().is_some(),
4300 "sealed=true must return Some(canonicalized path)"
4301 );
4302 }
4303
4304 #[test]
4305 fn sealed_config_dir_returns_none_when_not_sealed() {
4306 let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
4307 assert!(result.is_ok());
4308 assert!(result.unwrap().is_none(), "sealed=false must return None");
4309 }
4310
4311 #[test]
4317 fn collect_unknown_rule_keys_override_without_rules_key() {
4318 let merged = serde_json::json!({
4319 "overrides": [
4320 {
4321 "files": ["src/**/*.ts"]
4322 },
4324 {
4325 "files": ["tests/**"],
4326 "rules": {
4327 "unsued-exports": "off"
4328 }
4329 }
4330 ]
4331 });
4332 let findings = collect_unknown_rule_keys(&merged);
4333 assert_eq!(
4334 findings.len(),
4335 1,
4336 "only the entry with rules should produce a finding"
4337 );
4338 assert_eq!(findings[0].context, "overrides[1].rules");
4339 }
4340
4341 #[test]
4346 #[cfg_attr(miri, ignore)]
4347 fn load_fails_on_deserialization_error() {
4348 let dir = test_dir("deser-error");
4349 let path = dir.path().join(".fallowrc.json");
4350 std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
4352
4353 let result = FallowConfig::load(&path);
4354 assert!(result.is_err());
4355 let err = result.unwrap_err().to_string();
4356 assert!(
4357 err.contains("Failed to deserialize"),
4358 "error should mention deserialization: {err}"
4359 );
4360 }
4361
4362 #[test]
4368 #[cfg_attr(miri, ignore)]
4369 fn load_rejects_threshold_override_with_empty_files() {
4370 let dir = test_dir("threshold-empty-files");
4371 let path = dir.path().join(".fallowrc.json");
4372 std::fs::write(
4373 &path,
4374 r#"{
4375 "health": {
4376 "thresholdOverrides": [
4377 {"files": [], "maxCyclomatic": 30}
4378 ]
4379 }
4380 }"#,
4381 )
4382 .unwrap();
4383
4384 let result = FallowConfig::load(&path);
4385 assert!(result.is_err());
4386 let err = result.unwrap_err().to_string();
4387 assert!(
4388 err.contains("thresholdOverrides"),
4389 "error should mention thresholdOverrides: {err}"
4390 );
4391 assert!(
4392 err.contains("files"),
4393 "error should name the files field: {err}"
4394 );
4395 }
4396
4397 #[test]
4398 #[cfg_attr(miri, ignore)]
4399 fn load_rejects_threshold_override_with_no_threshold_set() {
4400 let dir = test_dir("threshold-no-threshold");
4401 let path = dir.path().join(".fallowrc.json");
4402 std::fs::write(
4403 &path,
4404 r#"{
4405 "health": {
4406 "thresholdOverrides": [
4407 {"files": ["src/legacy.ts"]}
4408 ]
4409 }
4410 }"#,
4411 )
4412 .unwrap();
4413
4414 let result = FallowConfig::load(&path);
4415 assert!(result.is_err());
4416 let err = result.unwrap_err().to_string();
4417 assert!(
4418 err.contains("maxCyclomatic")
4419 || err.contains("maxCognitive")
4420 || err.contains("maxCrap"),
4421 "error should name at least one threshold field: {err}"
4422 );
4423 }
4424
4425 #[test]
4431 #[cfg_attr(miri, ignore)]
4432 fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
4433 let dir = test_dir("invalid-catalog-consumer-glob");
4434 let path = dir.path().join(".fallowrc.json");
4435 std::fs::write(
4436 &path,
4437 r#"{
4438 "ignoreCatalogReferences": [
4439 {"package": "react", "consumer": "[invalid-glob"}
4440 ]
4441 }"#,
4442 )
4443 .unwrap();
4444
4445 let result = FallowConfig::load(&path);
4446 assert!(result.is_err());
4447 let err = result.unwrap_err().to_string();
4448 assert!(
4449 err.contains("ignoreCatalogReferences"),
4450 "error should mention the field: {err}"
4451 );
4452 }
4453
4454 #[test]
4455 #[cfg_attr(miri, ignore)]
4456 fn load_accepts_ignore_catalog_references_without_consumer() {
4457 let dir = test_dir("catalog-ref-no-consumer");
4458 let path = dir.path().join(".fallowrc.json");
4459 std::fs::write(
4460 &path,
4461 r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
4462 )
4463 .unwrap();
4464
4465 let config = FallowConfig::load(&path).unwrap();
4466 assert_eq!(config.ignore_catalog_references.len(), 1);
4467 assert!(config.ignore_catalog_references[0].consumer.is_none());
4468 }
4469
4470 #[test]
4477 #[cfg_attr(miri, ignore)]
4478 fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
4479 let dir = test_dir("boundaries-preset-no-tsconfig");
4482 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4483 let config = FallowConfig {
4484 boundaries: crate::BoundaryConfig {
4485 coverage: crate::BoundaryCoverageConfig::default(),
4486 calls: crate::BoundaryCallsConfig::default(),
4487 preset: Some(crate::BoundaryPreset::Bulletproof),
4488 zones: vec![],
4489 rules: vec![],
4490 },
4491 ..FallowConfig::default()
4492 };
4493 let _ = config.validate_resolved_boundaries(dir.path());
4495 }
4496
4497 #[test]
4503 fn validate_user_globs_framework_plugin_invalid_entry_glob() {
4504 use crate::ExternalPluginDef;
4505 use crate::external_plugin::EntryPointRole;
4506 let config = FallowConfig {
4507 framework: vec![ExternalPluginDef {
4508 schema: None,
4509 name: "test-plugin".to_owned(),
4510 detection: None,
4511 enablers: vec![],
4512 entry_points: vec!["[invalid-glob".to_owned()],
4513 entry_point_role: EntryPointRole::Support,
4514 config_patterns: vec![],
4515 always_used: vec![],
4516 tooling_dependencies: vec![],
4517 used_exports: vec![],
4518 used_class_members: vec![],
4519 }],
4520 ..FallowConfig::default()
4521 };
4522
4523 let result = config.validate_user_globs();
4524 assert!(
4525 result.is_err(),
4526 "invalid entry_points glob should fail validation"
4527 );
4528 let errors = result.unwrap_err();
4529 assert!(!errors.is_empty());
4530 }
4531
4532 #[test]
4537 #[cfg_attr(miri, ignore)]
4538 fn shadowed_config_names_empty_when_last_config_wins() {
4539 let dir = test_dir("shadow-last");
4540 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
4541 assert!(shadowed_config_names(dir.path(), 3).is_empty());
4543 }
4544
4545 #[test]
4551 fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
4552 let ((), captured) = capture_coexisting_config_warnings(|| {
4553 warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
4554 });
4555 assert!(
4556 captured.is_empty(),
4557 "empty shadowed list must produce no warning"
4558 );
4559 }
4560
4561 #[test]
4566 fn extract_extends_array_filters_non_strings() {
4567 let mut value = serde_json::json!({
4568 "extends": ["a.json", 42, null, "b.json", true]
4569 });
4570 let extends = extract_extends(&mut value);
4571 assert_eq!(extends, vec!["a.json", "b.json"]);
4572 }
4573
4574 #[test]
4580 #[cfg_attr(miri, ignore)]
4581 fn record_extends_visit_circular_same_file() {
4582 let dir = test_dir("visit-circular");
4583 let path = dir.path().join("config.json");
4584 std::fs::write(&path, "{}").unwrap();
4585
4586 let mut visited = FxHashSet::default();
4587 record_extends_visit(&path, &mut visited).unwrap();
4588 let result = record_extends_visit(&path, &mut visited);
4589 assert!(result.is_err());
4590 let err = result.unwrap_err().to_string();
4591 assert!(
4592 err.contains("Circular extends"),
4593 "second visit of same file must report circular: {err}"
4594 );
4595 }
4596
4597 #[test]
4602 #[cfg_attr(miri, ignore)]
4603 fn find_config_path_stops_at_svn_dir() {
4604 let dir = test_dir("find-path-svn");
4605 let sub = dir.path().join("sub");
4606 std::fs::create_dir(&sub).unwrap();
4607 std::fs::create_dir(dir.path().join(".svn")).unwrap();
4608
4609 let path = FallowConfig::find_config_path(&sub);
4610 assert!(path.is_none(), "svn root should stop config search");
4611 }
4612
4613 #[test]
4618 fn deep_merge_array_over_object_replaces() {
4619 let mut base = serde_json::json!({"key": "value"});
4620 deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
4621 assert_eq!(base, serde_json::json!(["a", "b"]));
4622 }
4623
4624 #[test]
4629 #[cfg_attr(miri, ignore)]
4630 fn find_and_load_returns_error_for_invalid_glob_in_config() {
4631 let dir = test_dir("find-invalid-glob");
4632 std::fs::create_dir(dir.path().join(".git")).unwrap();
4633 std::fs::write(
4634 dir.path().join(".fallowrc.json"),
4635 r#"{"entry": ["[invalid-glob"]}"#,
4636 )
4637 .unwrap();
4638
4639 let result = FallowConfig::find_and_load(dir.path());
4640 assert!(
4641 result.is_err(),
4642 "invalid glob should surface as an error from find_and_load"
4643 );
4644 }
4645
4646 #[test]
4652 fn resolve_package_exports_dot_key_array_returns_none() {
4653 let pkg = serde_json::json!({
4655 "exports": {".": ["array-value"]}
4656 });
4657 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4658 assert!(result.is_none(), "array dot-export should return None");
4659 }
4660
4661 #[test]
4662 fn resolve_package_exports_exports_is_array_returns_none() {
4663 let pkg = serde_json::json!({
4665 "exports": ["./index.js"]
4666 });
4667 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4668 assert!(result.is_none(), "array-form exports should return None");
4669 }
4670
4671 #[test]
4672 fn resolve_package_exports_object_no_dot_key_returns_none() {
4673 let pkg = serde_json::json!({
4675 "exports": {"./sub": "./sub.js"}
4676 });
4677 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4678 assert!(result.is_none(), "no dot key should return None");
4679 }
4680
4681 #[test]
4682 fn resolve_package_exports_conditions_without_known_key_returns_none() {
4683 let pkg = serde_json::json!({
4685 "exports": {".": {"browser": "./browser.js"}}
4686 });
4687 let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4688 assert!(result.is_none(), "unknown condition key should return None");
4689 }
4690
4691 #[test]
4696 #[cfg_attr(miri, ignore)]
4697 fn extends_npm_exports_import_condition() {
4698 let dir = test_dir("npm-import-cond");
4699 let pkg_dir = dir.path().join("node_modules/import-config");
4700 std::fs::create_dir_all(&pkg_dir).unwrap();
4701 std::fs::write(
4702 pkg_dir.join("package.json"),
4703 r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
4704 )
4705 .unwrap();
4706 std::fs::write(
4707 pkg_dir.join("esm.json"),
4708 r#"{"rules": {"unused-types": "warn"}}"#,
4709 )
4710 .unwrap();
4711
4712 std::fs::write(
4713 dir.path().join(".fallowrc.json"),
4714 r#"{"extends": "npm:import-config"}"#,
4715 )
4716 .unwrap();
4717
4718 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4719 assert_eq!(config.rules.unused_types, Severity::Warn);
4720 }
4721
4722 #[test]
4727 #[cfg_attr(miri, ignore)]
4728 fn extends_npm_exports_require_condition() {
4729 let dir = test_dir("npm-require-cond");
4730 let pkg_dir = dir.path().join("node_modules/require-config");
4731 std::fs::create_dir_all(&pkg_dir).unwrap();
4732 std::fs::write(
4733 pkg_dir.join("package.json"),
4734 r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
4735 )
4736 .unwrap();
4737 std::fs::write(
4738 pkg_dir.join("cjs.json"),
4739 r#"{"rules": {"unused-class-members": "warn"}}"#,
4740 )
4741 .unwrap();
4742
4743 std::fs::write(
4744 dir.path().join(".fallowrc.json"),
4745 r#"{"extends": "npm:require-config"}"#,
4746 )
4747 .unwrap();
4748
4749 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4750 assert_eq!(config.rules.unused_class_members, Severity::Warn);
4751 }
4752}