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