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