1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9pub(super) const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
14
15pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
16
17const NPM_PREFIX: &str = "npm:";
19
20const HTTPS_PREFIX: &str = "https://";
22
23const HTTP_PREFIX: &str = "http://";
25
26const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
28
29pub(super) enum ConfigFormat {
31 Toml,
32 Json,
33}
34
35impl ConfigFormat {
36 pub(super) fn from_path(path: &Path) -> Self {
37 match path.extension().and_then(|e| e.to_str()) {
38 Some("json") => Self::Json,
39 _ => Self::Toml,
40 }
41 }
42}
43
44pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
47 match (base, overlay) {
48 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
49 for (key, value) in overlay_map {
50 if let Some(base_value) = base_map.get_mut(&key) {
51 deep_merge_json(base_value, value);
52 } else {
53 base_map.insert(key, value);
54 }
55 }
56 }
57 (base, overlay) => {
58 *base = overlay;
59 }
60 }
61}
62
63pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
64 let content = std::fs::read_to_string(path)
65 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
66
67 match ConfigFormat::from_path(path) {
68 ConfigFormat::Toml => {
69 let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
70 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
71 })?;
72 serde_json::to_value(toml_value).map_err(|e| {
73 miette::miette!(
74 "Failed to convert TOML to JSON for {}: {}",
75 path.display(),
76 e
77 )
78 })
79 }
80 ConfigFormat::Json => {
81 let mut stripped = String::new();
82 json_comments::StripComments::new(content.as_bytes())
83 .read_to_string(&mut stripped)
84 .map_err(|e| {
85 miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
86 })?;
87 serde_json::from_str(&stripped).map_err(|e| {
88 miette::miette!("Failed to parse config file {}: {}", path.display(), e)
89 })
90 }
91 }
92}
93
94fn resolve_confined(
99 base_dir: &Path,
100 resolved: &Path,
101 context: &str,
102 source_config: &Path,
103) -> Result<PathBuf, miette::Report> {
104 let canonical_base = dunce::canonicalize(base_dir)
105 .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
106 let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
107 miette::miette!(
108 "Config file not found: {} ({}, referenced from {}): {}",
109 resolved.display(),
110 context,
111 source_config.display(),
112 e
113 )
114 })?;
115 if !canonical_file.starts_with(&canonical_base) {
116 return Err(miette::miette!(
117 "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
118 resolved.display(),
119 base_dir.display(),
120 context,
121 source_config.display()
122 ));
123 }
124 Ok(canonical_file)
125}
126
127fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
129 if name.starts_with('@') && !name.contains('/') {
130 return Err(miette::miette!(
131 "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
132 name,
133 source_config.display()
134 ));
135 }
136 if name.split('/').any(|c| c == ".." || c == ".") {
137 return Err(miette::miette!(
138 "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
139 name,
140 source_config.display()
141 ));
142 }
143 Ok(())
144}
145
146fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
153 if specifier.starts_with('@') {
154 let mut slashes = 0;
157 for (i, ch) in specifier.char_indices() {
158 if ch == '/' {
159 slashes += 1;
160 if slashes == 2 {
161 return (&specifier[..i], Some(&specifier[i + 1..]));
162 }
163 }
164 }
165 (specifier, None)
167 } else if let Some(slash) = specifier.find('/') {
168 (&specifier[..slash], Some(&specifier[slash + 1..]))
169 } else {
170 (specifier, None)
171 }
172}
173
174fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
181 let exports = pkg.get("exports")?;
182 match exports {
183 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
184 serde_json::Value::Object(map) => {
185 let dot_export = map.get(".")?;
186 match dot_export {
187 serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
188 serde_json::Value::Object(conditions) => {
189 for key in ["default", "node", "import", "require"] {
190 if let Some(serde_json::Value::String(s)) = conditions.get(key) {
191 return Some(package_dir.join(s.as_str()));
192 }
193 }
194 None
195 }
196 _ => None,
197 }
198 }
199 _ => None,
202 }
203}
204
205fn find_config_in_npm_package(
215 package_dir: &Path,
216 source_config: &Path,
217) -> Result<PathBuf, miette::Report> {
218 let pkg_json_path = package_dir.join("package.json");
219 if pkg_json_path.exists() {
220 let content = std::fs::read_to_string(&pkg_json_path)
221 .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
222 let pkg: serde_json::Value = serde_json::from_str(&content)
223 .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
224 if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
225 && config_path.exists()
226 {
227 return resolve_confined(
228 package_dir,
229 &config_path,
230 "package.json exports",
231 source_config,
232 );
233 }
234 if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
235 let main_path = package_dir.join(main);
236 if main_path.exists() {
237 return resolve_confined(
238 package_dir,
239 &main_path,
240 "package.json main",
241 source_config,
242 );
243 }
244 }
245 }
246
247 for config_name in CONFIG_NAMES {
248 let config_path = package_dir.join(config_name);
249 if config_path.exists() {
250 return resolve_confined(
251 package_dir,
252 &config_path,
253 "config name fallback",
254 source_config,
255 );
256 }
257 }
258
259 Err(miette::miette!(
260 "No fallow config found in npm package at {}. \
261 Expected package.json with main/exports pointing to a config file, \
262 or one of: {}",
263 package_dir.display(),
264 CONFIG_NAMES.join(", ")
265 ))
266}
267
268fn resolve_npm_package(
274 config_dir: &Path,
275 specifier: &str,
276 source_config: &Path,
277) -> Result<PathBuf, miette::Report> {
278 let specifier = specifier.trim();
279 if specifier.is_empty() {
280 return Err(miette::miette!(
281 "Empty npm specifier in extends (in {})",
282 source_config.display()
283 ));
284 }
285
286 let (package_name, subpath) = parse_npm_specifier(specifier);
287 validate_npm_package_name(package_name, source_config)?;
288
289 let mut dir = Some(config_dir);
290 while let Some(d) = dir {
291 let candidate = d.join("node_modules").join(package_name);
292 if candidate.is_dir() {
293 return if let Some(sub) = subpath {
294 let file = candidate.join(sub);
295 if file.exists() {
296 resolve_confined(
297 &candidate,
298 &file,
299 &format!("subpath '{sub}'"),
300 source_config,
301 )
302 } else {
303 Err(miette::miette!(
304 "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
305 file.display(),
306 sub,
307 candidate.display(),
308 source_config.display()
309 ))
310 }
311 } else {
312 find_config_in_npm_package(&candidate, source_config)
313 };
314 }
315 dir = d.parent();
316 }
317
318 Err(miette::miette!(
319 "npm package '{}' not found. \
320 Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
321 If this package should be available, install it and ensure it is listed in your project's dependencies",
322 package_name,
323 package_name,
324 config_dir.display(),
325 source_config.display()
326 ))
327}
328
329fn normalize_url_for_dedup(url: &str) -> String {
336 let Some((scheme, rest)) = url.split_once("://") else {
338 return url.to_string();
339 };
340 let scheme = scheme.to_ascii_lowercase();
341
342 let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
344 let authority = authority.to_ascii_lowercase();
345
346 let authority = authority.strip_suffix(":443").unwrap_or(&authority);
348
349 let path = path.split_once('#').map_or(path, |(p, _)| p);
351 let path = path.split_once('?').map_or(path, |(p, _)| p);
352 let path = path.strip_suffix('/').unwrap_or(path);
353
354 if path.is_empty() {
355 format!("{scheme}://{authority}")
356 } else {
357 format!("{scheme}://{authority}/{path}")
358 }
359}
360
361fn url_timeout() -> Duration {
366 std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
367 .ok()
368 .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
369 .map_or(
370 Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
371 Duration::from_secs,
372 )
373}
374
375const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
378
379fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
384 let timeout = url_timeout();
385 let agent = ureq::Agent::config_builder()
386 .timeout_global(Some(timeout))
387 .https_only(true)
388 .build()
389 .new_agent();
390
391 let mut response = agent.get(url).call().map_err(|e| {
392 miette::miette!(
393 "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
394 If this URL is unavailable, use a local path or npm: specifier instead"
395 )
396 })?;
397
398 let body = response
399 .body_mut()
400 .with_config()
401 .limit(MAX_URL_CONFIG_BYTES)
402 .read_to_string()
403 .map_err(|e| {
404 miette::miette!(
405 "Failed to read response body from {url} (referenced from {source}): {e}"
406 )
407 })?;
408
409 let mut stripped = String::new();
411 json_comments::StripComments::new(body.as_bytes())
412 .read_to_string(&mut stripped)
413 .map_err(|e| {
414 miette::miette!(
415 "Failed to strip comments from remote config {url} (referenced from {source}): {e}"
416 )
417 })?;
418
419 serde_json::from_str(&stripped).map_err(|e| {
420 miette::miette!(
421 "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
422 Only JSON/JSONC is supported for URL-sourced configs"
423 )
424 })
425}
426
427fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
429 value
430 .as_object_mut()
431 .and_then(|obj| obj.remove("extends"))
432 .and_then(|v| match v {
433 serde_json::Value::Array(arr) => Some(
434 arr.into_iter()
435 .filter_map(|v| v.as_str().map(String::from))
436 .collect::<Vec<_>>(),
437 ),
438 serde_json::Value::String(s) => Some(vec![s]),
439 _ => None,
440 })
441 .unwrap_or_default()
442}
443
444fn resolve_url_extends(
449 url: &str,
450 visited: &mut FxHashSet<String>,
451 depth: usize,
452) -> Result<serde_json::Value, miette::Report> {
453 if depth >= MAX_EXTENDS_DEPTH {
454 return Err(miette::miette!(
455 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
456 ));
457 }
458
459 let normalized = normalize_url_for_dedup(url);
460 if !visited.insert(normalized) {
461 return Err(miette::miette!(
462 "Circular extends detected: {url} was already visited in the extends chain"
463 ));
464 }
465
466 let mut value = fetch_url_config(url, url)?;
467 let extends = extract_extends(&mut value);
468
469 if extends.is_empty() {
470 return Ok(value);
471 }
472
473 let mut merged = serde_json::Value::Object(serde_json::Map::new());
474
475 for entry in &extends {
476 let base = if entry.starts_with(HTTPS_PREFIX) {
477 resolve_url_extends(entry, visited, depth + 1)?
478 } else if entry.starts_with(HTTP_PREFIX) {
479 return Err(miette::miette!(
480 "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
481 Change the URL to use https:// instead",
482 entry,
483 url
484 ));
485 } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
486 let cwd = std::env::current_dir().map_err(|e| {
490 miette::miette!(
491 "Cannot resolve npm: specifier from URL-sourced config: \
492 failed to determine current directory: {e}"
493 )
494 })?;
495 tracing::warn!(
496 "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
497 current working directory for node_modules lookup"
498 );
499 let path_placeholder = PathBuf::from(url);
500 let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
501 resolve_extends_file(&npm_path, visited, depth + 1)?
502 } else {
503 return Err(miette::miette!(
504 "Relative paths in 'extends' are not supported when the base config was \
505 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
506 instead. Got: '{entry}'"
507 ));
508 };
509 deep_merge_json(&mut merged, base);
510 }
511
512 deep_merge_json(&mut merged, value);
513 Ok(merged)
514}
515
516fn resolve_extends_file(
522 path: &Path,
523 visited: &mut FxHashSet<String>,
524 depth: usize,
525) -> Result<serde_json::Value, miette::Report> {
526 if depth >= MAX_EXTENDS_DEPTH {
527 return Err(miette::miette!(
528 "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
529 path.display()
530 ));
531 }
532
533 let canonical = dunce::canonicalize(path).map_err(|e| {
534 miette::miette!(
535 "Config file not found or unresolvable: {}: {}",
536 path.display(),
537 e
538 )
539 })?;
540
541 if !visited.insert(canonical.to_string_lossy().into_owned()) {
542 return Err(miette::miette!(
543 "Circular extends detected: {} was already visited in the extends chain",
544 path.display()
545 ));
546 }
547
548 let mut value = parse_config_to_value(path)?;
549 let extends = extract_extends(&mut value);
550
551 if extends.is_empty() {
552 return Ok(value);
553 }
554
555 let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
556 let mut merged = serde_json::Value::Object(serde_json::Map::new());
557
558 for extend_path_str in &extends {
559 let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
560 resolve_url_extends(extend_path_str, visited, depth + 1)?
561 } else if extend_path_str.starts_with(HTTP_PREFIX) {
562 return Err(miette::miette!(
563 "URL extends must use https://, got http:// URL '{}' (in {}). \
564 Change the URL to use https:// instead",
565 extend_path_str,
566 path.display()
567 ));
568 } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
569 let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
570 resolve_extends_file(&npm_path, visited, depth + 1)?
571 } else {
572 if Path::new(extend_path_str).is_absolute() {
573 return Err(miette::miette!(
574 "extends paths must be relative, got absolute path: {} (in {})",
575 extend_path_str,
576 path.display()
577 ));
578 }
579 let p = config_dir.join(extend_path_str);
580 if !p.exists() {
581 return Err(miette::miette!(
582 "Extended config file not found: {} (referenced from {})",
583 p.display(),
584 path.display()
585 ));
586 }
587 resolve_extends_file(&p, visited, depth + 1)?
588 };
589 deep_merge_json(&mut merged, base);
590 }
591
592 deep_merge_json(&mut merged, value);
593 Ok(merged)
594}
595
596pub(super) fn resolve_extends(
600 path: &Path,
601 visited: &mut FxHashSet<String>,
602 depth: usize,
603) -> Result<serde_json::Value, miette::Report> {
604 resolve_extends_file(path, visited, depth)
605}
606
607impl FallowConfig {
608 pub fn load(path: &Path) -> Result<Self, miette::Report> {
621 let mut visited = FxHashSet::default();
622 let merged = resolve_extends(path, &mut visited, 0)?;
623
624 serde_json::from_value(merged).map_err(|e| {
625 miette::miette!(
626 "Failed to deserialize config from {}: {}",
627 path.display(),
628 e
629 )
630 })
631 }
632
633 #[must_use]
636 pub fn find_config_path(start: &Path) -> Option<PathBuf> {
637 let mut dir = start;
638 loop {
639 for name in CONFIG_NAMES {
640 let candidate = dir.join(name);
641 if candidate.exists() {
642 return Some(candidate);
643 }
644 }
645 if dir.join(".git").exists() || dir.join("package.json").exists() {
646 break;
647 }
648 dir = dir.parent()?;
649 }
650 None
651 }
652
653 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
659 let mut dir = start;
660 loop {
661 for name in CONFIG_NAMES {
662 let candidate = dir.join(name);
663 if candidate.exists() {
664 match Self::load(&candidate) {
665 Ok(config) => return Ok(Some((config, candidate))),
666 Err(e) => {
667 return Err(format!("Failed to parse {}: {e}", candidate.display()));
668 }
669 }
670 }
671 }
672 if dir.join(".git").exists() || dir.join("package.json").exists() {
674 break;
675 }
676 dir = match dir.parent() {
677 Some(parent) => parent,
678 None => break,
679 };
680 }
681 Ok(None)
682 }
683
684 #[must_use]
686 pub fn json_schema() -> serde_json::Value {
687 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use std::io::Read as _;
694
695 use super::*;
696 use crate::PackageJson;
697 use crate::config::boundaries::BoundaryConfig;
698 use crate::config::duplicates_config::DuplicatesConfig;
699 use crate::config::format::OutputFormat;
700 use crate::config::health::HealthConfig;
701 use crate::config::rules::{RulesConfig, Severity};
702
703 fn test_dir(_name: &str) -> tempfile::TempDir {
705 tempfile::tempdir().expect("create temp dir")
706 }
707
708 #[test]
709 fn fallow_config_deserialize_minimal() {
710 let toml_str = r#"
711entry = ["src/main.ts"]
712"#;
713 let config: FallowConfig = toml::from_str(toml_str).unwrap();
714 assert_eq!(config.entry, vec!["src/main.ts"]);
715 assert!(config.ignore_patterns.is_empty());
716 }
717
718 #[test]
719 fn fallow_config_deserialize_ignore_exports() {
720 let toml_str = r#"
721[[ignoreExports]]
722file = "src/types/*.ts"
723exports = ["*"]
724
725[[ignoreExports]]
726file = "src/constants.ts"
727exports = ["FOO", "BAR"]
728"#;
729 let config: FallowConfig = toml::from_str(toml_str).unwrap();
730 assert_eq!(config.ignore_exports.len(), 2);
731 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
732 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
733 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
734 }
735
736 #[test]
737 fn fallow_config_deserialize_ignore_dependencies() {
738 let toml_str = r#"
739ignoreDependencies = ["autoprefixer", "postcss"]
740"#;
741 let config: FallowConfig = toml::from_str(toml_str).unwrap();
742 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
743 }
744
745 #[test]
746 fn fallow_config_resolve_default_ignores() {
747 let config = FallowConfig {
748 schema: None,
749 extends: vec![],
750 entry: vec![],
751 ignore_patterns: vec![],
752 framework: vec![],
753 workspaces: None,
754 ignore_dependencies: vec![],
755 ignore_exports: vec![],
756 duplicates: DuplicatesConfig::default(),
757 health: HealthConfig::default(),
758 rules: RulesConfig::default(),
759 boundaries: BoundaryConfig::default(),
760 production: false,
761 plugins: vec![],
762 dynamically_loaded: vec![],
763 overrides: vec![],
764 regression: None,
765 codeowners: None,
766 public_packages: vec![],
767 };
768 let resolved = config.resolve(
769 PathBuf::from("/tmp/test"),
770 OutputFormat::Human,
771 4,
772 true,
773 true,
774 );
775
776 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
778 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
779 assert!(resolved.ignore_patterns.is_match("build/output.js"));
780 assert!(resolved.ignore_patterns.is_match(".git/config"));
781 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
782 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
783 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
784 }
785
786 #[test]
787 fn fallow_config_resolve_custom_ignores() {
788 let config = FallowConfig {
789 schema: None,
790 extends: vec![],
791 entry: vec!["src/**/*.ts".to_string()],
792 ignore_patterns: vec!["**/*.generated.ts".to_string()],
793 framework: vec![],
794 workspaces: None,
795 ignore_dependencies: vec![],
796 ignore_exports: vec![],
797 duplicates: DuplicatesConfig::default(),
798 health: HealthConfig::default(),
799 rules: RulesConfig::default(),
800 boundaries: BoundaryConfig::default(),
801 production: false,
802 plugins: vec![],
803 dynamically_loaded: vec![],
804 overrides: vec![],
805 regression: None,
806 codeowners: None,
807 public_packages: vec![],
808 };
809 let resolved = config.resolve(
810 PathBuf::from("/tmp/test"),
811 OutputFormat::Json,
812 4,
813 false,
814 true,
815 );
816
817 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
818 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
819 assert!(matches!(resolved.output, OutputFormat::Json));
820 assert!(!resolved.no_cache);
821 }
822
823 #[test]
824 fn fallow_config_resolve_cache_dir() {
825 let config = FallowConfig {
826 schema: None,
827 extends: vec![],
828 entry: vec![],
829 ignore_patterns: vec![],
830 framework: vec![],
831 workspaces: None,
832 ignore_dependencies: vec![],
833 ignore_exports: vec![],
834 duplicates: DuplicatesConfig::default(),
835 health: HealthConfig::default(),
836 rules: RulesConfig::default(),
837 boundaries: BoundaryConfig::default(),
838 production: false,
839 plugins: vec![],
840 dynamically_loaded: vec![],
841 overrides: vec![],
842 regression: None,
843 codeowners: None,
844 public_packages: vec![],
845 };
846 let resolved = config.resolve(
847 PathBuf::from("/tmp/project"),
848 OutputFormat::Human,
849 4,
850 true,
851 true,
852 );
853 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
854 assert!(resolved.no_cache);
855 }
856
857 #[test]
858 fn package_json_entry_points_main() {
859 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
860 let entries = pkg.entry_points();
861 assert!(entries.contains(&"dist/index.js".to_string()));
862 }
863
864 #[test]
865 fn package_json_entry_points_module() {
866 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
867 let entries = pkg.entry_points();
868 assert!(entries.contains(&"dist/index.mjs".to_string()));
869 }
870
871 #[test]
872 fn package_json_entry_points_types() {
873 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
874 let entries = pkg.entry_points();
875 assert!(entries.contains(&"dist/index.d.ts".to_string()));
876 }
877
878 #[test]
879 fn package_json_entry_points_bin_string() {
880 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
881 let entries = pkg.entry_points();
882 assert!(entries.contains(&"bin/cli.js".to_string()));
883 }
884
885 #[test]
886 fn package_json_entry_points_bin_object() {
887 let pkg: PackageJson =
888 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
889 .unwrap();
890 let entries = pkg.entry_points();
891 assert!(entries.contains(&"bin/cli.js".to_string()));
892 assert!(entries.contains(&"bin/serve.js".to_string()));
893 }
894
895 #[test]
896 fn package_json_entry_points_exports_string() {
897 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
898 let entries = pkg.entry_points();
899 assert!(entries.contains(&"./dist/index.js".to_string()));
900 }
901
902 #[test]
903 fn package_json_entry_points_exports_object() {
904 let pkg: PackageJson = serde_json::from_str(
905 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
906 )
907 .unwrap();
908 let entries = pkg.entry_points();
909 assert!(entries.contains(&"./dist/index.mjs".to_string()));
910 assert!(entries.contains(&"./dist/index.cjs".to_string()));
911 }
912
913 #[test]
914 fn package_json_dependency_names() {
915 let pkg: PackageJson = serde_json::from_str(
916 r#"{
917 "dependencies": {"react": "^18", "lodash": "^4"},
918 "devDependencies": {"typescript": "^5"},
919 "peerDependencies": {"react-dom": "^18"}
920 }"#,
921 )
922 .unwrap();
923
924 let all = pkg.all_dependency_names();
925 assert!(all.contains(&"react".to_string()));
926 assert!(all.contains(&"lodash".to_string()));
927 assert!(all.contains(&"typescript".to_string()));
928 assert!(all.contains(&"react-dom".to_string()));
929
930 let prod = pkg.production_dependency_names();
931 assert!(prod.contains(&"react".to_string()));
932 assert!(!prod.contains(&"typescript".to_string()));
933
934 let dev = pkg.dev_dependency_names();
935 assert!(dev.contains(&"typescript".to_string()));
936 assert!(!dev.contains(&"react".to_string()));
937 }
938
939 #[test]
940 fn package_json_no_dependencies() {
941 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
942 assert!(pkg.all_dependency_names().is_empty());
943 assert!(pkg.production_dependency_names().is_empty());
944 assert!(pkg.dev_dependency_names().is_empty());
945 assert!(pkg.entry_points().is_empty());
946 }
947
948 #[test]
949 fn rules_deserialize_toml_kebab_case() {
950 let toml_str = r#"
951[rules]
952unused-files = "error"
953unused-exports = "warn"
954unused-types = "off"
955"#;
956 let config: FallowConfig = toml::from_str(toml_str).unwrap();
957 assert_eq!(config.rules.unused_files, Severity::Error);
958 assert_eq!(config.rules.unused_exports, Severity::Warn);
959 assert_eq!(config.rules.unused_types, Severity::Off);
960 assert_eq!(config.rules.unresolved_imports, Severity::Error);
962 }
963
964 #[test]
965 fn config_without_rules_defaults_to_error() {
966 let toml_str = r#"
967entry = ["src/main.ts"]
968"#;
969 let config: FallowConfig = toml::from_str(toml_str).unwrap();
970 assert_eq!(config.rules.unused_files, Severity::Error);
971 assert_eq!(config.rules.unused_exports, Severity::Error);
972 }
973
974 #[test]
975 fn fallow_config_denies_unknown_fields() {
976 let toml_str = r"
977unknown_field = true
978";
979 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
980 assert!(result.is_err());
981 }
982
983 #[test]
984 fn fallow_config_deserialize_json() {
985 let json_str = r#"{"entry": ["src/main.ts"]}"#;
986 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
987 assert_eq!(config.entry, vec!["src/main.ts"]);
988 }
989
990 #[test]
991 fn fallow_config_deserialize_jsonc() {
992 let jsonc_str = r#"{
993 // This is a comment
994 "entry": ["src/main.ts"],
995 "rules": {
996 "unused-files": "warn"
997 }
998 }"#;
999 let mut stripped = String::new();
1000 json_comments::StripComments::new(jsonc_str.as_bytes())
1001 .read_to_string(&mut stripped)
1002 .unwrap();
1003 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1004 assert_eq!(config.entry, vec!["src/main.ts"]);
1005 assert_eq!(config.rules.unused_files, Severity::Warn);
1006 }
1007
1008 #[test]
1009 fn fallow_config_json_with_schema_field() {
1010 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1011 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1012 assert_eq!(config.entry, vec!["src/main.ts"]);
1013 }
1014
1015 #[test]
1016 fn fallow_config_json_schema_generation() {
1017 let schema = FallowConfig::json_schema();
1018 assert!(schema.is_object());
1019 let obj = schema.as_object().unwrap();
1020 assert!(obj.contains_key("properties"));
1021 }
1022
1023 #[test]
1024 fn config_format_detection() {
1025 assert!(matches!(
1026 ConfigFormat::from_path(Path::new("fallow.toml")),
1027 ConfigFormat::Toml
1028 ));
1029 assert!(matches!(
1030 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1031 ConfigFormat::Json
1032 ));
1033 assert!(matches!(
1034 ConfigFormat::from_path(Path::new(".fallow.toml")),
1035 ConfigFormat::Toml
1036 ));
1037 }
1038
1039 #[test]
1040 fn config_names_priority_order() {
1041 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1042 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
1043 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
1044 }
1045
1046 #[test]
1047 fn load_json_config_file() {
1048 let dir = test_dir("json-config");
1049 let config_path = dir.path().join(".fallowrc.json");
1050 std::fs::write(
1051 &config_path,
1052 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1053 )
1054 .unwrap();
1055
1056 let config = FallowConfig::load(&config_path).unwrap();
1057 assert_eq!(config.entry, vec!["src/index.ts"]);
1058 assert_eq!(config.rules.unused_exports, Severity::Warn);
1059 }
1060
1061 #[test]
1062 fn load_jsonc_config_file() {
1063 let dir = test_dir("jsonc-config");
1064 let config_path = dir.path().join(".fallowrc.json");
1065 std::fs::write(
1066 &config_path,
1067 r#"{
1068 // Entry points for analysis
1069 "entry": ["src/index.ts"],
1070 /* Block comment */
1071 "rules": {
1072 "unused-exports": "warn"
1073 }
1074 }"#,
1075 )
1076 .unwrap();
1077
1078 let config = FallowConfig::load(&config_path).unwrap();
1079 assert_eq!(config.entry, vec!["src/index.ts"]);
1080 assert_eq!(config.rules.unused_exports, Severity::Warn);
1081 }
1082
1083 #[test]
1084 fn json_config_ignore_dependencies_camel_case() {
1085 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1086 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1087 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1088 }
1089
1090 #[test]
1091 fn json_config_all_fields() {
1092 let json_str = r#"{
1093 "ignoreDependencies": ["lodash"],
1094 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1095 "rules": {
1096 "unused-files": "off",
1097 "unused-exports": "warn",
1098 "unused-dependencies": "error",
1099 "unused-dev-dependencies": "off",
1100 "unused-types": "warn",
1101 "unused-enum-members": "error",
1102 "unused-class-members": "off",
1103 "unresolved-imports": "warn",
1104 "unlisted-dependencies": "error",
1105 "duplicate-exports": "off"
1106 },
1107 "duplicates": {
1108 "minTokens": 100,
1109 "minLines": 10,
1110 "skipLocal": true
1111 }
1112 }"#;
1113 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1114 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1115 assert_eq!(config.rules.unused_files, Severity::Off);
1116 assert_eq!(config.rules.unused_exports, Severity::Warn);
1117 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1118 assert_eq!(config.duplicates.min_tokens, 100);
1119 assert_eq!(config.duplicates.min_lines, 10);
1120 assert!(config.duplicates.skip_local);
1121 }
1122
1123 #[test]
1126 fn extends_single_base() {
1127 let dir = test_dir("extends-single");
1128
1129 std::fs::write(
1130 dir.path().join("base.json"),
1131 r#"{"rules": {"unused-files": "warn"}}"#,
1132 )
1133 .unwrap();
1134 std::fs::write(
1135 dir.path().join(".fallowrc.json"),
1136 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1137 )
1138 .unwrap();
1139
1140 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1141 assert_eq!(config.rules.unused_files, Severity::Warn);
1142 assert_eq!(config.entry, vec!["src/index.ts"]);
1143 assert_eq!(config.rules.unused_exports, Severity::Error);
1145 }
1146
1147 #[test]
1148 fn extends_overlay_overrides_base() {
1149 let dir = test_dir("extends-overlay");
1150
1151 std::fs::write(
1152 dir.path().join("base.json"),
1153 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1154 )
1155 .unwrap();
1156 std::fs::write(
1157 dir.path().join(".fallowrc.json"),
1158 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1159 )
1160 .unwrap();
1161
1162 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1163 assert_eq!(config.rules.unused_files, Severity::Error);
1165 assert_eq!(config.rules.unused_exports, Severity::Off);
1167 }
1168
1169 #[test]
1170 fn extends_chained() {
1171 let dir = test_dir("extends-chained");
1172
1173 std::fs::write(
1174 dir.path().join("grandparent.json"),
1175 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1176 )
1177 .unwrap();
1178 std::fs::write(
1179 dir.path().join("parent.json"),
1180 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1181 )
1182 .unwrap();
1183 std::fs::write(
1184 dir.path().join(".fallowrc.json"),
1185 r#"{"extends": ["parent.json"]}"#,
1186 )
1187 .unwrap();
1188
1189 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1190 assert_eq!(config.rules.unused_files, Severity::Warn);
1192 assert_eq!(config.rules.unused_exports, Severity::Warn);
1194 }
1195
1196 #[test]
1197 fn extends_circular_detected() {
1198 let dir = test_dir("extends-circular");
1199
1200 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1201 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1202
1203 let result = FallowConfig::load(&dir.path().join("a.json"));
1204 assert!(result.is_err());
1205 let err_msg = format!("{}", result.unwrap_err());
1206 assert!(
1207 err_msg.contains("Circular extends"),
1208 "Expected circular error, got: {err_msg}"
1209 );
1210 }
1211
1212 #[test]
1213 fn extends_missing_file_errors() {
1214 let dir = test_dir("extends-missing");
1215
1216 std::fs::write(
1217 dir.path().join(".fallowrc.json"),
1218 r#"{"extends": ["nonexistent.json"]}"#,
1219 )
1220 .unwrap();
1221
1222 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1223 assert!(result.is_err());
1224 let err_msg = format!("{}", result.unwrap_err());
1225 assert!(
1226 err_msg.contains("not found"),
1227 "Expected not found error, got: {err_msg}"
1228 );
1229 }
1230
1231 #[test]
1232 fn extends_string_sugar() {
1233 let dir = test_dir("extends-string");
1234
1235 std::fs::write(
1236 dir.path().join("base.json"),
1237 r#"{"ignorePatterns": ["gen/**"]}"#,
1238 )
1239 .unwrap();
1240 std::fs::write(
1242 dir.path().join(".fallowrc.json"),
1243 r#"{"extends": "base.json"}"#,
1244 )
1245 .unwrap();
1246
1247 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1248 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1249 }
1250
1251 #[test]
1252 fn extends_deep_merge_preserves_arrays() {
1253 let dir = test_dir("extends-array");
1254
1255 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1256 std::fs::write(
1257 dir.path().join(".fallowrc.json"),
1258 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1259 )
1260 .unwrap();
1261
1262 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1263 assert_eq!(config.entry, vec!["src/b.ts"]);
1265 }
1266
1267 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1271 let pkg_dir = root.join("node_modules").join(name);
1272 std::fs::create_dir_all(&pkg_dir).unwrap();
1273 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1274 }
1275
1276 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1278 let pkg_dir = root.join("node_modules").join(name);
1279 std::fs::create_dir_all(&pkg_dir).unwrap();
1280 std::fs::write(
1281 pkg_dir.join("package.json"),
1282 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1283 )
1284 .unwrap();
1285 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1286 }
1287
1288 #[test]
1289 fn extends_npm_basic_unscoped() {
1290 let dir = test_dir("npm-basic");
1291 create_npm_package(
1292 dir.path(),
1293 "fallow-config-acme",
1294 r#"{"rules": {"unused-files": "warn"}}"#,
1295 );
1296 std::fs::write(
1297 dir.path().join(".fallowrc.json"),
1298 r#"{"extends": "npm:fallow-config-acme"}"#,
1299 )
1300 .unwrap();
1301
1302 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1303 assert_eq!(config.rules.unused_files, Severity::Warn);
1304 }
1305
1306 #[test]
1307 fn extends_npm_scoped_package() {
1308 let dir = test_dir("npm-scoped");
1309 create_npm_package(
1310 dir.path(),
1311 "@company/fallow-config",
1312 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1313 );
1314 std::fs::write(
1315 dir.path().join(".fallowrc.json"),
1316 r#"{"extends": "npm:@company/fallow-config"}"#,
1317 )
1318 .unwrap();
1319
1320 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1321 assert_eq!(config.rules.unused_exports, Severity::Off);
1322 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1323 }
1324
1325 #[test]
1326 fn extends_npm_with_subpath() {
1327 let dir = test_dir("npm-subpath");
1328 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1329 std::fs::create_dir_all(&pkg_dir).unwrap();
1330 std::fs::write(
1331 pkg_dir.join("strict.json"),
1332 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1333 )
1334 .unwrap();
1335
1336 std::fs::write(
1337 dir.path().join(".fallowrc.json"),
1338 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1339 )
1340 .unwrap();
1341
1342 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1343 assert_eq!(config.rules.unused_files, Severity::Error);
1344 assert_eq!(config.rules.unused_exports, Severity::Error);
1345 }
1346
1347 #[test]
1348 fn extends_npm_package_json_main() {
1349 let dir = test_dir("npm-main");
1350 create_npm_package_with_main(
1351 dir.path(),
1352 "fallow-config-acme",
1353 "config.json",
1354 r#"{"rules": {"unused-types": "off"}}"#,
1355 );
1356 std::fs::write(
1357 dir.path().join(".fallowrc.json"),
1358 r#"{"extends": "npm:fallow-config-acme"}"#,
1359 )
1360 .unwrap();
1361
1362 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1363 assert_eq!(config.rules.unused_types, Severity::Off);
1364 }
1365
1366 #[test]
1367 fn extends_npm_package_json_exports_string() {
1368 let dir = test_dir("npm-exports-str");
1369 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1370 std::fs::create_dir_all(&pkg_dir).unwrap();
1371 std::fs::write(
1372 pkg_dir.join("package.json"),
1373 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1374 )
1375 .unwrap();
1376 std::fs::write(
1377 pkg_dir.join("base.json"),
1378 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1379 )
1380 .unwrap();
1381
1382 std::fs::write(
1383 dir.path().join(".fallowrc.json"),
1384 r#"{"extends": "npm:fallow-config-co"}"#,
1385 )
1386 .unwrap();
1387
1388 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1389 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1390 }
1391
1392 #[test]
1393 fn extends_npm_package_json_exports_object() {
1394 let dir = test_dir("npm-exports-obj");
1395 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1396 std::fs::create_dir_all(&pkg_dir).unwrap();
1397 std::fs::write(
1398 pkg_dir.join("package.json"),
1399 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1400 )
1401 .unwrap();
1402 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1403
1404 std::fs::write(
1405 dir.path().join(".fallowrc.json"),
1406 r#"{"extends": "npm:@co/cfg"}"#,
1407 )
1408 .unwrap();
1409
1410 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1411 assert_eq!(config.entry, vec!["src/app.ts"]);
1412 }
1413
1414 #[test]
1415 fn extends_npm_exports_takes_priority_over_main() {
1416 let dir = test_dir("npm-exports-prio");
1417 let pkg_dir = dir.path().join("node_modules/my-config");
1418 std::fs::create_dir_all(&pkg_dir).unwrap();
1419 std::fs::write(
1420 pkg_dir.join("package.json"),
1421 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1422 )
1423 .unwrap();
1424 std::fs::write(
1425 pkg_dir.join("old.json"),
1426 r#"{"rules": {"unused-files": "off"}}"#,
1427 )
1428 .unwrap();
1429 std::fs::write(
1430 pkg_dir.join("new.json"),
1431 r#"{"rules": {"unused-files": "warn"}}"#,
1432 )
1433 .unwrap();
1434
1435 std::fs::write(
1436 dir.path().join(".fallowrc.json"),
1437 r#"{"extends": "npm:my-config"}"#,
1438 )
1439 .unwrap();
1440
1441 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1442 assert_eq!(config.rules.unused_files, Severity::Warn);
1444 }
1445
1446 #[test]
1447 fn extends_npm_walk_up_directories() {
1448 let dir = test_dir("npm-walkup");
1449 create_npm_package(
1451 dir.path(),
1452 "shared-config",
1453 r#"{"rules": {"unused-files": "warn"}}"#,
1454 );
1455 let sub = dir.path().join("packages/app");
1457 std::fs::create_dir_all(&sub).unwrap();
1458 std::fs::write(
1459 sub.join(".fallowrc.json"),
1460 r#"{"extends": "npm:shared-config"}"#,
1461 )
1462 .unwrap();
1463
1464 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1465 assert_eq!(config.rules.unused_files, Severity::Warn);
1466 }
1467
1468 #[test]
1469 fn extends_npm_overlay_overrides_base() {
1470 let dir = test_dir("npm-overlay");
1471 create_npm_package(
1472 dir.path(),
1473 "@company/base",
1474 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1475 );
1476 std::fs::write(
1477 dir.path().join(".fallowrc.json"),
1478 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1479 )
1480 .unwrap();
1481
1482 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1483 assert_eq!(config.rules.unused_files, Severity::Error);
1484 assert_eq!(config.rules.unused_exports, Severity::Off);
1485 assert_eq!(config.entry, vec!["src/app.ts"]);
1486 }
1487
1488 #[test]
1489 fn extends_npm_chained_with_relative() {
1490 let dir = test_dir("npm-chained");
1491 let pkg_dir = dir.path().join("node_modules/my-config");
1493 std::fs::create_dir_all(&pkg_dir).unwrap();
1494 std::fs::write(
1495 pkg_dir.join("base.json"),
1496 r#"{"rules": {"unused-files": "warn"}}"#,
1497 )
1498 .unwrap();
1499 std::fs::write(
1500 pkg_dir.join(".fallowrc.json"),
1501 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1502 )
1503 .unwrap();
1504
1505 std::fs::write(
1506 dir.path().join(".fallowrc.json"),
1507 r#"{"extends": "npm:my-config"}"#,
1508 )
1509 .unwrap();
1510
1511 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1512 assert_eq!(config.rules.unused_files, Severity::Warn);
1513 assert_eq!(config.rules.unused_exports, Severity::Off);
1514 }
1515
1516 #[test]
1517 fn extends_npm_mixed_with_relative_paths() {
1518 let dir = test_dir("npm-mixed");
1519 create_npm_package(
1520 dir.path(),
1521 "shared-base",
1522 r#"{"rules": {"unused-files": "off"}}"#,
1523 );
1524 std::fs::write(
1525 dir.path().join("local-overrides.json"),
1526 r#"{"rules": {"unused-files": "warn"}}"#,
1527 )
1528 .unwrap();
1529 std::fs::write(
1530 dir.path().join(".fallowrc.json"),
1531 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1532 )
1533 .unwrap();
1534
1535 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1536 assert_eq!(config.rules.unused_files, Severity::Warn);
1538 }
1539
1540 #[test]
1541 fn extends_npm_missing_package_errors() {
1542 let dir = test_dir("npm-missing");
1543 std::fs::write(
1544 dir.path().join(".fallowrc.json"),
1545 r#"{"extends": "npm:nonexistent-package"}"#,
1546 )
1547 .unwrap();
1548
1549 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1550 assert!(result.is_err());
1551 let err_msg = format!("{}", result.unwrap_err());
1552 assert!(
1553 err_msg.contains("not found"),
1554 "Expected 'not found' error, got: {err_msg}"
1555 );
1556 assert!(
1557 err_msg.contains("nonexistent-package"),
1558 "Expected package name in error, got: {err_msg}"
1559 );
1560 assert!(
1561 err_msg.contains("install it"),
1562 "Expected install hint in error, got: {err_msg}"
1563 );
1564 }
1565
1566 #[test]
1567 fn extends_npm_no_config_in_package_errors() {
1568 let dir = test_dir("npm-no-config");
1569 let pkg_dir = dir.path().join("node_modules/empty-pkg");
1570 std::fs::create_dir_all(&pkg_dir).unwrap();
1571 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1573
1574 std::fs::write(
1575 dir.path().join(".fallowrc.json"),
1576 r#"{"extends": "npm:empty-pkg"}"#,
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("No fallow config found"),
1585 "Expected 'No fallow config found' error, got: {err_msg}"
1586 );
1587 }
1588
1589 #[test]
1590 fn extends_npm_missing_subpath_errors() {
1591 let dir = test_dir("npm-missing-sub");
1592 let pkg_dir = dir.path().join("node_modules/@co/config");
1593 std::fs::create_dir_all(&pkg_dir).unwrap();
1594
1595 std::fs::write(
1596 dir.path().join(".fallowrc.json"),
1597 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1598 )
1599 .unwrap();
1600
1601 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1602 assert!(result.is_err());
1603 let err_msg = format!("{}", result.unwrap_err());
1604 assert!(
1605 err_msg.contains("nonexistent.json"),
1606 "Expected subpath in error, got: {err_msg}"
1607 );
1608 }
1609
1610 #[test]
1611 fn extends_npm_empty_specifier_errors() {
1612 let dir = test_dir("npm-empty");
1613 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1614
1615 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1616 assert!(result.is_err());
1617 let err_msg = format!("{}", result.unwrap_err());
1618 assert!(
1619 err_msg.contains("Empty npm specifier"),
1620 "Expected 'Empty npm specifier' error, got: {err_msg}"
1621 );
1622 }
1623
1624 #[test]
1625 fn extends_npm_space_after_colon_trimmed() {
1626 let dir = test_dir("npm-space");
1627 create_npm_package(
1628 dir.path(),
1629 "fallow-config-acme",
1630 r#"{"rules": {"unused-files": "warn"}}"#,
1631 );
1632 std::fs::write(
1634 dir.path().join(".fallowrc.json"),
1635 r#"{"extends": "npm: fallow-config-acme"}"#,
1636 )
1637 .unwrap();
1638
1639 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1640 assert_eq!(config.rules.unused_files, Severity::Warn);
1641 }
1642
1643 #[test]
1644 fn extends_npm_exports_node_condition() {
1645 let dir = test_dir("npm-node-cond");
1646 let pkg_dir = dir.path().join("node_modules/node-config");
1647 std::fs::create_dir_all(&pkg_dir).unwrap();
1648 std::fs::write(
1649 pkg_dir.join("package.json"),
1650 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1651 )
1652 .unwrap();
1653 std::fs::write(
1654 pkg_dir.join("node.json"),
1655 r#"{"rules": {"unused-files": "off"}}"#,
1656 )
1657 .unwrap();
1658
1659 std::fs::write(
1660 dir.path().join(".fallowrc.json"),
1661 r#"{"extends": "npm:node-config"}"#,
1662 )
1663 .unwrap();
1664
1665 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1666 assert_eq!(config.rules.unused_files, Severity::Off);
1667 }
1668
1669 #[test]
1672 fn parse_npm_specifier_unscoped() {
1673 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1674 }
1675
1676 #[test]
1677 fn parse_npm_specifier_unscoped_with_subpath() {
1678 assert_eq!(
1679 parse_npm_specifier("my-config/strict.json"),
1680 ("my-config", Some("strict.json"))
1681 );
1682 }
1683
1684 #[test]
1685 fn parse_npm_specifier_scoped() {
1686 assert_eq!(
1687 parse_npm_specifier("@company/fallow-config"),
1688 ("@company/fallow-config", None)
1689 );
1690 }
1691
1692 #[test]
1693 fn parse_npm_specifier_scoped_with_subpath() {
1694 assert_eq!(
1695 parse_npm_specifier("@company/fallow-config/strict.json"),
1696 ("@company/fallow-config", Some("strict.json"))
1697 );
1698 }
1699
1700 #[test]
1701 fn parse_npm_specifier_scoped_with_nested_subpath() {
1702 assert_eq!(
1703 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1704 ("@company/fallow-config", Some("presets/strict.json"))
1705 );
1706 }
1707
1708 #[test]
1711 fn extends_npm_subpath_traversal_rejected() {
1712 let dir = test_dir("npm-traversal-sub");
1713 let pkg_dir = dir.path().join("node_modules/evil-pkg");
1714 std::fs::create_dir_all(&pkg_dir).unwrap();
1715 std::fs::write(
1717 dir.path().join("secret.json"),
1718 r#"{"entry": ["stolen.ts"]}"#,
1719 )
1720 .unwrap();
1721
1722 std::fs::write(
1723 dir.path().join(".fallowrc.json"),
1724 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1725 )
1726 .unwrap();
1727
1728 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1729 assert!(result.is_err());
1730 let err_msg = format!("{}", result.unwrap_err());
1731 assert!(
1732 err_msg.contains("traversal") || err_msg.contains("not found"),
1733 "Expected traversal or not-found error, got: {err_msg}"
1734 );
1735 }
1736
1737 #[test]
1738 fn extends_npm_dotdot_package_name_rejected() {
1739 let dir = test_dir("npm-dotdot-name");
1740 std::fs::write(
1741 dir.path().join(".fallowrc.json"),
1742 r#"{"extends": "npm:../relative"}"#,
1743 )
1744 .unwrap();
1745
1746 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1747 assert!(result.is_err());
1748 let err_msg = format!("{}", result.unwrap_err());
1749 assert!(
1750 err_msg.contains("path traversal"),
1751 "Expected 'path traversal' error, got: {err_msg}"
1752 );
1753 }
1754
1755 #[test]
1756 fn extends_npm_scoped_without_name_rejected() {
1757 let dir = test_dir("npm-scope-only");
1758 std::fs::write(
1759 dir.path().join(".fallowrc.json"),
1760 r#"{"extends": "npm:@scope"}"#,
1761 )
1762 .unwrap();
1763
1764 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1765 assert!(result.is_err());
1766 let err_msg = format!("{}", result.unwrap_err());
1767 assert!(
1768 err_msg.contains("@scope/name"),
1769 "Expected scoped name format error, got: {err_msg}"
1770 );
1771 }
1772
1773 #[test]
1774 fn extends_npm_malformed_package_json_errors() {
1775 let dir = test_dir("npm-bad-pkgjson");
1776 let pkg_dir = dir.path().join("node_modules/bad-pkg");
1777 std::fs::create_dir_all(&pkg_dir).unwrap();
1778 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1779
1780 std::fs::write(
1781 dir.path().join(".fallowrc.json"),
1782 r#"{"extends": "npm:bad-pkg"}"#,
1783 )
1784 .unwrap();
1785
1786 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1787 assert!(result.is_err());
1788 let err_msg = format!("{}", result.unwrap_err());
1789 assert!(
1790 err_msg.contains("Failed to parse"),
1791 "Expected parse error, got: {err_msg}"
1792 );
1793 }
1794
1795 #[test]
1796 fn extends_npm_exports_traversal_rejected() {
1797 let dir = test_dir("npm-exports-escape");
1798 let pkg_dir = dir.path().join("node_modules/evil-exports");
1799 std::fs::create_dir_all(&pkg_dir).unwrap();
1800 std::fs::write(
1801 pkg_dir.join("package.json"),
1802 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1803 )
1804 .unwrap();
1805 std::fs::write(
1807 dir.path().join("secret.json"),
1808 r#"{"entry": ["stolen.ts"]}"#,
1809 )
1810 .unwrap();
1811
1812 std::fs::write(
1813 dir.path().join(".fallowrc.json"),
1814 r#"{"extends": "npm:evil-exports"}"#,
1815 )
1816 .unwrap();
1817
1818 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1819 assert!(result.is_err());
1820 let err_msg = format!("{}", result.unwrap_err());
1821 assert!(
1822 err_msg.contains("traversal"),
1823 "Expected traversal error, got: {err_msg}"
1824 );
1825 }
1826
1827 #[test]
1830 fn deep_merge_scalar_overlay_replaces_base() {
1831 let mut base = serde_json::json!("hello");
1832 deep_merge_json(&mut base, serde_json::json!("world"));
1833 assert_eq!(base, serde_json::json!("world"));
1834 }
1835
1836 #[test]
1837 fn deep_merge_array_overlay_replaces_base() {
1838 let mut base = serde_json::json!(["a", "b"]);
1839 deep_merge_json(&mut base, serde_json::json!(["c"]));
1840 assert_eq!(base, serde_json::json!(["c"]));
1841 }
1842
1843 #[test]
1844 fn deep_merge_nested_object_merge() {
1845 let mut base = serde_json::json!({
1846 "level1": {
1847 "level2": {
1848 "a": 1,
1849 "b": 2
1850 }
1851 }
1852 });
1853 let overlay = serde_json::json!({
1854 "level1": {
1855 "level2": {
1856 "b": 99,
1857 "c": 3
1858 }
1859 }
1860 });
1861 deep_merge_json(&mut base, overlay);
1862 assert_eq!(base["level1"]["level2"]["a"], 1);
1863 assert_eq!(base["level1"]["level2"]["b"], 99);
1864 assert_eq!(base["level1"]["level2"]["c"], 3);
1865 }
1866
1867 #[test]
1868 fn deep_merge_overlay_adds_new_fields() {
1869 let mut base = serde_json::json!({"existing": true});
1870 let overlay = serde_json::json!({"new_field": "added", "another": 42});
1871 deep_merge_json(&mut base, overlay);
1872 assert_eq!(base["existing"], true);
1873 assert_eq!(base["new_field"], "added");
1874 assert_eq!(base["another"], 42);
1875 }
1876
1877 #[test]
1878 fn deep_merge_null_overlay_replaces_object() {
1879 let mut base = serde_json::json!({"key": "value"});
1880 deep_merge_json(&mut base, serde_json::json!(null));
1881 assert_eq!(base, serde_json::json!(null));
1882 }
1883
1884 #[test]
1885 fn deep_merge_empty_object_overlay_preserves_base() {
1886 let mut base = serde_json::json!({"a": 1, "b": 2});
1887 deep_merge_json(&mut base, serde_json::json!({}));
1888 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1889 }
1890
1891 #[test]
1894 fn rules_severity_error_warn_off_from_json() {
1895 let json_str = r#"{
1896 "rules": {
1897 "unused-files": "error",
1898 "unused-exports": "warn",
1899 "unused-types": "off"
1900 }
1901 }"#;
1902 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1903 assert_eq!(config.rules.unused_files, Severity::Error);
1904 assert_eq!(config.rules.unused_exports, Severity::Warn);
1905 assert_eq!(config.rules.unused_types, Severity::Off);
1906 }
1907
1908 #[test]
1909 fn rules_omitted_default_to_error() {
1910 let json_str = r#"{
1911 "rules": {
1912 "unused-files": "warn"
1913 }
1914 }"#;
1915 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1916 assert_eq!(config.rules.unused_files, Severity::Warn);
1917 assert_eq!(config.rules.unused_exports, Severity::Error);
1919 assert_eq!(config.rules.unused_types, Severity::Error);
1920 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1921 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1922 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
1923 assert_eq!(config.rules.duplicate_exports, Severity::Error);
1924 assert_eq!(config.rules.circular_dependencies, Severity::Error);
1925 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
1927 }
1928
1929 #[test]
1932 fn find_and_load_returns_none_when_no_config() {
1933 let dir = test_dir("find-none");
1934 std::fs::create_dir(dir.path().join(".git")).unwrap();
1936
1937 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1938 assert!(result.is_none());
1939 }
1940
1941 #[test]
1942 fn find_and_load_finds_fallowrc_json() {
1943 let dir = test_dir("find-json");
1944 std::fs::create_dir(dir.path().join(".git")).unwrap();
1945 std::fs::write(
1946 dir.path().join(".fallowrc.json"),
1947 r#"{"entry": ["src/main.ts"]}"#,
1948 )
1949 .unwrap();
1950
1951 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1952 assert_eq!(config.entry, vec!["src/main.ts"]);
1953 assert!(path.ends_with(".fallowrc.json"));
1954 }
1955
1956 #[test]
1957 fn find_and_load_prefers_fallowrc_json_over_toml() {
1958 let dir = test_dir("find-priority");
1959 std::fs::create_dir(dir.path().join(".git")).unwrap();
1960 std::fs::write(
1961 dir.path().join(".fallowrc.json"),
1962 r#"{"entry": ["from-json.ts"]}"#,
1963 )
1964 .unwrap();
1965 std::fs::write(
1966 dir.path().join("fallow.toml"),
1967 "entry = [\"from-toml.ts\"]\n",
1968 )
1969 .unwrap();
1970
1971 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1972 assert_eq!(config.entry, vec!["from-json.ts"]);
1973 assert!(path.ends_with(".fallowrc.json"));
1974 }
1975
1976 #[test]
1977 fn find_and_load_finds_fallow_toml() {
1978 let dir = test_dir("find-toml");
1979 std::fs::create_dir(dir.path().join(".git")).unwrap();
1980 std::fs::write(
1981 dir.path().join("fallow.toml"),
1982 "entry = [\"src/index.ts\"]\n",
1983 )
1984 .unwrap();
1985
1986 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1987 assert_eq!(config.entry, vec!["src/index.ts"]);
1988 }
1989
1990 #[test]
1991 fn find_and_load_stops_at_git_dir() {
1992 let dir = test_dir("find-git-stop");
1993 let sub = dir.path().join("sub");
1994 std::fs::create_dir(&sub).unwrap();
1995 std::fs::create_dir(dir.path().join(".git")).unwrap();
1997 let result = FallowConfig::find_and_load(&sub).unwrap();
2001 assert!(result.is_none());
2002 }
2003
2004 #[test]
2005 fn find_and_load_stops_at_package_json() {
2006 let dir = test_dir("find-pkg-stop");
2007 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
2008
2009 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2010 assert!(result.is_none());
2011 }
2012
2013 #[test]
2014 fn find_and_load_returns_error_for_invalid_config() {
2015 let dir = test_dir("find-invalid");
2016 std::fs::create_dir(dir.path().join(".git")).unwrap();
2017 std::fs::write(
2018 dir.path().join(".fallowrc.json"),
2019 r"{ this is not valid json }",
2020 )
2021 .unwrap();
2022
2023 let result = FallowConfig::find_and_load(dir.path());
2024 assert!(result.is_err());
2025 }
2026
2027 #[test]
2030 fn load_toml_config_file() {
2031 let dir = test_dir("toml-config");
2032 let config_path = dir.path().join("fallow.toml");
2033 std::fs::write(
2034 &config_path,
2035 r#"
2036entry = ["src/index.ts"]
2037ignorePatterns = ["dist/**"]
2038
2039[rules]
2040unused-files = "warn"
2041
2042[duplicates]
2043minTokens = 100
2044"#,
2045 )
2046 .unwrap();
2047
2048 let config = FallowConfig::load(&config_path).unwrap();
2049 assert_eq!(config.entry, vec!["src/index.ts"]);
2050 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2051 assert_eq!(config.rules.unused_files, Severity::Warn);
2052 assert_eq!(config.duplicates.min_tokens, 100);
2053 }
2054
2055 #[test]
2058 fn extends_absolute_path_rejected() {
2059 let dir = test_dir("extends-absolute");
2060
2061 #[cfg(unix)]
2063 let abs_path = "/absolute/path/config.json";
2064 #[cfg(windows)]
2065 let abs_path = "C:\\absolute\\path\\config.json";
2066
2067 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2068 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2069
2070 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2071 assert!(result.is_err());
2072 let err_msg = format!("{}", result.unwrap_err());
2073 assert!(
2074 err_msg.contains("must be relative"),
2075 "Expected 'must be relative' error, got: {err_msg}"
2076 );
2077 }
2078
2079 #[test]
2082 fn resolve_production_mode_disables_dev_deps() {
2083 let config = FallowConfig {
2084 schema: None,
2085 extends: vec![],
2086 entry: vec![],
2087 ignore_patterns: vec![],
2088 framework: vec![],
2089 workspaces: None,
2090 ignore_dependencies: vec![],
2091 ignore_exports: vec![],
2092 duplicates: DuplicatesConfig::default(),
2093 health: HealthConfig::default(),
2094 rules: RulesConfig::default(),
2095 boundaries: BoundaryConfig::default(),
2096 production: true,
2097 plugins: vec![],
2098 dynamically_loaded: vec![],
2099 overrides: vec![],
2100 regression: None,
2101 codeowners: None,
2102 public_packages: vec![],
2103 };
2104 let resolved = config.resolve(
2105 PathBuf::from("/tmp/test"),
2106 OutputFormat::Human,
2107 4,
2108 false,
2109 true,
2110 );
2111 assert!(resolved.production);
2112 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2113 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2114 assert_eq!(resolved.rules.unused_files, Severity::Error);
2116 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2117 }
2118
2119 #[test]
2122 fn config_format_defaults_to_toml_for_unknown() {
2123 assert!(matches!(
2124 ConfigFormat::from_path(Path::new("config.yaml")),
2125 ConfigFormat::Toml
2126 ));
2127 assert!(matches!(
2128 ConfigFormat::from_path(Path::new("config")),
2129 ConfigFormat::Toml
2130 ));
2131 }
2132
2133 #[test]
2136 fn deep_merge_object_over_scalar_replaces() {
2137 let mut base = serde_json::json!("just a string");
2138 let overlay = serde_json::json!({"key": "value"});
2139 deep_merge_json(&mut base, overlay);
2140 assert_eq!(base, serde_json::json!({"key": "value"}));
2141 }
2142
2143 #[test]
2144 fn deep_merge_scalar_over_object_replaces() {
2145 let mut base = serde_json::json!({"key": "value"});
2146 let overlay = serde_json::json!(42);
2147 deep_merge_json(&mut base, overlay);
2148 assert_eq!(base, serde_json::json!(42));
2149 }
2150
2151 #[test]
2154 fn extends_non_string_non_array_ignored() {
2155 let dir = test_dir("extends-numeric");
2156 std::fs::write(
2157 dir.path().join(".fallowrc.json"),
2158 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2159 )
2160 .unwrap();
2161
2162 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2164 assert_eq!(config.entry, vec!["src/index.ts"]);
2165 }
2166
2167 #[test]
2170 fn extends_multiple_bases_later_wins() {
2171 let dir = test_dir("extends-multi-base");
2172
2173 std::fs::write(
2174 dir.path().join("base-a.json"),
2175 r#"{"rules": {"unused-files": "warn"}}"#,
2176 )
2177 .unwrap();
2178 std::fs::write(
2179 dir.path().join("base-b.json"),
2180 r#"{"rules": {"unused-files": "off"}}"#,
2181 )
2182 .unwrap();
2183 std::fs::write(
2184 dir.path().join(".fallowrc.json"),
2185 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2186 )
2187 .unwrap();
2188
2189 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2190 assert_eq!(config.rules.unused_files, Severity::Off);
2192 }
2193
2194 #[test]
2197 fn fallow_config_deserialize_production() {
2198 let json_str = r#"{"production": true}"#;
2199 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2200 assert!(config.production);
2201 }
2202
2203 #[test]
2204 fn fallow_config_production_defaults_false() {
2205 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2206 assert!(!config.production);
2207 }
2208
2209 #[test]
2212 fn package_json_optional_dependency_names() {
2213 let pkg: PackageJson = serde_json::from_str(
2214 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2215 )
2216 .unwrap();
2217 let opt = pkg.optional_dependency_names();
2218 assert_eq!(opt.len(), 2);
2219 assert!(opt.contains(&"fsevents".to_string()));
2220 assert!(opt.contains(&"chokidar".to_string()));
2221 }
2222
2223 #[test]
2224 fn package_json_optional_deps_empty_when_missing() {
2225 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2226 assert!(pkg.optional_dependency_names().is_empty());
2227 }
2228
2229 #[test]
2232 fn find_config_path_returns_fallowrc_json() {
2233 let dir = test_dir("find-path-json");
2234 std::fs::create_dir(dir.path().join(".git")).unwrap();
2235 std::fs::write(
2236 dir.path().join(".fallowrc.json"),
2237 r#"{"entry": ["src/main.ts"]}"#,
2238 )
2239 .unwrap();
2240
2241 let path = FallowConfig::find_config_path(dir.path());
2242 assert!(path.is_some());
2243 assert!(path.unwrap().ends_with(".fallowrc.json"));
2244 }
2245
2246 #[test]
2247 fn find_config_path_returns_fallow_toml() {
2248 let dir = test_dir("find-path-toml");
2249 std::fs::create_dir(dir.path().join(".git")).unwrap();
2250 std::fs::write(
2251 dir.path().join("fallow.toml"),
2252 "entry = [\"src/main.ts\"]\n",
2253 )
2254 .unwrap();
2255
2256 let path = FallowConfig::find_config_path(dir.path());
2257 assert!(path.is_some());
2258 assert!(path.unwrap().ends_with("fallow.toml"));
2259 }
2260
2261 #[test]
2262 fn find_config_path_returns_dot_fallow_toml() {
2263 let dir = test_dir("find-path-dot-toml");
2264 std::fs::create_dir(dir.path().join(".git")).unwrap();
2265 std::fs::write(
2266 dir.path().join(".fallow.toml"),
2267 "entry = [\"src/main.ts\"]\n",
2268 )
2269 .unwrap();
2270
2271 let path = FallowConfig::find_config_path(dir.path());
2272 assert!(path.is_some());
2273 assert!(path.unwrap().ends_with(".fallow.toml"));
2274 }
2275
2276 #[test]
2277 fn find_config_path_prefers_json_over_toml() {
2278 let dir = test_dir("find-path-priority");
2279 std::fs::create_dir(dir.path().join(".git")).unwrap();
2280 std::fs::write(
2281 dir.path().join(".fallowrc.json"),
2282 r#"{"entry": ["json.ts"]}"#,
2283 )
2284 .unwrap();
2285 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2286
2287 let path = FallowConfig::find_config_path(dir.path());
2288 assert!(path.unwrap().ends_with(".fallowrc.json"));
2289 }
2290
2291 #[test]
2292 fn find_config_path_none_when_no_config() {
2293 let dir = test_dir("find-path-none");
2294 std::fs::create_dir(dir.path().join(".git")).unwrap();
2295
2296 let path = FallowConfig::find_config_path(dir.path());
2297 assert!(path.is_none());
2298 }
2299
2300 #[test]
2301 fn find_config_path_stops_at_package_json() {
2302 let dir = test_dir("find-path-pkg-stop");
2303 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
2304
2305 let path = FallowConfig::find_config_path(dir.path());
2306 assert!(path.is_none());
2307 }
2308
2309 #[test]
2312 fn extends_toml_base() {
2313 let dir = test_dir("extends-toml");
2314
2315 std::fs::write(
2316 dir.path().join("base.json"),
2317 r#"{"rules": {"unused-files": "warn"}}"#,
2318 )
2319 .unwrap();
2320 std::fs::write(
2321 dir.path().join("fallow.toml"),
2322 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2323 )
2324 .unwrap();
2325
2326 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2327 assert_eq!(config.rules.unused_files, Severity::Warn);
2328 assert_eq!(config.entry, vec!["src/index.ts"]);
2329 }
2330
2331 #[test]
2334 fn deep_merge_boolean_overlay() {
2335 let mut base = serde_json::json!(true);
2336 deep_merge_json(&mut base, serde_json::json!(false));
2337 assert_eq!(base, serde_json::json!(false));
2338 }
2339
2340 #[test]
2341 fn deep_merge_number_overlay() {
2342 let mut base = serde_json::json!(42);
2343 deep_merge_json(&mut base, serde_json::json!(99));
2344 assert_eq!(base, serde_json::json!(99));
2345 }
2346
2347 #[test]
2348 fn deep_merge_disjoint_objects() {
2349 let mut base = serde_json::json!({"a": 1});
2350 let overlay = serde_json::json!({"b": 2});
2351 deep_merge_json(&mut base, overlay);
2352 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2353 }
2354
2355 #[test]
2358 fn max_extends_depth_is_reasonable() {
2359 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2360 }
2361
2362 #[test]
2365 fn config_names_has_three_entries() {
2366 assert_eq!(CONFIG_NAMES.len(), 3);
2367 for name in CONFIG_NAMES {
2369 assert!(
2370 name.starts_with('.') || name.starts_with("fallow"),
2371 "unexpected config name: {name}"
2372 );
2373 }
2374 }
2375
2376 #[test]
2379 fn package_json_peer_dependency_names() {
2380 let pkg: PackageJson = serde_json::from_str(
2381 r#"{
2382 "dependencies": {"react": "^18"},
2383 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2384 }"#,
2385 )
2386 .unwrap();
2387 let all = pkg.all_dependency_names();
2388 assert!(all.contains(&"react".to_string()));
2389 assert!(all.contains(&"react-dom".to_string()));
2390 assert!(all.contains(&"react-native".to_string()));
2391 }
2392
2393 #[test]
2396 fn package_json_scripts_field() {
2397 let pkg: PackageJson = serde_json::from_str(
2398 r#"{
2399 "scripts": {
2400 "build": "tsc",
2401 "test": "vitest",
2402 "lint": "fallow check"
2403 }
2404 }"#,
2405 )
2406 .unwrap();
2407 let scripts = pkg.scripts.unwrap();
2408 assert_eq!(scripts.len(), 3);
2409 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2410 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2411 }
2412
2413 #[test]
2416 fn extends_toml_chain() {
2417 let dir = test_dir("extends-toml-chain");
2418
2419 std::fs::write(
2420 dir.path().join("base.json"),
2421 r#"{"entry": ["src/base.ts"]}"#,
2422 )
2423 .unwrap();
2424 std::fs::write(
2425 dir.path().join("middle.json"),
2426 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2427 )
2428 .unwrap();
2429 std::fs::write(
2430 dir.path().join("fallow.toml"),
2431 "extends = [\"middle.json\"]\n",
2432 )
2433 .unwrap();
2434
2435 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2436 assert_eq!(config.entry, vec!["src/base.ts"]);
2437 assert_eq!(config.rules.unused_files, Severity::Off);
2438 }
2439
2440 #[test]
2443 fn find_and_load_walks_up_directories() {
2444 let dir = test_dir("find-walk-up");
2445 let sub = dir.path().join("src").join("deep");
2446 std::fs::create_dir_all(&sub).unwrap();
2447 std::fs::write(
2448 dir.path().join(".fallowrc.json"),
2449 r#"{"entry": ["src/main.ts"]}"#,
2450 )
2451 .unwrap();
2452 std::fs::create_dir(dir.path().join(".git")).unwrap();
2454
2455 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2456 assert_eq!(config.entry, vec!["src/main.ts"]);
2457 assert!(path.ends_with(".fallowrc.json"));
2458 }
2459
2460 #[test]
2463 fn json_schema_contains_entry_field() {
2464 let schema = FallowConfig::json_schema();
2465 let obj = schema.as_object().unwrap();
2466 let props = obj.get("properties").and_then(|v| v.as_object());
2467 assert!(props.is_some(), "schema should have properties");
2468 assert!(
2469 props.unwrap().contains_key("entry"),
2470 "schema should contain entry property"
2471 );
2472 }
2473
2474 #[test]
2477 fn fallow_config_json_duplicates_all_fields() {
2478 let json = r#"{
2479 "duplicates": {
2480 "enabled": true,
2481 "mode": "semantic",
2482 "minTokens": 200,
2483 "minLines": 20,
2484 "threshold": 10.5,
2485 "ignore": ["**/*.test.ts"],
2486 "skipLocal": true,
2487 "crossLanguage": true,
2488 "normalization": {
2489 "ignoreIdentifiers": true,
2490 "ignoreStringValues": false
2491 }
2492 }
2493 }"#;
2494 let config: FallowConfig = serde_json::from_str(json).unwrap();
2495 assert!(config.duplicates.enabled);
2496 assert_eq!(
2497 config.duplicates.mode,
2498 crate::config::DetectionMode::Semantic
2499 );
2500 assert_eq!(config.duplicates.min_tokens, 200);
2501 assert_eq!(config.duplicates.min_lines, 20);
2502 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2503 assert!(config.duplicates.skip_local);
2504 assert!(config.duplicates.cross_language);
2505 assert_eq!(
2506 config.duplicates.normalization.ignore_identifiers,
2507 Some(true)
2508 );
2509 assert_eq!(
2510 config.duplicates.normalization.ignore_string_values,
2511 Some(false)
2512 );
2513 }
2514
2515 #[test]
2518 fn normalize_url_basic() {
2519 assert_eq!(
2520 normalize_url_for_dedup("https://example.com/config.json"),
2521 "https://example.com/config.json"
2522 );
2523 }
2524
2525 #[test]
2526 fn normalize_url_trailing_slash() {
2527 assert_eq!(
2528 normalize_url_for_dedup("https://example.com/config/"),
2529 "https://example.com/config"
2530 );
2531 }
2532
2533 #[test]
2534 fn normalize_url_uppercase_scheme_and_host() {
2535 assert_eq!(
2536 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2537 "https://example.com/Config.json"
2538 );
2539 }
2540
2541 #[test]
2542 fn normalize_url_root_path() {
2543 assert_eq!(
2544 normalize_url_for_dedup("https://example.com/"),
2545 "https://example.com"
2546 );
2547 assert_eq!(
2548 normalize_url_for_dedup("https://example.com"),
2549 "https://example.com"
2550 );
2551 }
2552
2553 #[test]
2554 fn normalize_url_preserves_path_case() {
2555 assert_eq!(
2557 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2558 "https://github.com/Org/Repo/Fallow.json"
2559 );
2560 }
2561
2562 #[test]
2563 fn normalize_url_strips_query_string() {
2564 assert_eq!(
2565 normalize_url_for_dedup("https://example.com/config.json?v=1"),
2566 "https://example.com/config.json"
2567 );
2568 }
2569
2570 #[test]
2571 fn normalize_url_strips_fragment() {
2572 assert_eq!(
2573 normalize_url_for_dedup("https://example.com/config.json#section"),
2574 "https://example.com/config.json"
2575 );
2576 }
2577
2578 #[test]
2579 fn normalize_url_strips_query_and_fragment() {
2580 assert_eq!(
2581 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2582 "https://example.com/config.json"
2583 );
2584 }
2585
2586 #[test]
2587 fn normalize_url_default_https_port() {
2588 assert_eq!(
2589 normalize_url_for_dedup("https://example.com:443/config.json"),
2590 "https://example.com/config.json"
2591 );
2592 assert_eq!(
2594 normalize_url_for_dedup("https://example.com:8443/config.json"),
2595 "https://example.com:8443/config.json"
2596 );
2597 }
2598
2599 #[test]
2600 fn extends_http_rejected() {
2601 let dir = test_dir("http-rejected");
2602 std::fs::write(
2603 dir.path().join(".fallowrc.json"),
2604 r#"{"extends": "http://example.com/config.json"}"#,
2605 )
2606 .unwrap();
2607
2608 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2609 assert!(result.is_err());
2610 let err_msg = format!("{}", result.unwrap_err());
2611 assert!(
2612 err_msg.contains("https://"),
2613 "Expected https hint in error, got: {err_msg}"
2614 );
2615 assert!(
2616 err_msg.contains("http://"),
2617 "Expected http:// mention in error, got: {err_msg}"
2618 );
2619 }
2620
2621 #[test]
2622 fn extends_url_circular_detection() {
2623 let mut visited = FxHashSet::default();
2625 let url = "https://example.com/config.json";
2626 let normalized = normalize_url_for_dedup(url);
2627 visited.insert(normalized.clone());
2628
2629 assert!(
2631 !visited.insert(normalized),
2632 "Same URL should be detected as duplicate"
2633 );
2634 }
2635
2636 #[test]
2637 fn extends_url_circular_case_insensitive() {
2638 let mut visited = FxHashSet::default();
2640 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2641
2642 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2643 assert!(
2644 !visited.insert(normalized),
2645 "Case-different URLs should normalize to the same key"
2646 );
2647 }
2648
2649 #[test]
2650 fn extract_extends_array() {
2651 let mut value = serde_json::json!({
2652 "extends": ["a.json", "b.json"],
2653 "entry": ["src/index.ts"]
2654 });
2655 let extends = extract_extends(&mut value);
2656 assert_eq!(extends, vec!["a.json", "b.json"]);
2657 assert!(value.get("extends").is_none());
2659 assert!(value.get("entry").is_some());
2660 }
2661
2662 #[test]
2663 fn extract_extends_string_sugar() {
2664 let mut value = serde_json::json!({
2665 "extends": "base.json",
2666 "entry": ["src/index.ts"]
2667 });
2668 let extends = extract_extends(&mut value);
2669 assert_eq!(extends, vec!["base.json"]);
2670 }
2671
2672 #[test]
2673 fn extract_extends_none() {
2674 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2675 let extends = extract_extends(&mut value);
2676 assert!(extends.is_empty());
2677 }
2678
2679 #[test]
2680 fn url_timeout_default() {
2681 let timeout = url_timeout();
2683 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2686 }
2687
2688 #[test]
2689 fn extends_url_mixed_with_file_and_npm() {
2690 let dir = test_dir("url-mixed");
2693 std::fs::write(
2694 dir.path().join("local.json"),
2695 r#"{"rules": {"unused-files": "warn"}}"#,
2696 )
2697 .unwrap();
2698 std::fs::write(
2699 dir.path().join(".fallowrc.json"),
2700 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2701 )
2702 .unwrap();
2703
2704 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2705 assert!(result.is_err());
2706 let err_msg = format!("{}", result.unwrap_err());
2707 assert!(
2708 err_msg.contains("unreachable.invalid"),
2709 "Expected URL in error message, got: {err_msg}"
2710 );
2711 }
2712
2713 #[test]
2714 fn extends_https_url_unreachable_errors() {
2715 let dir = test_dir("url-unreachable");
2716 std::fs::write(
2717 dir.path().join(".fallowrc.json"),
2718 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2719 )
2720 .unwrap();
2721
2722 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2723 assert!(result.is_err());
2724 let err_msg = format!("{}", result.unwrap_err());
2725 assert!(
2726 err_msg.contains("unreachable.invalid"),
2727 "Expected URL in error, got: {err_msg}"
2728 );
2729 assert!(
2730 err_msg.contains("local path or npm:"),
2731 "Expected remediation hint, got: {err_msg}"
2732 );
2733 }
2734}