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