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 std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
317 .ok()
318 .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
319 .map_or(
320 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
321 Duration::from_secs,
322 )
323}
324
325const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
327
328fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
330 let timeout = url_timeout();
331 let agent = ureq::Agent::config_builder()
332 .timeout_global(Some(timeout))
333 .https_only(true)
334 .build()
335 .new_agent();
336
337 let mut response = agent.get(url).call().map_err(|e| {
338 miette::miette!(
339 "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
340 If this URL is unavailable, use a local path or npm: specifier instead"
341 )
342 })?;
343
344 let body = response
345 .body_mut()
346 .with_config()
347 .limit(MAX_URL_CONFIG_BYTES)
348 .read_to_string()
349 .map_err(|e| {
350 miette::miette!(
351 "Failed to read response body from {url} (referenced from {source}): {e}"
352 )
353 })?;
354
355 crate::jsonc::parse_to_value(&body).map_err(|e| {
356 miette::miette!(
357 "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
358 Only JSON/JSONC is supported for URL-sourced configs"
359 )
360 })
361}
362
363fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
365 value
366 .as_object_mut()
367 .and_then(|obj| obj.remove("extends"))
368 .and_then(|v| match v {
369 serde_json::Value::Array(arr) => Some(
370 arr.into_iter()
371 .filter_map(|v| v.as_str().map(String::from))
372 .collect::<Vec<_>>(),
373 ),
374 serde_json::Value::String(s) => Some(vec![s]),
375 _ => None,
376 })
377 .unwrap_or_default()
378}
379
380fn resolve_url_extends(
382 url: &str,
383 visited: &mut FxHashSet<String>,
384 depth: usize,
385) -> Result<serde_json::Value, miette::Report> {
386 if depth >= MAX_EXTENDS_DEPTH {
387 return Err(miette::miette!(
388 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
389 ));
390 }
391
392 let normalized = normalize_url_for_dedup(url);
393 if !visited.insert(normalized) {
394 return Err(miette::miette!(
395 "Circular extends detected: {url} was already visited in the extends chain"
396 ));
397 }
398
399 let mut value = fetch_url_config(url, url)?;
400 let extends = extract_extends(&mut value);
401
402 if extends.is_empty() {
403 return Ok(value);
404 }
405
406 let mut merged = serde_json::Value::Object(serde_json::Map::new());
407
408 for entry in &extends {
409 let base = if entry.starts_with(HTTPS_PREFIX) {
410 resolve_url_extends(entry, visited, depth + 1)?
411 } else if entry.starts_with(HTTP_PREFIX) {
412 return Err(miette::miette!(
413 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
414 Change the URL to use https:// instead",
415 entry,
416 url
417 ));
418 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
419 let cwd = std::env::current_dir().map_err(|e| {
420 miette::miette!(
421 "Cannot resolve npm: specifier from URL-sourced config: \
422 failed to determine current directory: {e}"
423 )
424 })?;
425 let path_placeholder = PathBuf::from(url);
426 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
427 resolve_extends_file(&npm_path, visited, depth + 1)?
428 } else {
429 return Err(miette::miette!(
430 "Relative paths in 'extends' are not supported when the base config was \
431 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
432 instead. Got: '{entry}'"
433 ));
434 };
435 deep_merge_json(&mut merged, base);
436 }
437
438 deep_merge_json(&mut merged, value);
439 Ok(merged)
440}
441
442fn resolve_extends_file(
444 path: &Path,
445 visited: &mut FxHashSet<String>,
446 depth: usize,
447) -> Result<serde_json::Value, miette::Report> {
448 if depth >= MAX_EXTENDS_DEPTH {
449 return Err(miette::miette!(
450 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
451 path.display()
452 ));
453 }
454
455 let canonical = dunce::canonicalize(path).map_err(|e| {
456 miette::miette!(
457 "Config file not found or unresolvable: {}: {}",
458 path.display(),
459 e
460 )
461 })?;
462
463 if !visited.insert(canonical.to_string_lossy().into_owned()) {
464 return Err(miette::miette!(
465 "Circular extends detected: {} was already visited in the extends chain",
466 path.display()
467 ));
468 }
469
470 let mut value = parse_config_to_value(path)?;
471 let extends = extract_extends(&mut value);
472
473 if extends.is_empty() {
474 return Ok(value);
475 }
476
477 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
478 let sealed = value
479 .get("sealed")
480 .and_then(serde_json::Value::as_bool)
481 .unwrap_or(false);
482 let sealed_dir_canonical = if sealed {
483 Some(dunce::canonicalize(config_dir).map_err(|e| {
484 miette::miette!(
485 "Sealed config directory '{}' could not be canonicalized: {e}",
486 config_dir.display()
487 )
488 })?)
489 } else {
490 None
491 };
492 let mut merged = serde_json::Value::Object(serde_json::Map::new());
493
494 for extend_path_str in &extends {
495 let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
496 if sealed {
497 return Err(miette::miette!(
498 "'sealed: true' config at {} rejects URL extends '{}'. \
499 Sealed configs only allow file-relative extends within \
500 the config's directory",
501 path.display(),
502 extend_path_str
503 ));
504 }
505 resolve_url_extends(extend_path_str, visited, depth + 1)?
506 } else if extend_path_str.starts_with(HTTP_PREFIX) {
507 return Err(miette::miette!(
508 "URL extends must use https://, got http:// URL '{}' (in {}). \
509 Change the URL to use https:// instead",
510 extend_path_str,
511 path.display()
512 ));
513 } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
514 if sealed {
515 return Err(miette::miette!(
516 "'sealed: true' config at {} rejects npm extends '{}'. \
517 Sealed configs only allow file-relative extends within \
518 the config's directory",
519 path.display(),
520 extend_path_str
521 ));
522 }
523 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
524 resolve_extends_file(&npm_path, visited, depth + 1)?
525 } else {
526 if is_absolute_path_any_platform(Path::new(extend_path_str)) {
527 return Err(miette::miette!(
528 "extends paths must be relative, got absolute path: {} (in {})",
529 extend_path_str,
530 path.display()
531 ));
532 }
533 let p = config_dir.join(extend_path_str);
534 if !p.exists() {
535 return Err(miette::miette!(
536 "Extended config file not found: {} (referenced from {})",
537 p.display(),
538 path.display()
539 ));
540 }
541 if let Some(dir_canonical) = &sealed_dir_canonical {
542 let p_canonical = dunce::canonicalize(&p).map_err(|e| {
543 miette::miette!(
544 "Sealed config extends path '{}' could not be canonicalized: {e}",
545 p.display()
546 )
547 })?;
548 if !p_canonical.starts_with(dir_canonical) {
549 return Err(miette::miette!(
550 "'sealed: true' config at {} rejects extends '{}' which resolves \
551 outside the config's directory ({}). Sealed configs only allow \
552 extends within the config's directory",
553 path.display(),
554 extend_path_str,
555 p_canonical.display()
556 ));
557 }
558 }
559 resolve_extends_file(&p, visited, depth + 1)?
560 };
561 deep_merge_json(&mut merged, base);
562 }
563
564 deep_merge_json(&mut merged, value);
565 Ok(merged)
566}
567
568pub(super) fn resolve_extends(
572 path: &Path,
573 visited: &mut FxHashSet<String>,
574 depth: usize,
575) -> Result<serde_json::Value, miette::Report> {
576 resolve_extends_file(path, visited, depth)
577}
578
579pub(super) fn collect_unknown_rule_keys(
594 merged: &serde_json::Value,
595) -> Vec<super::rules::UnknownRuleKey> {
596 use super::rules::find_unknown_rule_keys;
597
598 let mut findings = Vec::new();
599
600 if let Some(rules) = merged.get("rules") {
601 findings.extend(find_unknown_rule_keys(rules, "rules"));
602 }
603
604 if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
605 for (i, entry) in overrides.iter().enumerate() {
606 if let Some(rules) = entry.get("rules") {
607 let context = format!("overrides[{i}].rules");
608 findings.extend(find_unknown_rule_keys(rules, &context));
609 }
610 }
611 }
612
613 findings
614}
615
616thread_local! {
617 #[cfg(test)]
623 static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
624 const { std::cell::RefCell::new(None) };
625}
626
627#[cfg(test)]
631pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
632 body: F,
633) -> (R, Vec<super::rules::UnknownRuleKey>) {
634 UNKNOWN_RULE_CAPTURE.with(|cell| {
635 *cell.borrow_mut() = Some(Vec::new());
636 });
637 let result = body();
638 let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
639 (result, findings)
640}
641
642fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
653 use std::sync::{Mutex, OnceLock};
654
655 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
656 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
657
658 let path_display = config_path.display().to_string();
659
660 for finding in collect_unknown_rule_keys(merged) {
661 let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
662 if let Ok(mut set) = warned.lock()
663 && !set.insert(dedupe_key)
664 {
665 continue;
666 }
667
668 #[cfg(test)]
669 UNKNOWN_RULE_CAPTURE.with(|cell| {
670 if let Some(buf) = cell.borrow_mut().as_mut() {
671 buf.push(finding.clone());
672 }
673 });
674
675 if let Some(suggestion) = finding.suggestion {
676 tracing::warn!(
677 "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
678 the rule will be ignored. A future release will reject unknown rule names.",
679 key = finding.key,
680 context = finding.context,
681 path = path_display,
682 );
683 } else {
684 tracing::warn!(
685 "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
686 A future release will reject unknown rule names.",
687 key = finding.key,
688 context = finding.context,
689 path = path_display,
690 );
691 }
692 }
693}
694
695fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
702 CONFIG_NAMES
703 .iter()
704 .skip(chosen_index + 1)
705 .filter(|name| dir.join(name).exists())
706 .copied()
707 .collect()
708}
709
710#[cfg(test)]
713type CoexistWarning = (String, Vec<String>);
714
715thread_local! {
716 #[cfg(test)]
723 static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
724 const { std::cell::RefCell::new(None) };
725}
726
727#[cfg(test)]
731pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
732 body: F,
733) -> (R, Vec<CoexistWarning>) {
734 COEXIST_CAPTURE.with(|cell| {
735 *cell.borrow_mut() = Some(Vec::new());
736 });
737 let result = body();
738 let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
739 (result, findings)
740}
741
742fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
756 use std::sync::{Mutex, OnceLock};
757
758 if shadowed.is_empty() {
759 return;
760 }
761
762 let chosen_name = chosen_path.file_name().map_or_else(
763 || chosen_path.display().to_string(),
764 |n| n.to_string_lossy().into_owned(),
765 );
766 let dir = chosen_path.parent().unwrap_or(chosen_path);
767
768 #[cfg(test)]
769 COEXIST_CAPTURE.with(|cell| {
770 if let Some(buf) = cell.borrow_mut().as_mut() {
771 buf.push((
772 chosen_name.clone(),
773 shadowed.iter().map(|s| (*s).to_owned()).collect(),
774 ));
775 }
776 });
777
778 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
779 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
780 let dedupe_key = std::fs::canonicalize(dir)
781 .unwrap_or_else(|_| dir.to_path_buf())
782 .display()
783 .to_string();
784 if let Ok(mut set) = warned.lock()
785 && !set.insert(dedupe_key)
786 {
787 return;
788 }
789
790 tracing::warn!(
791 "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
792 fallow uses the first match in precedence order \
793 (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
794 remove the unused file(s) to silence this warning.",
795 dir = dir.display(),
796 chosen = chosen_name,
797 shadowed = shadowed.join(", "),
798 );
799}
800
801impl FallowConfig {
802 pub fn load(path: &Path) -> Result<Self, miette::Report> {
824 let mut visited = FxHashSet::default();
825 let merged = resolve_extends(path, &mut visited, 0)?;
826
827 warn_on_unknown_rule_keys(path, &merged);
828
829 let config: Self = serde_json::from_value(merged).map_err(|e| {
830 miette::miette!(
831 "Failed to deserialize config from {}: {}",
832 path.display(),
833 e
834 )
835 })?;
836
837 config.validate_user_globs().map_err(|errors| {
838 let joined = errors
839 .iter()
840 .map(ToString::to_string)
841 .collect::<Vec<_>>()
842 .join("\n - ");
843 miette::miette!("invalid config:\n - {}", joined)
844 })?;
845 if !config.security.request_receivers_are_valid() {
846 return Err(miette::miette!(
847 "invalid config:\n - security.requestReceivers entries must be non-empty strings"
848 ));
849 }
850
851 Ok(config)
852 }
853
854 pub fn validate_user_globs(
884 &self,
885 ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
886 use super::glob_validation::{
887 compile_user_glob, validate_user_globs, validate_user_path, validate_user_paths,
888 validate_user_specifier_globs,
889 };
890
891 let mut errors = Vec::new();
892
893 validate_user_globs(&self.entry, "entry", &mut errors);
894 validate_user_globs(&self.ignore_patterns, "ignorePatterns", &mut errors);
895 validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", &mut errors);
896 validate_user_specifier_globs(
897 &self.ignore_unresolved_imports,
898 "ignoreUnresolvedImports",
899 &mut errors,
900 );
901 validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", &mut errors);
902 validate_user_globs(&self.health.ignore, "health.ignore", &mut errors);
903
904 for override_entry in &self.overrides {
905 validate_user_globs(&override_entry.files, "overrides[].files", &mut errors);
906 }
907
908 for rule in &self.ignore_exports {
909 if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
910 errors.push(e);
911 }
912 }
913
914 for rule in &self.ignore_catalog_references {
915 if let Some(consumer) = &rule.consumer
916 && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
917 {
918 errors.push(e);
919 }
920 }
921
922 for zone in &self.boundaries.zones {
923 validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", &mut errors);
924 if let Some(root) = &zone.root
925 && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
926 {
927 errors.push(e);
928 }
929 validate_user_paths(
930 &zone.auto_discover,
931 "boundaries.zones[].autoDiscover",
932 &mut errors,
933 );
934 }
935 validate_user_globs(
936 &self.boundaries.coverage.allow_unmatched,
937 "boundaries.coverage.allowUnmatched",
938 &mut errors,
939 );
940
941 for plugin in &self.framework {
942 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
943 errors.append(&mut plugin_errors);
944 }
945 }
946
947 if errors.is_empty() {
948 Ok(())
949 } else {
950 Err(errors)
951 }
952 }
953
954 #[must_use]
957 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
958 let mut dir = start;
959 loop {
960 for name in CONFIG_NAMES {
961 let candidate = dir.join(name);
962 if candidate.exists() {
963 return Some(candidate);
964 }
965 }
966 if is_repo_root(dir) {
967 break;
968 }
969 dir = dir.parent()?;
970 }
971 None
972 }
973
974 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
980 let mut dir = start;
981 loop {
982 for (idx, name) in CONFIG_NAMES.iter().enumerate() {
983 let candidate = dir.join(name);
984 if candidate.exists() {
985 warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
986 match Self::load(&candidate) {
987 Ok(config) => return Ok(Some((config, candidate))),
988 Err(e) => {
989 return Err(format!("Failed to parse {}: {e}", candidate.display()));
990 }
991 }
992 }
993 }
994 if is_repo_root(dir) {
995 break;
996 }
997 dir = match dir.parent() {
998 Some(parent) => parent,
999 None => break,
1000 };
1001 }
1002 Ok(None)
1003 }
1004
1005 #[must_use]
1007 pub fn json_schema() -> serde_json::Value {
1008 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1009 }
1010
1011 pub fn validate_resolved_boundaries(
1040 &self,
1041 root: &Path,
1042 ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1043 use super::boundaries::ZoneValidationError;
1044
1045 let mut boundaries = self.boundaries.clone();
1046 if boundaries.preset.is_some() {
1047 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1048 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1049 .unwrap_or_else(|| "src".to_owned());
1050 boundaries.expand(&source_root);
1051 }
1052 let _logical_groups = boundaries.expand_auto_discover(root);
1053
1054 let mut errors: Vec<ZoneValidationError> = boundaries
1055 .validate_zone_references()
1056 .into_iter()
1057 .map(ZoneValidationError::UnknownZoneReference)
1058 .collect();
1059 errors.extend(
1060 boundaries
1061 .validate_root_prefixes()
1062 .into_iter()
1063 .map(ZoneValidationError::RedundantRootPrefix),
1064 );
1065 errors.extend(
1066 boundaries
1067 .validate_call_rules()
1068 .into_iter()
1069 .map(ZoneValidationError::InvalidForbiddenCallee),
1070 );
1071
1072 if errors.is_empty() {
1073 Ok(())
1074 } else {
1075 Err(errors)
1076 }
1077 }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083 use crate::CacheConfig;
1084 use crate::PackageJson;
1085 use crate::config::format::OutputFormat;
1086 use crate::config::rules::Severity;
1087
1088 fn test_dir(_name: &str) -> tempfile::TempDir {
1090 tempfile::tempdir().expect("create temp dir")
1091 }
1092
1093 #[test]
1094 fn fallow_config_deserialize_minimal() {
1095 let toml_str = r#"
1096entry = ["src/main.ts"]
1097"#;
1098 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1099 assert_eq!(config.entry, vec!["src/main.ts"]);
1100 assert!(config.ignore_patterns.is_empty());
1101 }
1102
1103 #[test]
1104 fn fallow_config_deserialize_ignore_exports() {
1105 let toml_str = r#"
1106[[ignoreExports]]
1107file = "src/types/*.ts"
1108exports = ["*"]
1109
1110[[ignoreExports]]
1111file = "src/constants.ts"
1112exports = ["FOO", "BAR"]
1113"#;
1114 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1115 assert_eq!(config.ignore_exports.len(), 2);
1116 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1117 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1118 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1119 }
1120
1121 #[test]
1122 fn fallow_config_deserialize_ignore_dependencies() {
1123 let toml_str = r#"
1124ignoreDependencies = ["autoprefixer", "postcss"]
1125"#;
1126 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1127 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1128 }
1129
1130 #[test]
1131 fn fallow_config_deserialize_ignore_unresolved_imports() {
1132 let toml_str = r#"
1133ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1134"#;
1135 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1136 assert_eq!(
1137 config.ignore_unresolved_imports,
1138 vec!["@example/icons", "@example/icons/**", "../generated/**"]
1139 );
1140 }
1141
1142 #[test]
1143 fn fallow_config_resolve_default_ignores() {
1144 let config = FallowConfig::default();
1145 let resolved = config.resolve(
1146 PathBuf::from("/tmp/test"),
1147 OutputFormat::Human,
1148 4,
1149 true,
1150 true,
1151 None,
1152 );
1153
1154 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1155 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1156 assert!(resolved.ignore_patterns.is_match("build/output.js"));
1157 assert!(resolved.ignore_patterns.is_match(".git/config"));
1158 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1159 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1160 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1161 }
1162
1163 #[test]
1164 fn fallow_config_resolve_custom_ignores() {
1165 let config = FallowConfig {
1166 entry: vec!["src/**/*.ts".to_string()],
1167 ignore_patterns: vec!["**/*.generated.ts".to_string()],
1168 ..Default::default()
1169 };
1170 let resolved = config.resolve(
1171 PathBuf::from("/tmp/test"),
1172 OutputFormat::Json,
1173 4,
1174 false,
1175 true,
1176 None,
1177 );
1178
1179 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1180 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1181 assert!(matches!(resolved.output, OutputFormat::Json));
1182 assert!(!resolved.no_cache);
1183 }
1184
1185 #[test]
1186 fn fallow_config_resolve_cache_dir() {
1187 let config = FallowConfig::default();
1188 let resolved = config.resolve(
1189 PathBuf::from("/tmp/project"),
1190 OutputFormat::Human,
1191 4,
1192 true,
1193 true,
1194 None,
1195 );
1196 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1197 assert!(resolved.no_cache);
1198 }
1199
1200 #[test]
1201 fn package_json_entry_points_main() {
1202 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1203 let entries = pkg.entry_points();
1204 assert!(entries.contains(&"dist/index.js".to_string()));
1205 }
1206
1207 #[test]
1208 fn package_json_entry_points_module() {
1209 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1210 let entries = pkg.entry_points();
1211 assert!(entries.contains(&"dist/index.mjs".to_string()));
1212 }
1213
1214 #[test]
1215 fn package_json_entry_points_types() {
1216 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1217 let entries = pkg.entry_points();
1218 assert!(entries.contains(&"dist/index.d.ts".to_string()));
1219 }
1220
1221 #[test]
1222 fn package_json_entry_points_bin_string() {
1223 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1224 let entries = pkg.entry_points();
1225 assert!(entries.contains(&"bin/cli.js".to_string()));
1226 }
1227
1228 #[test]
1229 fn package_json_entry_points_bin_object() {
1230 let pkg: PackageJson =
1231 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1232 .unwrap();
1233 let entries = pkg.entry_points();
1234 assert!(entries.contains(&"bin/cli.js".to_string()));
1235 assert!(entries.contains(&"bin/serve.js".to_string()));
1236 }
1237
1238 #[test]
1239 fn package_json_entry_points_exports_string() {
1240 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1241 let entries = pkg.entry_points();
1242 assert!(entries.contains(&"./dist/index.js".to_string()));
1243 }
1244
1245 #[test]
1246 fn package_json_entry_points_exports_object() {
1247 let pkg: PackageJson = serde_json::from_str(
1248 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1249 )
1250 .unwrap();
1251 let entries = pkg.entry_points();
1252 assert!(entries.contains(&"./dist/index.mjs".to_string()));
1253 assert!(entries.contains(&"./dist/index.cjs".to_string()));
1254 }
1255
1256 #[test]
1257 fn package_json_dependency_names() {
1258 let pkg: PackageJson = serde_json::from_str(
1259 r#"{
1260 "dependencies": {"react": "^18", "lodash": "^4"},
1261 "devDependencies": {"typescript": "^5"},
1262 "peerDependencies": {"react-dom": "^18"}
1263 }"#,
1264 )
1265 .unwrap();
1266
1267 let all = pkg.all_dependency_names();
1268 assert!(all.contains(&"react".to_string()));
1269 assert!(all.contains(&"lodash".to_string()));
1270 assert!(all.contains(&"typescript".to_string()));
1271 assert!(all.contains(&"react-dom".to_string()));
1272
1273 let prod = pkg.production_dependency_names();
1274 assert!(prod.contains(&"react".to_string()));
1275 assert!(!prod.contains(&"typescript".to_string()));
1276
1277 let dev = pkg.dev_dependency_names();
1278 assert!(dev.contains(&"typescript".to_string()));
1279 assert!(!dev.contains(&"react".to_string()));
1280 }
1281
1282 #[test]
1283 fn package_json_no_dependencies() {
1284 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1285 assert!(pkg.all_dependency_names().is_empty());
1286 assert!(pkg.production_dependency_names().is_empty());
1287 assert!(pkg.dev_dependency_names().is_empty());
1288 assert!(pkg.entry_points().is_empty());
1289 }
1290
1291 #[test]
1292 fn rules_deserialize_toml_kebab_case() {
1293 let toml_str = r#"
1294[rules]
1295unused-files = "error"
1296unused-exports = "warn"
1297unused-types = "off"
1298"#;
1299 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1300 assert_eq!(config.rules.unused_files, Severity::Error);
1301 assert_eq!(config.rules.unused_exports, Severity::Warn);
1302 assert_eq!(config.rules.unused_types, Severity::Off);
1303 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1304 }
1305
1306 #[test]
1307 fn config_without_rules_defaults_to_error() {
1308 let toml_str = r#"
1309entry = ["src/main.ts"]
1310"#;
1311 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1312 assert_eq!(config.rules.unused_files, Severity::Error);
1313 assert_eq!(config.rules.unused_exports, Severity::Error);
1314 }
1315
1316 #[test]
1317 fn fallow_config_denies_unknown_fields() {
1318 let toml_str = r"
1319unknown_field = true
1320";
1321 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1322 assert!(result.is_err());
1323 }
1324
1325 #[test]
1326 fn fallow_config_deserialize_json() {
1327 let json_str = r#"{"entry": ["src/main.ts"]}"#;
1328 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1329 assert_eq!(config.entry, vec!["src/main.ts"]);
1330 }
1331
1332 #[test]
1333 fn fallow_config_deserialize_jsonc() {
1334 let jsonc_str = r#"{
1335 "entry": ["src/main.ts"],
1336 "rules": {
1337 "unused-files": "warn"
1338 }
1339 }"#;
1340 let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1341 assert_eq!(config.entry, vec!["src/main.ts"]);
1342 assert_eq!(config.rules.unused_files, Severity::Warn);
1343 }
1344
1345 #[test]
1346 fn fallow_config_json_with_schema_field() {
1347 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1348 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1349 assert_eq!(config.entry, vec!["src/main.ts"]);
1350 }
1351
1352 #[test]
1353 fn fallow_config_json_schema_generation() {
1354 let schema = FallowConfig::json_schema();
1355 assert!(schema.is_object());
1356 let obj = schema.as_object().unwrap();
1357 assert!(obj.contains_key("properties"));
1358 }
1359
1360 #[test]
1361 fn config_format_detection() {
1362 assert!(matches!(
1363 ConfigFormat::from_path(Path::new("fallow.toml")),
1364 ConfigFormat::Toml
1365 ));
1366 assert!(matches!(
1367 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1368 ConfigFormat::Json
1369 ));
1370 assert!(matches!(
1371 ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1372 ConfigFormat::Json
1373 ));
1374 assert!(matches!(
1375 ConfigFormat::from_path(Path::new(".fallow.toml")),
1376 ConfigFormat::Toml
1377 ));
1378 }
1379
1380 #[test]
1381 fn config_names_priority_order() {
1382 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1383 assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1384 assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1385 assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1386 }
1387
1388 #[test]
1389 fn load_json_config_file() {
1390 let dir = test_dir("json-config");
1391 let config_path = dir.path().join(".fallowrc.json");
1392 std::fs::write(
1393 &config_path,
1394 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1395 )
1396 .unwrap();
1397
1398 let config = FallowConfig::load(&config_path).unwrap();
1399 assert_eq!(config.entry, vec!["src/index.ts"]);
1400 assert_eq!(config.rules.unused_exports, Severity::Warn);
1401 }
1402
1403 #[test]
1404 fn load_jsonc_config_file() {
1405 let dir = test_dir("jsonc-config");
1406 let config_path = dir.path().join(".fallowrc.json");
1407 std::fs::write(
1408 &config_path,
1409 r#"{
1410 "entry": ["src/index.ts"],
1411 /* Block comment */
1412 "rules": {
1413 "unused-exports": "warn"
1414 }
1415 }"#,
1416 )
1417 .unwrap();
1418
1419 let config = FallowConfig::load(&config_path).unwrap();
1420 assert_eq!(config.entry, vec!["src/index.ts"]);
1421 assert_eq!(config.rules.unused_exports, Severity::Warn);
1422 }
1423
1424 #[test]
1425 fn load_fallowrc_jsonc_extension() {
1426 let dir = test_dir("jsonc-extension");
1427 let config_path = dir.path().join(".fallowrc.jsonc");
1428 std::fs::write(
1429 &config_path,
1430 r#"{
1431 "ignoreDependencies": ["tailwindcss-react-aria-components"],
1432 "entry": ["src/index.ts"]
1433 }"#,
1434 )
1435 .unwrap();
1436
1437 let config = FallowConfig::load(&config_path).unwrap();
1438 assert_eq!(config.entry, vec!["src/index.ts"]);
1439 assert_eq!(
1440 config.ignore_dependencies,
1441 vec!["tailwindcss-react-aria-components"]
1442 );
1443 }
1444
1445 #[test]
1446 fn json_config_ignore_dependencies_camel_case() {
1447 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1448 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1449 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1450 }
1451
1452 #[test]
1453 fn json_config_ignore_unresolved_imports_camel_case() {
1454 let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1455 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1456 assert_eq!(
1457 config.ignore_unresolved_imports,
1458 vec!["@example/icons", "@example/icons/**"]
1459 );
1460 }
1461
1462 #[test]
1463 fn json_config_all_fields() {
1464 let json_str = r#"{
1465 "ignoreDependencies": ["lodash"],
1466 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1467 "rules": {
1468 "unused-files": "off",
1469 "unused-exports": "warn",
1470 "unused-dependencies": "error",
1471 "unused-dev-dependencies": "off",
1472 "unused-types": "warn",
1473 "unused-enum-members": "error",
1474 "unused-class-members": "off",
1475 "unresolved-imports": "warn",
1476 "unlisted-dependencies": "error",
1477 "duplicate-exports": "off"
1478 },
1479 "duplicates": {
1480 "minTokens": 100,
1481 "minLines": 10,
1482 "skipLocal": true
1483 }
1484 }"#;
1485 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1486 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1487 assert_eq!(config.rules.unused_files, Severity::Off);
1488 assert_eq!(config.rules.unused_exports, Severity::Warn);
1489 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1490 assert_eq!(config.duplicates.min_tokens, 100);
1491 assert_eq!(config.duplicates.min_lines, 10);
1492 assert!(config.duplicates.skip_local);
1493 }
1494
1495 #[test]
1496 fn extends_single_base() {
1497 let dir = test_dir("extends-single");
1498
1499 std::fs::write(
1500 dir.path().join("base.json"),
1501 r#"{"rules": {"unused-files": "warn"}}"#,
1502 )
1503 .unwrap();
1504 std::fs::write(
1505 dir.path().join(".fallowrc.json"),
1506 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1507 )
1508 .unwrap();
1509
1510 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1511 assert_eq!(config.rules.unused_files, Severity::Warn);
1512 assert_eq!(config.entry, vec!["src/index.ts"]);
1513 assert_eq!(config.rules.unused_exports, Severity::Error);
1514 }
1515
1516 #[test]
1517 fn extends_overlay_overrides_base() {
1518 let dir = test_dir("extends-overlay");
1519
1520 std::fs::write(
1521 dir.path().join("base.json"),
1522 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1523 )
1524 .unwrap();
1525 std::fs::write(
1526 dir.path().join(".fallowrc.json"),
1527 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1528 )
1529 .unwrap();
1530
1531 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1532 assert_eq!(config.rules.unused_files, Severity::Error);
1533 assert_eq!(config.rules.unused_exports, Severity::Off);
1534 }
1535
1536 #[test]
1537 fn extends_chained() {
1538 let dir = test_dir("extends-chained");
1539
1540 std::fs::write(
1541 dir.path().join("grandparent.json"),
1542 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1543 )
1544 .unwrap();
1545 std::fs::write(
1546 dir.path().join("parent.json"),
1547 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1548 )
1549 .unwrap();
1550 std::fs::write(
1551 dir.path().join(".fallowrc.json"),
1552 r#"{"extends": ["parent.json"]}"#,
1553 )
1554 .unwrap();
1555
1556 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1557 assert_eq!(config.rules.unused_files, Severity::Warn);
1558 assert_eq!(config.rules.unused_exports, Severity::Warn);
1559 }
1560
1561 #[test]
1562 fn extends_circular_detected() {
1563 let dir = test_dir("extends-circular");
1564
1565 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1566 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1567
1568 let result = FallowConfig::load(&dir.path().join("a.json"));
1569 assert!(result.is_err());
1570 let err_msg = format!("{}", result.unwrap_err());
1571 assert!(
1572 err_msg.contains("Circular extends"),
1573 "Expected circular error, got: {err_msg}"
1574 );
1575 }
1576
1577 #[test]
1578 fn extends_missing_file_errors() {
1579 let dir = test_dir("extends-missing");
1580
1581 std::fs::write(
1582 dir.path().join(".fallowrc.json"),
1583 r#"{"extends": ["nonexistent.json"]}"#,
1584 )
1585 .unwrap();
1586
1587 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1588 assert!(result.is_err());
1589 let err_msg = format!("{}", result.unwrap_err());
1590 assert!(
1591 err_msg.contains("not found"),
1592 "Expected not found error, got: {err_msg}"
1593 );
1594 }
1595
1596 #[test]
1597 fn sealed_allows_in_directory_extends() {
1598 let dir = test_dir("sealed-allows-local");
1599 std::fs::write(
1600 dir.path().join("base.json"),
1601 r#"{"ignorePatterns": ["gen/**"]}"#,
1602 )
1603 .unwrap();
1604 std::fs::write(
1605 dir.path().join(".fallowrc.json"),
1606 r#"{"sealed": true, "extends": ["./base.json"]}"#,
1607 )
1608 .unwrap();
1609
1610 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1611 assert!(config.sealed);
1612 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1613 }
1614
1615 #[test]
1616 fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
1617 let dir = test_dir("boundary-coverage-invalid-glob");
1618 std::fs::write(
1619 dir.path().join(".fallowrc.json"),
1620 r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
1621 )
1622 .unwrap();
1623
1624 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1625 assert!(result.is_err());
1626 let err_msg = format!("{}", result.unwrap_err());
1627 assert!(
1628 err_msg.contains("boundaries.coverage.allowUnmatched"),
1629 "expected coverage field in error, got: {err_msg}"
1630 );
1631 }
1632
1633 #[test]
1634 fn sealed_rejects_extends_escaping_directory() {
1635 let dir = test_dir("sealed-rejects-escape");
1636 let sub = dir.path().join("packages").join("app");
1637 std::fs::create_dir_all(&sub).unwrap();
1638
1639 std::fs::write(
1640 dir.path().join("base.json"),
1641 r#"{"ignorePatterns": ["dist/**"]}"#,
1642 )
1643 .unwrap();
1644 std::fs::write(
1645 sub.join(".fallowrc.json"),
1646 r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1647 )
1648 .unwrap();
1649
1650 let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1651 assert!(
1652 result.is_err(),
1653 "Expected sealed config to reject escaping extends"
1654 );
1655 let err_msg = format!("{}", result.unwrap_err());
1656 assert!(
1657 err_msg.contains("sealed"),
1658 "Error must mention sealed: {err_msg}"
1659 );
1660 assert!(
1661 err_msg.contains("outside the config's directory"),
1662 "Error must explain the constraint: {err_msg}"
1663 );
1664 }
1665
1666 #[test]
1667 fn sealed_rejects_https_extends() {
1668 let dir = test_dir("sealed-rejects-https");
1669 std::fs::write(
1670 dir.path().join(".fallowrc.json"),
1671 r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1672 )
1673 .unwrap();
1674
1675 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1676 assert!(result.is_err());
1677 let err_msg = format!("{}", result.unwrap_err());
1678 assert!(
1679 err_msg.contains("sealed"),
1680 "Error must mention sealed: {err_msg}"
1681 );
1682 assert!(
1683 err_msg.contains("URL extends"),
1684 "Error must mention URL: {err_msg}"
1685 );
1686 }
1687
1688 #[test]
1689 fn sealed_rejects_npm_extends() {
1690 let dir = test_dir("sealed-rejects-npm");
1691 std::fs::write(
1692 dir.path().join(".fallowrc.json"),
1693 r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1694 )
1695 .unwrap();
1696
1697 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1698 assert!(result.is_err());
1699 let err_msg = format!("{}", result.unwrap_err());
1700 assert!(
1701 err_msg.contains("sealed"),
1702 "Error must mention sealed: {err_msg}"
1703 );
1704 assert!(
1705 err_msg.contains("npm extends"),
1706 "Error must mention npm: {err_msg}"
1707 );
1708 }
1709
1710 #[test]
1711 fn sealed_default_is_false() {
1712 let dir = test_dir("sealed-default");
1713 std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1714 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1715 assert!(!config.sealed);
1716 }
1717
1718 #[test]
1719 fn sealed_false_allows_escaping_extends() {
1720 let dir = test_dir("sealed-false-allows");
1721 let sub = dir.path().join("packages").join("app");
1722 std::fs::create_dir_all(&sub).unwrap();
1723
1724 std::fs::write(
1725 dir.path().join("base.json"),
1726 r#"{"ignorePatterns": ["dist/**"]}"#,
1727 )
1728 .unwrap();
1729 std::fs::write(
1730 sub.join(".fallowrc.json"),
1731 r#"{"extends": ["../../base.json"]}"#,
1732 )
1733 .unwrap();
1734
1735 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1736 assert!(!config.sealed);
1737 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1738 }
1739
1740 #[test]
1741 fn extends_string_sugar() {
1742 let dir = test_dir("extends-string");
1743
1744 std::fs::write(
1745 dir.path().join("base.json"),
1746 r#"{"ignorePatterns": ["gen/**"]}"#,
1747 )
1748 .unwrap();
1749 std::fs::write(
1750 dir.path().join(".fallowrc.json"),
1751 r#"{"extends": "base.json"}"#,
1752 )
1753 .unwrap();
1754
1755 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1756 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1757 }
1758
1759 #[test]
1760 fn extends_deep_merge_preserves_arrays() {
1761 let dir = test_dir("extends-array");
1762
1763 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1764 std::fs::write(
1765 dir.path().join(".fallowrc.json"),
1766 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1767 )
1768 .unwrap();
1769
1770 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1771 assert_eq!(config.entry, vec!["src/b.ts"]);
1772 }
1773
1774 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1775 let pkg_dir = root.join("node_modules").join(name);
1776 std::fs::create_dir_all(&pkg_dir).unwrap();
1777 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1778 }
1779
1780 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1781 let pkg_dir = root.join("node_modules").join(name);
1782 std::fs::create_dir_all(&pkg_dir).unwrap();
1783 std::fs::write(
1784 pkg_dir.join("package.json"),
1785 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1786 )
1787 .unwrap();
1788 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1789 }
1790
1791 #[test]
1792 fn extends_npm_basic_unscoped() {
1793 let dir = test_dir("npm-basic");
1794 create_npm_package(
1795 dir.path(),
1796 "fallow-config-acme",
1797 r#"{"rules": {"unused-files": "warn"}}"#,
1798 );
1799 std::fs::write(
1800 dir.path().join(".fallowrc.json"),
1801 r#"{"extends": "npm:fallow-config-acme"}"#,
1802 )
1803 .unwrap();
1804
1805 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1806 assert_eq!(config.rules.unused_files, Severity::Warn);
1807 }
1808
1809 #[test]
1810 fn extends_npm_scoped_package() {
1811 let dir = test_dir("npm-scoped");
1812 create_npm_package(
1813 dir.path(),
1814 "@company/fallow-config",
1815 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1816 );
1817 std::fs::write(
1818 dir.path().join(".fallowrc.json"),
1819 r#"{"extends": "npm:@company/fallow-config"}"#,
1820 )
1821 .unwrap();
1822
1823 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1824 assert_eq!(config.rules.unused_exports, Severity::Off);
1825 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1826 }
1827
1828 #[test]
1829 fn extends_npm_with_subpath() {
1830 let dir = test_dir("npm-subpath");
1831 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1832 std::fs::create_dir_all(&pkg_dir).unwrap();
1833 std::fs::write(
1834 pkg_dir.join("strict.json"),
1835 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1836 )
1837 .unwrap();
1838
1839 std::fs::write(
1840 dir.path().join(".fallowrc.json"),
1841 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1842 )
1843 .unwrap();
1844
1845 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1846 assert_eq!(config.rules.unused_files, Severity::Error);
1847 assert_eq!(config.rules.unused_exports, Severity::Error);
1848 }
1849
1850 #[test]
1851 fn extends_npm_package_json_main() {
1852 let dir = test_dir("npm-main");
1853 create_npm_package_with_main(
1854 dir.path(),
1855 "fallow-config-acme",
1856 "config.json",
1857 r#"{"rules": {"unused-types": "off"}}"#,
1858 );
1859 std::fs::write(
1860 dir.path().join(".fallowrc.json"),
1861 r#"{"extends": "npm:fallow-config-acme"}"#,
1862 )
1863 .unwrap();
1864
1865 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1866 assert_eq!(config.rules.unused_types, Severity::Off);
1867 }
1868
1869 #[test]
1870 fn extends_npm_package_json_exports_string() {
1871 let dir = test_dir("npm-exports-str");
1872 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1873 std::fs::create_dir_all(&pkg_dir).unwrap();
1874 std::fs::write(
1875 pkg_dir.join("package.json"),
1876 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1877 )
1878 .unwrap();
1879 std::fs::write(
1880 pkg_dir.join("base.json"),
1881 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1882 )
1883 .unwrap();
1884
1885 std::fs::write(
1886 dir.path().join(".fallowrc.json"),
1887 r#"{"extends": "npm:fallow-config-co"}"#,
1888 )
1889 .unwrap();
1890
1891 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1892 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1893 }
1894
1895 #[test]
1896 fn extends_npm_package_json_exports_object() {
1897 let dir = test_dir("npm-exports-obj");
1898 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1899 std::fs::create_dir_all(&pkg_dir).unwrap();
1900 std::fs::write(
1901 pkg_dir.join("package.json"),
1902 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1903 )
1904 .unwrap();
1905 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1906
1907 std::fs::write(
1908 dir.path().join(".fallowrc.json"),
1909 r#"{"extends": "npm:@co/cfg"}"#,
1910 )
1911 .unwrap();
1912
1913 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1914 assert_eq!(config.entry, vec!["src/app.ts"]);
1915 }
1916
1917 #[test]
1918 fn extends_npm_exports_takes_priority_over_main() {
1919 let dir = test_dir("npm-exports-prio");
1920 let pkg_dir = dir.path().join("node_modules/my-config");
1921 std::fs::create_dir_all(&pkg_dir).unwrap();
1922 std::fs::write(
1923 pkg_dir.join("package.json"),
1924 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1925 )
1926 .unwrap();
1927 std::fs::write(
1928 pkg_dir.join("old.json"),
1929 r#"{"rules": {"unused-files": "off"}}"#,
1930 )
1931 .unwrap();
1932 std::fs::write(
1933 pkg_dir.join("new.json"),
1934 r#"{"rules": {"unused-files": "warn"}}"#,
1935 )
1936 .unwrap();
1937
1938 std::fs::write(
1939 dir.path().join(".fallowrc.json"),
1940 r#"{"extends": "npm:my-config"}"#,
1941 )
1942 .unwrap();
1943
1944 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1945 assert_eq!(config.rules.unused_files, Severity::Warn);
1946 }
1947
1948 #[test]
1949 fn extends_npm_walk_up_directories() {
1950 let dir = test_dir("npm-walkup");
1951 create_npm_package(
1952 dir.path(),
1953 "shared-config",
1954 r#"{"rules": {"unused-files": "warn"}}"#,
1955 );
1956 let sub = dir.path().join("packages/app");
1957 std::fs::create_dir_all(&sub).unwrap();
1958 std::fs::write(
1959 sub.join(".fallowrc.json"),
1960 r#"{"extends": "npm:shared-config"}"#,
1961 )
1962 .unwrap();
1963
1964 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1965 assert_eq!(config.rules.unused_files, Severity::Warn);
1966 }
1967
1968 #[test]
1969 fn extends_npm_overlay_overrides_base() {
1970 let dir = test_dir("npm-overlay");
1971 create_npm_package(
1972 dir.path(),
1973 "@company/base",
1974 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1975 );
1976 std::fs::write(
1977 dir.path().join(".fallowrc.json"),
1978 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1979 )
1980 .unwrap();
1981
1982 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1983 assert_eq!(config.rules.unused_files, Severity::Error);
1984 assert_eq!(config.rules.unused_exports, Severity::Off);
1985 assert_eq!(config.entry, vec!["src/app.ts"]);
1986 }
1987
1988 #[test]
1989 fn extends_npm_chained_with_relative() {
1990 let dir = test_dir("npm-chained");
1991 let pkg_dir = dir.path().join("node_modules/my-config");
1992 std::fs::create_dir_all(&pkg_dir).unwrap();
1993 std::fs::write(
1994 pkg_dir.join("base.json"),
1995 r#"{"rules": {"unused-files": "warn"}}"#,
1996 )
1997 .unwrap();
1998 std::fs::write(
1999 pkg_dir.join(".fallowrc.json"),
2000 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2001 )
2002 .unwrap();
2003
2004 std::fs::write(
2005 dir.path().join(".fallowrc.json"),
2006 r#"{"extends": "npm:my-config"}"#,
2007 )
2008 .unwrap();
2009
2010 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2011 assert_eq!(config.rules.unused_files, Severity::Warn);
2012 assert_eq!(config.rules.unused_exports, Severity::Off);
2013 }
2014
2015 #[test]
2016 fn extends_npm_mixed_with_relative_paths() {
2017 let dir = test_dir("npm-mixed");
2018 create_npm_package(
2019 dir.path(),
2020 "shared-base",
2021 r#"{"rules": {"unused-files": "off"}}"#,
2022 );
2023 std::fs::write(
2024 dir.path().join("local-overrides.json"),
2025 r#"{"rules": {"unused-files": "warn"}}"#,
2026 )
2027 .unwrap();
2028 std::fs::write(
2029 dir.path().join(".fallowrc.json"),
2030 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2031 )
2032 .unwrap();
2033
2034 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2035 assert_eq!(config.rules.unused_files, Severity::Warn);
2036 }
2037
2038 #[test]
2039 fn extends_npm_missing_package_errors() {
2040 let dir = test_dir("npm-missing");
2041 std::fs::write(
2042 dir.path().join(".fallowrc.json"),
2043 r#"{"extends": "npm:nonexistent-package"}"#,
2044 )
2045 .unwrap();
2046
2047 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2048 assert!(result.is_err());
2049 let err_msg = format!("{}", result.unwrap_err());
2050 assert!(
2051 err_msg.contains("not found"),
2052 "Expected 'not found' error, got: {err_msg}"
2053 );
2054 assert!(
2055 err_msg.contains("nonexistent-package"),
2056 "Expected package name in error, got: {err_msg}"
2057 );
2058 assert!(
2059 err_msg.contains("install it"),
2060 "Expected install hint in error, got: {err_msg}"
2061 );
2062 }
2063
2064 #[test]
2065 fn extends_npm_no_config_in_package_errors() {
2066 let dir = test_dir("npm-no-config");
2067 let pkg_dir = dir.path().join("node_modules/empty-pkg");
2068 std::fs::create_dir_all(&pkg_dir).unwrap();
2069 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2070
2071 std::fs::write(
2072 dir.path().join(".fallowrc.json"),
2073 r#"{"extends": "npm:empty-pkg"}"#,
2074 )
2075 .unwrap();
2076
2077 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2078 assert!(result.is_err());
2079 let err_msg = format!("{}", result.unwrap_err());
2080 assert!(
2081 err_msg.contains("No fallow config found"),
2082 "Expected 'No fallow config found' error, got: {err_msg}"
2083 );
2084 }
2085
2086 #[test]
2087 fn extends_npm_missing_subpath_errors() {
2088 let dir = test_dir("npm-missing-sub");
2089 let pkg_dir = dir.path().join("node_modules/@co/config");
2090 std::fs::create_dir_all(&pkg_dir).unwrap();
2091
2092 std::fs::write(
2093 dir.path().join(".fallowrc.json"),
2094 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2095 )
2096 .unwrap();
2097
2098 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2099 assert!(result.is_err());
2100 let err_msg = format!("{}", result.unwrap_err());
2101 assert!(
2102 err_msg.contains("nonexistent.json"),
2103 "Expected subpath in error, got: {err_msg}"
2104 );
2105 }
2106
2107 #[test]
2108 fn extends_npm_empty_specifier_errors() {
2109 let dir = test_dir("npm-empty");
2110 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2111
2112 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2113 assert!(result.is_err());
2114 let err_msg = format!("{}", result.unwrap_err());
2115 assert!(
2116 err_msg.contains("Empty npm specifier"),
2117 "Expected 'Empty npm specifier' error, got: {err_msg}"
2118 );
2119 }
2120
2121 #[test]
2122 fn extends_npm_space_after_colon_trimmed() {
2123 let dir = test_dir("npm-space");
2124 create_npm_package(
2125 dir.path(),
2126 "fallow-config-acme",
2127 r#"{"rules": {"unused-files": "warn"}}"#,
2128 );
2129 std::fs::write(
2130 dir.path().join(".fallowrc.json"),
2131 r#"{"extends": "npm: fallow-config-acme"}"#,
2132 )
2133 .unwrap();
2134
2135 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2136 assert_eq!(config.rules.unused_files, Severity::Warn);
2137 }
2138
2139 #[test]
2140 fn extends_npm_exports_node_condition() {
2141 let dir = test_dir("npm-node-cond");
2142 let pkg_dir = dir.path().join("node_modules/node-config");
2143 std::fs::create_dir_all(&pkg_dir).unwrap();
2144 std::fs::write(
2145 pkg_dir.join("package.json"),
2146 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2147 )
2148 .unwrap();
2149 std::fs::write(
2150 pkg_dir.join("node.json"),
2151 r#"{"rules": {"unused-files": "off"}}"#,
2152 )
2153 .unwrap();
2154
2155 std::fs::write(
2156 dir.path().join(".fallowrc.json"),
2157 r#"{"extends": "npm:node-config"}"#,
2158 )
2159 .unwrap();
2160
2161 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2162 assert_eq!(config.rules.unused_files, Severity::Off);
2163 }
2164
2165 #[test]
2166 fn parse_npm_specifier_unscoped() {
2167 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2168 }
2169
2170 #[test]
2171 fn parse_npm_specifier_unscoped_with_subpath() {
2172 assert_eq!(
2173 parse_npm_specifier("my-config/strict.json"),
2174 ("my-config", Some("strict.json"))
2175 );
2176 }
2177
2178 #[test]
2179 fn parse_npm_specifier_scoped() {
2180 assert_eq!(
2181 parse_npm_specifier("@company/fallow-config"),
2182 ("@company/fallow-config", None)
2183 );
2184 }
2185
2186 #[test]
2187 fn parse_npm_specifier_scoped_with_subpath() {
2188 assert_eq!(
2189 parse_npm_specifier("@company/fallow-config/strict.json"),
2190 ("@company/fallow-config", Some("strict.json"))
2191 );
2192 }
2193
2194 #[test]
2195 fn parse_npm_specifier_scoped_with_nested_subpath() {
2196 assert_eq!(
2197 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2198 ("@company/fallow-config", Some("presets/strict.json"))
2199 );
2200 }
2201
2202 #[test]
2203 fn extends_npm_subpath_traversal_rejected() {
2204 let dir = test_dir("npm-traversal-sub");
2205 let pkg_dir = dir.path().join("node_modules/evil-pkg");
2206 std::fs::create_dir_all(&pkg_dir).unwrap();
2207 std::fs::write(
2208 dir.path().join("secret.json"),
2209 r#"{"entry": ["stolen.ts"]}"#,
2210 )
2211 .unwrap();
2212
2213 std::fs::write(
2214 dir.path().join(".fallowrc.json"),
2215 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2216 )
2217 .unwrap();
2218
2219 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2220 assert!(result.is_err());
2221 let err_msg = format!("{}", result.unwrap_err());
2222 assert!(
2223 err_msg.contains("traversal") || err_msg.contains("not found"),
2224 "Expected traversal or not-found error, got: {err_msg}"
2225 );
2226 }
2227
2228 #[test]
2229 fn extends_npm_dotdot_package_name_rejected() {
2230 let dir = test_dir("npm-dotdot-name");
2231 std::fs::write(
2232 dir.path().join(".fallowrc.json"),
2233 r#"{"extends": "npm:../relative"}"#,
2234 )
2235 .unwrap();
2236
2237 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2238 assert!(result.is_err());
2239 let err_msg = format!("{}", result.unwrap_err());
2240 assert!(
2241 err_msg.contains("path traversal"),
2242 "Expected 'path traversal' error, got: {err_msg}"
2243 );
2244 }
2245
2246 #[test]
2247 fn extends_npm_scoped_without_name_rejected() {
2248 let dir = test_dir("npm-scope-only");
2249 std::fs::write(
2250 dir.path().join(".fallowrc.json"),
2251 r#"{"extends": "npm:@scope"}"#,
2252 )
2253 .unwrap();
2254
2255 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2256 assert!(result.is_err());
2257 let err_msg = format!("{}", result.unwrap_err());
2258 assert!(
2259 err_msg.contains("@scope/name"),
2260 "Expected scoped name format error, got: {err_msg}"
2261 );
2262 }
2263
2264 #[test]
2265 fn extends_npm_malformed_package_json_errors() {
2266 let dir = test_dir("npm-bad-pkgjson");
2267 let pkg_dir = dir.path().join("node_modules/bad-pkg");
2268 std::fs::create_dir_all(&pkg_dir).unwrap();
2269 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2270
2271 std::fs::write(
2272 dir.path().join(".fallowrc.json"),
2273 r#"{"extends": "npm:bad-pkg"}"#,
2274 )
2275 .unwrap();
2276
2277 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2278 assert!(result.is_err());
2279 let err_msg = format!("{}", result.unwrap_err());
2280 assert!(
2281 err_msg.contains("Failed to parse"),
2282 "Expected parse error, got: {err_msg}"
2283 );
2284 }
2285
2286 #[test]
2287 fn extends_npm_exports_traversal_rejected() {
2288 let dir = test_dir("npm-exports-escape");
2289 let pkg_dir = dir.path().join("node_modules/evil-exports");
2290 std::fs::create_dir_all(&pkg_dir).unwrap();
2291 std::fs::write(
2292 pkg_dir.join("package.json"),
2293 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2294 )
2295 .unwrap();
2296 std::fs::write(
2297 dir.path().join("secret.json"),
2298 r#"{"entry": ["stolen.ts"]}"#,
2299 )
2300 .unwrap();
2301
2302 std::fs::write(
2303 dir.path().join(".fallowrc.json"),
2304 r#"{"extends": "npm:evil-exports"}"#,
2305 )
2306 .unwrap();
2307
2308 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2309 assert!(result.is_err());
2310 let err_msg = format!("{}", result.unwrap_err());
2311 assert!(
2312 err_msg.contains("traversal"),
2313 "Expected traversal error, got: {err_msg}"
2314 );
2315 }
2316
2317 #[test]
2318 fn deep_merge_scalar_overlay_replaces_base() {
2319 let mut base = serde_json::json!("hello");
2320 deep_merge_json(&mut base, serde_json::json!("world"));
2321 assert_eq!(base, serde_json::json!("world"));
2322 }
2323
2324 #[test]
2325 fn deep_merge_array_overlay_replaces_base() {
2326 let mut base = serde_json::json!(["a", "b"]);
2327 deep_merge_json(&mut base, serde_json::json!(["c"]));
2328 assert_eq!(base, serde_json::json!(["c"]));
2329 }
2330
2331 #[test]
2332 fn deep_merge_nested_object_merge() {
2333 let mut base = serde_json::json!({
2334 "level1": {
2335 "level2": {
2336 "a": 1,
2337 "b": 2
2338 }
2339 }
2340 });
2341 let overlay = serde_json::json!({
2342 "level1": {
2343 "level2": {
2344 "b": 99,
2345 "c": 3
2346 }
2347 }
2348 });
2349 deep_merge_json(&mut base, overlay);
2350 assert_eq!(base["level1"]["level2"]["a"], 1);
2351 assert_eq!(base["level1"]["level2"]["b"], 99);
2352 assert_eq!(base["level1"]["level2"]["c"], 3);
2353 }
2354
2355 #[test]
2356 fn deep_merge_overlay_adds_new_fields() {
2357 let mut base = serde_json::json!({"existing": true});
2358 let overlay = serde_json::json!({"new_field": "added", "another": 42});
2359 deep_merge_json(&mut base, overlay);
2360 assert_eq!(base["existing"], true);
2361 assert_eq!(base["new_field"], "added");
2362 assert_eq!(base["another"], 42);
2363 }
2364
2365 #[test]
2366 fn deep_merge_null_overlay_replaces_object() {
2367 let mut base = serde_json::json!({"key": "value"});
2368 deep_merge_json(&mut base, serde_json::json!(null));
2369 assert_eq!(base, serde_json::json!(null));
2370 }
2371
2372 #[test]
2373 fn deep_merge_empty_object_overlay_preserves_base() {
2374 let mut base = serde_json::json!({"a": 1, "b": 2});
2375 deep_merge_json(&mut base, serde_json::json!({}));
2376 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2377 }
2378
2379 #[test]
2380 fn rules_severity_error_warn_off_from_json() {
2381 let json_str = r#"{
2382 "rules": {
2383 "unused-files": "error",
2384 "unused-exports": "warn",
2385 "unused-types": "off"
2386 }
2387 }"#;
2388 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2389 assert_eq!(config.rules.unused_files, Severity::Error);
2390 assert_eq!(config.rules.unused_exports, Severity::Warn);
2391 assert_eq!(config.rules.unused_types, Severity::Off);
2392 }
2393
2394 #[test]
2395 fn rules_omitted_default_to_error() {
2396 let json_str = r#"{
2397 "rules": {
2398 "unused-files": "warn"
2399 }
2400 }"#;
2401 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2402 assert_eq!(config.rules.unused_files, Severity::Warn);
2403 assert_eq!(config.rules.unused_exports, Severity::Error);
2404 assert_eq!(config.rules.unused_types, Severity::Error);
2405 assert_eq!(config.rules.unused_dependencies, Severity::Error);
2406 assert_eq!(config.rules.unresolved_imports, Severity::Error);
2407 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2408 assert_eq!(config.rules.duplicate_exports, Severity::Error);
2409 assert_eq!(config.rules.circular_dependencies, Severity::Error);
2410 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2411 }
2412
2413 #[test]
2414 fn find_and_load_returns_none_when_no_config() {
2415 let dir = test_dir("find-none");
2416 std::fs::create_dir(dir.path().join(".git")).unwrap();
2417
2418 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2419 assert!(result.is_none());
2420 }
2421
2422 #[test]
2423 fn find_and_load_finds_fallowrc_json() {
2424 let dir = test_dir("find-json");
2425 std::fs::create_dir(dir.path().join(".git")).unwrap();
2426 std::fs::write(
2427 dir.path().join(".fallowrc.json"),
2428 r#"{"entry": ["src/main.ts"]}"#,
2429 )
2430 .unwrap();
2431
2432 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2433 assert_eq!(config.entry, vec!["src/main.ts"]);
2434 assert!(path.ends_with(".fallowrc.json"));
2435 }
2436
2437 #[test]
2438 fn find_and_load_finds_fallowrc_jsonc() {
2439 let dir = test_dir("find-jsonc");
2440 std::fs::create_dir(dir.path().join(".git")).unwrap();
2441 std::fs::write(
2442 dir.path().join(".fallowrc.jsonc"),
2443 r#"{
2444 "entry": ["src/main.ts"]
2445 }"#,
2446 )
2447 .unwrap();
2448
2449 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2450 assert_eq!(config.entry, vec!["src/main.ts"]);
2451 assert!(path.ends_with(".fallowrc.jsonc"));
2452 }
2453
2454 #[test]
2455 fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2456 let dir = test_dir("find-json-vs-jsonc");
2457 std::fs::create_dir(dir.path().join(".git")).unwrap();
2458 std::fs::write(
2459 dir.path().join(".fallowrc.json"),
2460 r#"{"entry": ["from-json.ts"]}"#,
2461 )
2462 .unwrap();
2463 std::fs::write(
2464 dir.path().join(".fallowrc.jsonc"),
2465 r#"{"entry": ["from-jsonc.ts"]}"#,
2466 )
2467 .unwrap();
2468
2469 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2470 assert_eq!(config.entry, vec!["from-json.ts"]);
2471 assert!(path.ends_with(".fallowrc.json"));
2472 }
2473
2474 #[test]
2475 fn find_and_load_prefers_fallowrc_json_over_toml() {
2476 let dir = test_dir("find-priority");
2477 std::fs::create_dir(dir.path().join(".git")).unwrap();
2478 std::fs::write(
2479 dir.path().join(".fallowrc.json"),
2480 r#"{"entry": ["from-json.ts"]}"#,
2481 )
2482 .unwrap();
2483 std::fs::write(
2484 dir.path().join("fallow.toml"),
2485 "entry = [\"from-toml.ts\"]\n",
2486 )
2487 .unwrap();
2488
2489 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2490 assert_eq!(config.entry, vec!["from-json.ts"]);
2491 assert!(path.ends_with(".fallowrc.json"));
2492 }
2493
2494 #[test]
2495 fn shadowed_config_names_empty_when_single_config() {
2496 let dir = test_dir("shadow-single");
2497 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2498 assert!(shadowed_config_names(dir.path(), 0).is_empty());
2499 }
2500
2501 #[test]
2502 fn shadowed_config_names_reports_lower_precedence_toml() {
2503 let dir = test_dir("shadow-json-toml");
2504 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2505 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2506 assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2507 }
2508
2509 #[test]
2510 fn shadowed_config_names_reports_jsonc_sibling() {
2511 let dir = test_dir("shadow-json-jsonc");
2512 std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2513 std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2514 assert_eq!(
2515 shadowed_config_names(dir.path(), 0),
2516 vec![".fallowrc.jsonc"]
2517 );
2518 }
2519
2520 #[test]
2521 fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2522 let dir = test_dir("shadow-all-four");
2523 for name in CONFIG_NAMES {
2524 std::fs::write(dir.path().join(name), "").unwrap();
2525 }
2526 assert_eq!(
2527 shadowed_config_names(dir.path(), 0),
2528 vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2529 );
2530 }
2531
2532 #[test]
2533 fn shadowed_config_names_scoped_to_indices_after_winner() {
2534 let dir = test_dir("shadow-toml-dottoml");
2535 std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2536 std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2537 assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2538 }
2539
2540 #[test]
2541 fn find_and_load_warns_when_configs_coexist() {
2542 let dir = test_dir("coexist-warn");
2543 std::fs::create_dir(dir.path().join(".git")).unwrap();
2544 std::fs::write(
2545 dir.path().join(".fallowrc.json"),
2546 r#"{"entry": ["from-json.ts"]}"#,
2547 )
2548 .unwrap();
2549 std::fs::write(
2550 dir.path().join("fallow.toml"),
2551 "entry = [\"from-toml.ts\"]\n",
2552 )
2553 .unwrap();
2554
2555 let (result, captured) =
2556 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2557
2558 let (config, path) = result.unwrap().unwrap();
2559 assert_eq!(config.entry, vec!["from-json.ts"]);
2560 assert!(path.ends_with(".fallowrc.json"));
2561
2562 assert_eq!(captured.len(), 1);
2563 let (chosen, shadowed) = &captured[0];
2564 assert_eq!(chosen, ".fallowrc.json");
2565 assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2566 }
2567
2568 #[test]
2569 fn find_and_load_does_not_warn_for_single_config() {
2570 let dir = test_dir("coexist-none");
2571 std::fs::create_dir(dir.path().join(".git")).unwrap();
2572 std::fs::write(
2573 dir.path().join(".fallowrc.json"),
2574 r#"{"entry": ["only.ts"]}"#,
2575 )
2576 .unwrap();
2577
2578 let (result, captured) =
2579 capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2580 assert!(result.unwrap().is_some());
2581 assert!(captured.is_empty());
2582 }
2583
2584 #[test]
2585 fn find_and_load_warns_per_directory_independently() {
2586 let make = |name: &str| {
2587 let dir = test_dir(name);
2588 std::fs::create_dir(dir.path().join(".git")).unwrap();
2589 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2590 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2591 dir
2592 };
2593 let first = make("coexist-dir-a");
2594 let second = make("coexist-dir-b");
2595
2596 let ((), captured) = capture_coexisting_config_warnings(|| {
2597 FallowConfig::find_and_load(first.path()).unwrap();
2598 FallowConfig::find_and_load(second.path()).unwrap();
2599 });
2600
2601 assert_eq!(captured.len(), 2);
2602 assert!(captured.iter().all(|(chosen, shadowed)| {
2603 chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2604 }));
2605 }
2606
2607 #[test]
2608 fn explicit_load_does_not_warn_about_coexisting_configs() {
2609 let dir = test_dir("coexist-explicit");
2610 std::fs::write(
2611 dir.path().join(".fallowrc.json"),
2612 r#"{"entry": ["chosen.ts"]}"#,
2613 )
2614 .unwrap();
2615 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2616
2617 let chosen = dir.path().join("fallow.toml");
2618 let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2619 assert!(result.is_ok());
2620 assert!(captured.is_empty());
2621 }
2622
2623 #[test]
2624 fn find_and_load_finds_fallow_toml() {
2625 let dir = test_dir("find-toml");
2626 std::fs::create_dir(dir.path().join(".git")).unwrap();
2627 std::fs::write(
2628 dir.path().join("fallow.toml"),
2629 "entry = [\"src/index.ts\"]\n",
2630 )
2631 .unwrap();
2632
2633 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2634 assert_eq!(config.entry, vec!["src/index.ts"]);
2635 }
2636
2637 #[test]
2638 fn find_and_load_stops_at_git_dir() {
2639 let dir = test_dir("find-git-stop");
2640 let sub = dir.path().join("sub");
2641 std::fs::create_dir(&sub).unwrap();
2642 std::fs::create_dir(dir.path().join(".git")).unwrap();
2643 let result = FallowConfig::find_and_load(&sub).unwrap();
2644 assert!(result.is_none());
2645 }
2646
2647 #[test]
2648 fn find_and_load_walks_past_package_json_in_monorepo() {
2649 let dir = test_dir("find-monorepo");
2650 std::fs::create_dir(dir.path().join(".git")).unwrap();
2651 std::fs::write(
2652 dir.path().join(".fallowrc.json"),
2653 r#"{"entry": ["src/index.ts"]}"#,
2654 )
2655 .unwrap();
2656
2657 let sub = dir.path().join("packages").join("app");
2658 std::fs::create_dir_all(&sub).unwrap();
2659 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2660
2661 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2662 assert_eq!(config.entry, vec!["src/index.ts"]);
2663 assert_eq!(path, dir.path().join(".fallowrc.json"));
2664 }
2665
2666 #[test]
2667 fn find_and_load_sub_package_config_wins_over_root() {
2668 let dir = test_dir("find-monorepo-override");
2669 std::fs::create_dir(dir.path().join(".git")).unwrap();
2670 std::fs::write(
2671 dir.path().join(".fallowrc.json"),
2672 r#"{"entry": ["src/root.ts"]}"#,
2673 )
2674 .unwrap();
2675
2676 let sub = dir.path().join("packages").join("app");
2677 std::fs::create_dir_all(&sub).unwrap();
2678 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2679 std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2680
2681 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2682 assert_eq!(config.entry, vec!["src/sub.ts"]);
2683 assert_eq!(path, sub.join(".fallowrc.json"));
2684 }
2685
2686 #[test]
2687 fn find_and_load_stops_at_git_file_submodule() {
2688 let dir = test_dir("find-git-file");
2689 std::fs::create_dir(dir.path().join(".git")).unwrap();
2690 std::fs::write(
2691 dir.path().join(".fallowrc.json"),
2692 r#"{"entry": ["src/parent.ts"]}"#,
2693 )
2694 .unwrap();
2695
2696 let submodule = dir.path().join("vendor").join("lib");
2697 std::fs::create_dir_all(&submodule).unwrap();
2698 std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2699
2700 let result = FallowConfig::find_and_load(&submodule).unwrap();
2701 assert!(
2702 result.is_none(),
2703 "submodule boundary should stop config walk",
2704 );
2705 }
2706
2707 #[test]
2708 fn find_and_load_stops_at_hg_dir() {
2709 let dir = test_dir("find-hg-stop");
2710 let sub = dir.path().join("sub");
2711 std::fs::create_dir(&sub).unwrap();
2712 std::fs::create_dir(dir.path().join(".hg")).unwrap();
2713
2714 let result = FallowConfig::find_and_load(&sub).unwrap();
2715 assert!(result.is_none());
2716 }
2717
2718 #[test]
2719 fn find_and_load_returns_error_for_invalid_config() {
2720 let dir = test_dir("find-invalid");
2721 std::fs::create_dir(dir.path().join(".git")).unwrap();
2722 std::fs::write(
2723 dir.path().join(".fallowrc.json"),
2724 r"{ this is not valid json }",
2725 )
2726 .unwrap();
2727
2728 let result = FallowConfig::find_and_load(dir.path());
2729 assert!(result.is_err());
2730 }
2731
2732 #[test]
2733 fn load_toml_config_file() {
2734 let dir = test_dir("toml-config");
2735 let config_path = dir.path().join("fallow.toml");
2736 std::fs::write(
2737 &config_path,
2738 r#"
2739entry = ["src/index.ts"]
2740ignorePatterns = ["dist/**"]
2741
2742[rules]
2743unused-files = "warn"
2744
2745[duplicates]
2746minTokens = 100
2747"#,
2748 )
2749 .unwrap();
2750
2751 let config = FallowConfig::load(&config_path).unwrap();
2752 assert_eq!(config.entry, vec!["src/index.ts"]);
2753 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2754 assert_eq!(config.rules.unused_files, Severity::Warn);
2755 assert_eq!(config.duplicates.min_tokens, 100);
2756 }
2757
2758 #[test]
2759 fn extends_absolute_path_rejected() {
2760 let dir = test_dir("extends-absolute");
2761
2762 #[cfg(unix)]
2763 let abs_path = "/absolute/path/config.json";
2764 #[cfg(windows)]
2765 let abs_path = "C:\\absolute\\path\\config.json";
2766
2767 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2768 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2769
2770 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2771 assert!(result.is_err());
2772 let err_msg = format!("{}", result.unwrap_err());
2773 assert!(
2774 err_msg.contains("must be relative"),
2775 "Expected 'must be relative' error, got: {err_msg}"
2776 );
2777 }
2778
2779 #[test]
2780 fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2781 let dir = test_dir("extends-windows-absolute");
2782
2783 std::fs::write(
2784 dir.path().join(".fallowrc.json"),
2785 r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2786 )
2787 .unwrap();
2788
2789 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2790 assert!(result.is_err());
2791 let err_msg = format!("{}", result.unwrap_err());
2792 assert!(
2793 err_msg.contains("must be relative"),
2794 "Expected 'must be relative' error, got: {err_msg}"
2795 );
2796 }
2797
2798 #[cfg(windows)]
2799 #[test]
2800 fn extends_posix_rooted_absolute_path_rejected_on_windows() {
2801 let dir = test_dir("extends-posix-rooted-absolute");
2802
2803 std::fs::write(
2804 dir.path().join(".fallowrc.json"),
2805 r#"{"extends": ["/absolute/path/config.json"]}"#,
2806 )
2807 .unwrap();
2808
2809 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2810 assert!(result.is_err());
2811 let err_msg = format!("{}", result.unwrap_err());
2812 assert!(
2813 err_msg.contains("must be relative"),
2814 "Expected 'must be relative' error, got: {err_msg}"
2815 );
2816 }
2817
2818 #[test]
2819 fn resolve_production_mode_disables_dev_deps() {
2820 let config = FallowConfig {
2821 production: true.into(),
2822 ..Default::default()
2823 };
2824 let resolved = config.resolve(
2825 PathBuf::from("/tmp/test"),
2826 OutputFormat::Human,
2827 4,
2828 false,
2829 true,
2830 None,
2831 );
2832 assert!(resolved.production);
2833 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2834 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2835 assert_eq!(resolved.rules.unused_files, Severity::Error);
2836 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2837 }
2838
2839 #[test]
2840 fn include_entry_exports_deserializes_from_camelcase_json() {
2841 let json = r#"{ "includeEntryExports": true }"#;
2842 let config: FallowConfig = serde_json::from_str(json).unwrap();
2843 assert!(config.include_entry_exports);
2844 }
2845
2846 #[test]
2847 fn include_entry_exports_deserializes_from_camelcase_toml() {
2848 let toml_str = "includeEntryExports = true\n";
2849 let config: FallowConfig = toml::from_str(toml_str).unwrap();
2850 assert!(config.include_entry_exports);
2851 }
2852
2853 #[test]
2854 fn include_entry_exports_default_is_false() {
2855 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2856 assert!(!config.include_entry_exports);
2857 }
2858
2859 #[test]
2860 fn include_entry_exports_propagates_through_resolve() {
2861 let config = FallowConfig {
2862 include_entry_exports: true,
2863 auto_imports: false,
2864 cache: CacheConfig::default(),
2865 ..Default::default()
2866 };
2867 let resolved = config.resolve(
2868 PathBuf::from("/tmp/test"),
2869 OutputFormat::Human,
2870 1,
2871 true,
2872 true,
2873 None,
2874 );
2875 assert!(resolved.include_entry_exports);
2876 }
2877
2878 #[test]
2879 fn config_format_defaults_to_toml_for_unknown() {
2880 assert!(matches!(
2881 ConfigFormat::from_path(Path::new("config.yaml")),
2882 ConfigFormat::Toml
2883 ));
2884 assert!(matches!(
2885 ConfigFormat::from_path(Path::new("config")),
2886 ConfigFormat::Toml
2887 ));
2888 }
2889
2890 #[test]
2891 fn deep_merge_object_over_scalar_replaces() {
2892 let mut base = serde_json::json!("just a string");
2893 let overlay = serde_json::json!({"key": "value"});
2894 deep_merge_json(&mut base, overlay);
2895 assert_eq!(base, serde_json::json!({"key": "value"}));
2896 }
2897
2898 #[test]
2899 fn deep_merge_scalar_over_object_replaces() {
2900 let mut base = serde_json::json!({"key": "value"});
2901 let overlay = serde_json::json!(42);
2902 deep_merge_json(&mut base, overlay);
2903 assert_eq!(base, serde_json::json!(42));
2904 }
2905
2906 #[test]
2907 fn extends_non_string_non_array_ignored() {
2908 let dir = test_dir("extends-numeric");
2909 std::fs::write(
2910 dir.path().join(".fallowrc.json"),
2911 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2912 )
2913 .unwrap();
2914
2915 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2916 assert_eq!(config.entry, vec!["src/index.ts"]);
2917 }
2918
2919 #[test]
2920 fn extends_multiple_bases_later_wins() {
2921 let dir = test_dir("extends-multi-base");
2922
2923 std::fs::write(
2924 dir.path().join("base-a.json"),
2925 r#"{"rules": {"unused-files": "warn"}}"#,
2926 )
2927 .unwrap();
2928 std::fs::write(
2929 dir.path().join("base-b.json"),
2930 r#"{"rules": {"unused-files": "off"}}"#,
2931 )
2932 .unwrap();
2933 std::fs::write(
2934 dir.path().join(".fallowrc.json"),
2935 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2936 )
2937 .unwrap();
2938
2939 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2940 assert_eq!(config.rules.unused_files, Severity::Off);
2941 }
2942
2943 #[test]
2944 fn load_rejects_empty_security_request_receivers() {
2945 let dir = test_dir("empty-security-request-receivers");
2946 std::fs::write(
2947 dir.path().join(".fallowrc.json"),
2948 r#"{"security": {"requestReceivers": ["req", " "]}}"#,
2949 )
2950 .unwrap();
2951
2952 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2953 let err = result.expect_err("empty receiver should be rejected");
2954 assert!(
2955 err.to_string().contains("security.requestReceivers"),
2956 "error should name security.requestReceivers: {err}"
2957 );
2958 }
2959
2960 #[test]
2961 fn resolve_normalizes_security_request_receivers() {
2962 let dir = test_dir("normalize-security-request-receivers");
2963 std::fs::write(
2964 dir.path().join(".fallowrc.json"),
2965 r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
2966 )
2967 .unwrap();
2968
2969 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
2970 .unwrap()
2971 .resolve(
2972 dir.path().to_path_buf(),
2973 OutputFormat::Human,
2974 1,
2975 true,
2976 true,
2977 None,
2978 );
2979 assert_eq!(
2980 config.security.request_receivers,
2981 vec!["httpreq".to_string(), "r".to_string()]
2982 );
2983 }
2984
2985 #[test]
2986 fn fallow_config_deserialize_production() {
2987 let json_str = r#"{"production": true}"#;
2988 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2989 assert!(config.production);
2990 }
2991
2992 #[test]
2993 fn fallow_config_production_defaults_false() {
2994 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2995 assert!(!config.production);
2996 }
2997
2998 #[test]
2999 fn package_json_optional_dependency_names() {
3000 let pkg: PackageJson = serde_json::from_str(
3001 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3002 )
3003 .unwrap();
3004 let opt = pkg.optional_dependency_names();
3005 assert_eq!(opt.len(), 2);
3006 assert!(opt.contains(&"fsevents".to_string()));
3007 assert!(opt.contains(&"chokidar".to_string()));
3008 }
3009
3010 #[test]
3011 fn package_json_optional_deps_empty_when_missing() {
3012 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3013 assert!(pkg.optional_dependency_names().is_empty());
3014 }
3015
3016 #[test]
3017 fn find_config_path_returns_fallowrc_json() {
3018 let dir = test_dir("find-path-json");
3019 std::fs::create_dir(dir.path().join(".git")).unwrap();
3020 std::fs::write(
3021 dir.path().join(".fallowrc.json"),
3022 r#"{"entry": ["src/main.ts"]}"#,
3023 )
3024 .unwrap();
3025
3026 let path = FallowConfig::find_config_path(dir.path());
3027 assert!(path.is_some());
3028 assert!(path.unwrap().ends_with(".fallowrc.json"));
3029 }
3030
3031 #[test]
3032 fn find_config_path_returns_fallow_toml() {
3033 let dir = test_dir("find-path-toml");
3034 std::fs::create_dir(dir.path().join(".git")).unwrap();
3035 std::fs::write(
3036 dir.path().join("fallow.toml"),
3037 "entry = [\"src/main.ts\"]\n",
3038 )
3039 .unwrap();
3040
3041 let path = FallowConfig::find_config_path(dir.path());
3042 assert!(path.is_some());
3043 assert!(path.unwrap().ends_with("fallow.toml"));
3044 }
3045
3046 #[test]
3047 fn find_config_path_returns_dot_fallow_toml() {
3048 let dir = test_dir("find-path-dot-toml");
3049 std::fs::create_dir(dir.path().join(".git")).unwrap();
3050 std::fs::write(
3051 dir.path().join(".fallow.toml"),
3052 "entry = [\"src/main.ts\"]\n",
3053 )
3054 .unwrap();
3055
3056 let path = FallowConfig::find_config_path(dir.path());
3057 assert!(path.is_some());
3058 assert!(path.unwrap().ends_with(".fallow.toml"));
3059 }
3060
3061 #[test]
3062 fn find_config_path_prefers_json_over_toml() {
3063 let dir = test_dir("find-path-priority");
3064 std::fs::create_dir(dir.path().join(".git")).unwrap();
3065 std::fs::write(
3066 dir.path().join(".fallowrc.json"),
3067 r#"{"entry": ["json.ts"]}"#,
3068 )
3069 .unwrap();
3070 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3071
3072 let path = FallowConfig::find_config_path(dir.path());
3073 assert!(path.unwrap().ends_with(".fallowrc.json"));
3074 }
3075
3076 #[test]
3077 fn find_config_path_none_when_no_config() {
3078 let dir = test_dir("find-path-none");
3079 std::fs::create_dir(dir.path().join(".git")).unwrap();
3080
3081 let path = FallowConfig::find_config_path(dir.path());
3082 assert!(path.is_none());
3083 }
3084
3085 #[test]
3086 fn find_config_path_walks_past_package_json_in_monorepo() {
3087 let dir = test_dir("find-path-monorepo");
3088 std::fs::create_dir(dir.path().join(".git")).unwrap();
3089 std::fs::write(
3090 dir.path().join(".fallowrc.json"),
3091 r#"{"entry": ["src/index.ts"]}"#,
3092 )
3093 .unwrap();
3094
3095 let sub = dir.path().join("packages").join("app");
3096 std::fs::create_dir_all(&sub).unwrap();
3097 std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3098
3099 let path = FallowConfig::find_config_path(&sub).unwrap();
3100 assert_eq!(path, dir.path().join(".fallowrc.json"));
3101 }
3102
3103 #[test]
3104 fn extends_toml_base() {
3105 let dir = test_dir("extends-toml");
3106
3107 std::fs::write(
3108 dir.path().join("base.json"),
3109 r#"{"rules": {"unused-files": "warn"}}"#,
3110 )
3111 .unwrap();
3112 std::fs::write(
3113 dir.path().join("fallow.toml"),
3114 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3115 )
3116 .unwrap();
3117
3118 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3119 assert_eq!(config.rules.unused_files, Severity::Warn);
3120 assert_eq!(config.entry, vec!["src/index.ts"]);
3121 }
3122
3123 #[test]
3124 fn deep_merge_boolean_overlay() {
3125 let mut base = serde_json::json!(true);
3126 deep_merge_json(&mut base, serde_json::json!(false));
3127 assert_eq!(base, serde_json::json!(false));
3128 }
3129
3130 #[test]
3131 fn deep_merge_number_overlay() {
3132 let mut base = serde_json::json!(42);
3133 deep_merge_json(&mut base, serde_json::json!(99));
3134 assert_eq!(base, serde_json::json!(99));
3135 }
3136
3137 #[test]
3138 fn deep_merge_disjoint_objects() {
3139 let mut base = serde_json::json!({"a": 1});
3140 let overlay = serde_json::json!({"b": 2});
3141 deep_merge_json(&mut base, overlay);
3142 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3143 }
3144
3145 #[test]
3146 fn max_extends_depth_is_reasonable() {
3147 assert_eq!(MAX_EXTENDS_DEPTH, 10);
3148 }
3149
3150 #[test]
3151 fn config_names_has_four_entries() {
3152 assert_eq!(CONFIG_NAMES.len(), 4);
3153 for name in CONFIG_NAMES {
3154 assert!(
3155 name.starts_with('.') || name.starts_with("fallow"),
3156 "unexpected config name: {name}"
3157 );
3158 }
3159 }
3160
3161 #[test]
3162 fn package_json_peer_dependency_names() {
3163 let pkg: PackageJson = serde_json::from_str(
3164 r#"{
3165 "dependencies": {"react": "^18"},
3166 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3167 }"#,
3168 )
3169 .unwrap();
3170 let all = pkg.all_dependency_names();
3171 assert!(all.contains(&"react".to_string()));
3172 assert!(all.contains(&"react-dom".to_string()));
3173 assert!(all.contains(&"react-native".to_string()));
3174 }
3175
3176 #[test]
3177 fn package_json_scripts_field() {
3178 let pkg: PackageJson = serde_json::from_str(
3179 r#"{
3180 "scripts": {
3181 "build": "tsc",
3182 "test": "vitest",
3183 "lint": "fallow check"
3184 }
3185 }"#,
3186 )
3187 .unwrap();
3188 let scripts = pkg.scripts.unwrap();
3189 assert_eq!(scripts.len(), 3);
3190 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3191 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3192 }
3193
3194 #[test]
3195 fn extends_toml_chain() {
3196 let dir = test_dir("extends-toml-chain");
3197
3198 std::fs::write(
3199 dir.path().join("base.json"),
3200 r#"{"entry": ["src/base.ts"]}"#,
3201 )
3202 .unwrap();
3203 std::fs::write(
3204 dir.path().join("middle.json"),
3205 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3206 )
3207 .unwrap();
3208 std::fs::write(
3209 dir.path().join("fallow.toml"),
3210 "extends = [\"middle.json\"]\n",
3211 )
3212 .unwrap();
3213
3214 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3215 assert_eq!(config.entry, vec!["src/base.ts"]);
3216 assert_eq!(config.rules.unused_files, Severity::Off);
3217 }
3218
3219 #[test]
3220 fn find_and_load_walks_up_directories() {
3221 let dir = test_dir("find-walk-up");
3222 let sub = dir.path().join("src").join("deep");
3223 std::fs::create_dir_all(&sub).unwrap();
3224 std::fs::write(
3225 dir.path().join(".fallowrc.json"),
3226 r#"{"entry": ["src/main.ts"]}"#,
3227 )
3228 .unwrap();
3229 std::fs::create_dir(dir.path().join(".git")).unwrap();
3230
3231 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3232 assert_eq!(config.entry, vec!["src/main.ts"]);
3233 assert!(path.ends_with(".fallowrc.json"));
3234 }
3235
3236 #[test]
3237 fn json_schema_contains_entry_field() {
3238 let schema = FallowConfig::json_schema();
3239 let obj = schema.as_object().unwrap();
3240 let props = obj.get("properties").and_then(|v| v.as_object());
3241 assert!(props.is_some(), "schema should have properties");
3242 assert!(
3243 props.unwrap().contains_key("entry"),
3244 "schema should contain entry property"
3245 );
3246 }
3247
3248 #[test]
3249 fn fallow_config_json_duplicates_all_fields() {
3250 let json = r#"{
3251 "duplicates": {
3252 "enabled": true,
3253 "mode": "semantic",
3254 "minTokens": 200,
3255 "minLines": 20,
3256 "threshold": 10.5,
3257 "ignore": ["**/*.test.ts"],
3258 "skipLocal": true,
3259 "crossLanguage": true,
3260 "normalization": {
3261 "ignoreIdentifiers": true,
3262 "ignoreStringValues": false
3263 }
3264 }
3265 }"#;
3266 let config: FallowConfig = serde_json::from_str(json).unwrap();
3267 assert!(config.duplicates.enabled);
3268 assert_eq!(
3269 config.duplicates.mode,
3270 crate::config::DetectionMode::Semantic
3271 );
3272 assert_eq!(config.duplicates.min_tokens, 200);
3273 assert_eq!(config.duplicates.min_lines, 20);
3274 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3275 assert!(config.duplicates.skip_local);
3276 assert!(config.duplicates.cross_language);
3277 assert_eq!(
3278 config.duplicates.normalization.ignore_identifiers,
3279 Some(true)
3280 );
3281 assert_eq!(
3282 config.duplicates.normalization.ignore_string_values,
3283 Some(false)
3284 );
3285 }
3286
3287 #[test]
3288 fn normalize_url_basic() {
3289 assert_eq!(
3290 normalize_url_for_dedup("https://example.com/config.json"),
3291 "https://example.com/config.json"
3292 );
3293 }
3294
3295 #[test]
3296 fn normalize_url_trailing_slash() {
3297 assert_eq!(
3298 normalize_url_for_dedup("https://example.com/config/"),
3299 "https://example.com/config"
3300 );
3301 }
3302
3303 #[test]
3304 fn normalize_url_uppercase_scheme_and_host() {
3305 assert_eq!(
3306 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3307 "https://example.com/Config.json"
3308 );
3309 }
3310
3311 #[test]
3312 fn normalize_url_root_path() {
3313 assert_eq!(
3314 normalize_url_for_dedup("https://example.com/"),
3315 "https://example.com"
3316 );
3317 assert_eq!(
3318 normalize_url_for_dedup("https://example.com"),
3319 "https://example.com"
3320 );
3321 }
3322
3323 #[test]
3324 fn normalize_url_preserves_path_case() {
3325 assert_eq!(
3326 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3327 "https://github.com/Org/Repo/Fallow.json"
3328 );
3329 }
3330
3331 #[test]
3332 fn normalize_url_strips_query_string() {
3333 assert_eq!(
3334 normalize_url_for_dedup("https://example.com/config.json?v=1"),
3335 "https://example.com/config.json"
3336 );
3337 }
3338
3339 #[test]
3340 fn normalize_url_strips_fragment() {
3341 assert_eq!(
3342 normalize_url_for_dedup("https://example.com/config.json#section"),
3343 "https://example.com/config.json"
3344 );
3345 }
3346
3347 #[test]
3348 fn normalize_url_strips_query_and_fragment() {
3349 assert_eq!(
3350 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3351 "https://example.com/config.json"
3352 );
3353 }
3354
3355 #[test]
3356 fn normalize_url_default_https_port() {
3357 assert_eq!(
3358 normalize_url_for_dedup("https://example.com:443/config.json"),
3359 "https://example.com/config.json"
3360 );
3361 assert_eq!(
3362 normalize_url_for_dedup("https://example.com:8443/config.json"),
3363 "https://example.com:8443/config.json"
3364 );
3365 }
3366
3367 #[test]
3368 fn extends_http_rejected() {
3369 let dir = test_dir("http-rejected");
3370 std::fs::write(
3371 dir.path().join(".fallowrc.json"),
3372 r#"{"extends": "http://example.com/config.json"}"#,
3373 )
3374 .unwrap();
3375
3376 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3377 assert!(result.is_err());
3378 let err_msg = format!("{}", result.unwrap_err());
3379 assert!(
3380 err_msg.contains("https://"),
3381 "Expected https hint in error, got: {err_msg}"
3382 );
3383 assert!(
3384 err_msg.contains("http://"),
3385 "Expected http:// mention in error, got: {err_msg}"
3386 );
3387 }
3388
3389 #[test]
3390 fn extends_url_circular_detection() {
3391 let mut visited = FxHashSet::default();
3392 let url = "https://example.com/config.json";
3393 let normalized = normalize_url_for_dedup(url);
3394 visited.insert(normalized.clone());
3395
3396 assert!(
3397 !visited.insert(normalized),
3398 "Same URL should be detected as duplicate"
3399 );
3400 }
3401
3402 #[test]
3403 fn extends_url_circular_case_insensitive() {
3404 let mut visited = FxHashSet::default();
3405 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3406
3407 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3408 assert!(
3409 !visited.insert(normalized),
3410 "Case-different URLs should normalize to the same key"
3411 );
3412 }
3413
3414 #[test]
3415 fn extract_extends_array() {
3416 let mut value = serde_json::json!({
3417 "extends": ["a.json", "b.json"],
3418 "entry": ["src/index.ts"]
3419 });
3420 let extends = extract_extends(&mut value);
3421 assert_eq!(extends, vec!["a.json", "b.json"]);
3422 assert!(value.get("extends").is_none());
3423 assert!(value.get("entry").is_some());
3424 }
3425
3426 #[test]
3427 fn extract_extends_string_sugar() {
3428 let mut value = serde_json::json!({
3429 "extends": "base.json",
3430 "entry": ["src/index.ts"]
3431 });
3432 let extends = extract_extends(&mut value);
3433 assert_eq!(extends, vec!["base.json"]);
3434 }
3435
3436 #[test]
3437 fn extract_extends_none() {
3438 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3439 let extends = extract_extends(&mut value);
3440 assert!(extends.is_empty());
3441 }
3442
3443 #[test]
3444 fn url_timeout_default() {
3445 let timeout = url_timeout();
3446 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3447 }
3448
3449 #[test]
3450 fn extends_url_mixed_with_file_and_npm() {
3451 let dir = test_dir("url-mixed");
3452 std::fs::write(
3453 dir.path().join("local.json"),
3454 r#"{"rules": {"unused-files": "warn"}}"#,
3455 )
3456 .unwrap();
3457 std::fs::write(
3458 dir.path().join(".fallowrc.json"),
3459 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3460 )
3461 .unwrap();
3462
3463 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3464 assert!(result.is_err());
3465 let err_msg = format!("{}", result.unwrap_err());
3466 assert!(
3467 err_msg.contains("unreachable.invalid"),
3468 "Expected URL in error message, got: {err_msg}"
3469 );
3470 }
3471
3472 #[test]
3473 fn extends_https_url_unreachable_errors() {
3474 let dir = test_dir("url-unreachable");
3475 std::fs::write(
3476 dir.path().join(".fallowrc.json"),
3477 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3478 )
3479 .unwrap();
3480
3481 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3482 assert!(result.is_err());
3483 let err_msg = format!("{}", result.unwrap_err());
3484 assert!(
3485 err_msg.contains("unreachable.invalid"),
3486 "Expected URL in error, got: {err_msg}"
3487 );
3488 assert!(
3489 err_msg.contains("local path or npm:"),
3490 "Expected remediation hint, got: {err_msg}"
3491 );
3492 }
3493
3494 #[test]
3495 fn collect_unknown_rule_keys_flags_top_level_typo() {
3496 let merged = serde_json::json!({
3497 "rules": {
3498 "unsued-files": "warn",
3499 "unused-exports": "off"
3500 }
3501 });
3502 let findings = collect_unknown_rule_keys(&merged);
3503 assert_eq!(findings.len(), 1);
3504 assert_eq!(findings[0].context, "rules");
3505 assert_eq!(findings[0].key, "unsued-files");
3506 assert_eq!(findings[0].suggestion, Some("unused-files"));
3507 }
3508
3509 #[test]
3510 fn collect_unknown_rule_keys_flags_overrides_typo() {
3511 let merged = serde_json::json!({
3512 "overrides": [
3513 {
3514 "files": ["src/**/*.ts"],
3515 "rules": {
3516 "unsued-files": "warn"
3517 }
3518 },
3519 {
3520 "files": ["tests/**/*.ts"],
3521 "rules": {
3522 "circular-dependnecy": "off"
3523 }
3524 }
3525 ]
3526 });
3527 let findings = collect_unknown_rule_keys(&merged);
3528 assert_eq!(findings.len(), 2);
3529 assert_eq!(findings[0].context, "overrides[0].rules");
3530 assert_eq!(findings[1].context, "overrides[1].rules");
3531 assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3532 }
3533
3534 #[test]
3535 fn collect_unknown_rule_keys_empty_for_valid_config() {
3536 let merged = serde_json::json!({
3537 "rules": {
3538 "unused-files": "warn",
3539 "unused-file": "off",
3540 "circular-dependency": "off",
3541 "boundary-violations": "warn"
3542 },
3543 "overrides": [
3544 {
3545 "files": ["src/**"],
3546 "rules": {
3547 "unused-exports": "warn"
3548 }
3549 }
3550 ]
3551 });
3552 let findings = collect_unknown_rule_keys(&merged);
3553 assert!(
3554 findings.is_empty(),
3555 "valid rule names and aliases must not be flagged: {findings:?}"
3556 );
3557 }
3558
3559 #[test]
3560 fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3561 let merged = serde_json::json!({
3562 "entry": ["src/main.ts"]
3563 });
3564 let findings = collect_unknown_rule_keys(&merged);
3565 assert!(findings.is_empty());
3566 }
3567
3568 #[test]
3569 fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3570 let dir = test_dir("wiring");
3571 let path = dir.path().join(".fallowrc.json");
3572 let typo = format!(
3573 "wiring-probe-{}-{}",
3574 std::process::id(),
3575 std::time::SystemTime::now()
3576 .duration_since(std::time::UNIX_EPOCH)
3577 .map_or(0, |d| d.as_nanos())
3578 );
3579 std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3580
3581 let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3582
3583 assert!(
3584 config_res.is_ok(),
3585 "load should succeed in phase 1: {:?}",
3586 config_res.err()
3587 );
3588 assert_eq!(
3589 captured.len(),
3590 1,
3591 "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3592 );
3593 assert_eq!(captured[0].key, typo);
3594 assert_eq!(captured[0].context, "rules");
3595 }
3596
3597 #[test]
3598 fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3599 let dir = test_dir("misspelled-rule");
3600 std::fs::write(
3601 dir.path().join(".fallowrc.json"),
3602 r#"{"rules": {"unsued-files": "warn"}}"#,
3603 )
3604 .unwrap();
3605
3606 let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3607 .expect("load should succeed in phase 1");
3608
3609 assert_eq!(config.rules.unused_files, Severity::Error);
3610 }
3611
3612 #[test]
3613 fn validate_resolved_boundaries_passes_on_valid_config() {
3614 let dir = test_dir("boundaries-valid");
3615 let config = FallowConfig {
3616 boundaries: crate::BoundaryConfig {
3617 coverage: crate::BoundaryCoverageConfig::default(),
3618 calls: crate::BoundaryCallsConfig::default(),
3619 preset: None,
3620 zones: vec![
3621 crate::BoundaryZone {
3622 name: "ui".to_string(),
3623 patterns: vec!["src/components/**".to_string()],
3624 auto_discover: vec![],
3625 root: None,
3626 },
3627 crate::BoundaryZone {
3628 name: "db".to_string(),
3629 patterns: vec!["src/db/**".to_string()],
3630 auto_discover: vec![],
3631 root: None,
3632 },
3633 ],
3634 rules: vec![crate::BoundaryRule {
3635 from: "ui".to_string(),
3636 allow: vec!["db".to_string()],
3637 allow_type_only: vec![],
3638 }],
3639 },
3640 ..FallowConfig::default()
3641 };
3642 config
3643 .validate_resolved_boundaries(dir.path())
3644 .expect("valid config should pass");
3645 }
3646
3647 #[test]
3648 fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3649 let dir = test_dir("boundaries-unknown-zones");
3650 let config = FallowConfig {
3651 boundaries: crate::BoundaryConfig {
3652 coverage: crate::BoundaryCoverageConfig::default(),
3653 calls: crate::BoundaryCallsConfig::default(),
3654 preset: None,
3655 zones: vec![crate::BoundaryZone {
3656 name: "ui".to_string(),
3657 patterns: vec!["src/ui/**".to_string()],
3658 auto_discover: vec![],
3659 root: None,
3660 }],
3661 rules: vec![
3662 crate::BoundaryRule {
3663 from: "typo-from".to_string(),
3664 allow: vec!["typo-allow".to_string()],
3665 allow_type_only: vec!["typo-type-only".to_string()],
3666 },
3667 crate::BoundaryRule {
3668 from: "ui".to_string(),
3669 allow: vec!["another-typo".to_string()],
3670 allow_type_only: vec![],
3671 },
3672 ],
3673 },
3674 ..FallowConfig::default()
3675 };
3676
3677 let errors = config
3678 .validate_resolved_boundaries(dir.path())
3679 .expect_err("invalid zone refs should fail");
3680
3681 assert_eq!(errors.len(), 4, "got: {errors:?}");
3682
3683 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3684 assert!(
3685 rendered
3686 .iter()
3687 .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3688 );
3689 assert!(
3690 rendered
3691 .iter()
3692 .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3693 );
3694 assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3695 && m.contains("rules[0]")
3696 && m.contains("allowTypeOnly")));
3697 assert!(
3698 rendered.iter().any(|m| m.contains("another-typo")
3699 && m.contains("rules[1]")
3700 && m.contains("allow"))
3701 );
3702 }
3703
3704 #[test]
3705 fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3706 let dir = test_dir("boundaries-redundant-prefix");
3707 let config = FallowConfig {
3708 boundaries: crate::BoundaryConfig {
3709 coverage: crate::BoundaryCoverageConfig::default(),
3710 calls: crate::BoundaryCallsConfig::default(),
3711 preset: None,
3712 zones: vec![crate::BoundaryZone {
3713 name: "ui".to_string(),
3714 patterns: vec!["packages/app/src/**".to_string()],
3715 auto_discover: vec![],
3716 root: Some("packages/app/".to_string()),
3717 }],
3718 rules: vec![],
3719 },
3720 ..FallowConfig::default()
3721 };
3722
3723 let errors = config
3724 .validate_resolved_boundaries(dir.path())
3725 .expect_err("redundant root prefix should fail");
3726 assert_eq!(errors.len(), 1, "got: {errors:?}");
3727 let rendered = errors[0].to_string();
3728 assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3729 assert!(rendered.contains("zone 'ui'"));
3730 }
3731
3732 #[test]
3733 fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3734 let dir = test_dir("boundaries-mixed-errors");
3735 let config = FallowConfig {
3736 boundaries: crate::BoundaryConfig {
3737 coverage: crate::BoundaryCoverageConfig::default(),
3738 calls: crate::BoundaryCallsConfig::default(),
3739 preset: None,
3740 zones: vec![crate::BoundaryZone {
3741 name: "ui".to_string(),
3742 patterns: vec!["packages/app/src/**".to_string()],
3743 auto_discover: vec![],
3744 root: Some("packages/app/".to_string()),
3745 }],
3746 rules: vec![crate::BoundaryRule {
3747 from: "ui".to_string(),
3748 allow: vec!["typo-zone".to_string()],
3749 allow_type_only: vec![],
3750 }],
3751 },
3752 ..FallowConfig::default()
3753 };
3754 let errors = config
3755 .validate_resolved_boundaries(dir.path())
3756 .expect_err("mixed errors should fail");
3757 assert_eq!(errors.len(), 2, "got: {errors:?}");
3758 let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3759 assert!(
3760 rendered
3761 .iter()
3762 .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3763 );
3764 assert!(
3765 rendered
3766 .iter()
3767 .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3768 );
3769 }
3770
3771 #[test]
3772 fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3773 let dir = test_dir("boundaries-bulletproof");
3774 std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3775 let config = FallowConfig {
3776 boundaries: crate::BoundaryConfig {
3777 coverage: crate::BoundaryCoverageConfig::default(),
3778 calls: crate::BoundaryCallsConfig::default(),
3779 preset: Some(crate::BoundaryPreset::Bulletproof),
3780 zones: vec![],
3781 rules: vec![],
3782 },
3783 ..FallowConfig::default()
3784 };
3785 config
3786 .validate_resolved_boundaries(dir.path())
3787 .expect("Bulletproof with discoverable features should pass");
3788 }
3789}