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 dynamically_loaded: vec![],
764 overrides: vec![],
765 regression: None,
766 codeowners: None,
767 public_packages: vec![],
768 };
769 let resolved = config.resolve(
770 PathBuf::from("/tmp/test"),
771 OutputFormat::Human,
772 4,
773 true,
774 true,
775 );
776
777 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
779 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
780 assert!(resolved.ignore_patterns.is_match("build/output.js"));
781 assert!(resolved.ignore_patterns.is_match(".git/config"));
782 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
783 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
784 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
785 }
786
787 #[test]
788 fn fallow_config_resolve_custom_ignores() {
789 let config = FallowConfig {
790 schema: None,
791 extends: vec![],
792 entry: vec!["src/**/*.ts".to_string()],
793 ignore_patterns: vec!["**/*.generated.ts".to_string()],
794 framework: vec![],
795 workspaces: None,
796 ignore_dependencies: vec![],
797 ignore_exports: vec![],
798 duplicates: DuplicatesConfig::default(),
799 health: HealthConfig::default(),
800 rules: RulesConfig::default(),
801 boundaries: BoundaryConfig::default(),
802 production: false,
803 plugins: vec![],
804 dynamically_loaded: vec![],
805 overrides: vec![],
806 regression: None,
807 codeowners: None,
808 public_packages: vec![],
809 };
810 let resolved = config.resolve(
811 PathBuf::from("/tmp/test"),
812 OutputFormat::Json,
813 4,
814 false,
815 true,
816 );
817
818 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
819 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
820 assert!(matches!(resolved.output, OutputFormat::Json));
821 assert!(!resolved.no_cache);
822 }
823
824 #[test]
825 fn fallow_config_resolve_cache_dir() {
826 let config = FallowConfig {
827 schema: None,
828 extends: vec![],
829 entry: vec![],
830 ignore_patterns: vec![],
831 framework: vec![],
832 workspaces: None,
833 ignore_dependencies: vec![],
834 ignore_exports: vec![],
835 duplicates: DuplicatesConfig::default(),
836 health: HealthConfig::default(),
837 rules: RulesConfig::default(),
838 boundaries: BoundaryConfig::default(),
839 production: false,
840 plugins: vec![],
841 dynamically_loaded: vec![],
842 overrides: vec![],
843 regression: None,
844 codeowners: None,
845 public_packages: vec![],
846 };
847 let resolved = config.resolve(
848 PathBuf::from("/tmp/project"),
849 OutputFormat::Human,
850 4,
851 true,
852 true,
853 );
854 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
855 assert!(resolved.no_cache);
856 }
857
858 #[test]
859 fn package_json_entry_points_main() {
860 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
861 let entries = pkg.entry_points();
862 assert!(entries.contains(&"dist/index.js".to_string()));
863 }
864
865 #[test]
866 fn package_json_entry_points_module() {
867 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
868 let entries = pkg.entry_points();
869 assert!(entries.contains(&"dist/index.mjs".to_string()));
870 }
871
872 #[test]
873 fn package_json_entry_points_types() {
874 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
875 let entries = pkg.entry_points();
876 assert!(entries.contains(&"dist/index.d.ts".to_string()));
877 }
878
879 #[test]
880 fn package_json_entry_points_bin_string() {
881 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
882 let entries = pkg.entry_points();
883 assert!(entries.contains(&"bin/cli.js".to_string()));
884 }
885
886 #[test]
887 fn package_json_entry_points_bin_object() {
888 let pkg: PackageJson =
889 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
890 .unwrap();
891 let entries = pkg.entry_points();
892 assert!(entries.contains(&"bin/cli.js".to_string()));
893 assert!(entries.contains(&"bin/serve.js".to_string()));
894 }
895
896 #[test]
897 fn package_json_entry_points_exports_string() {
898 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
899 let entries = pkg.entry_points();
900 assert!(entries.contains(&"./dist/index.js".to_string()));
901 }
902
903 #[test]
904 fn package_json_entry_points_exports_object() {
905 let pkg: PackageJson = serde_json::from_str(
906 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
907 )
908 .unwrap();
909 let entries = pkg.entry_points();
910 assert!(entries.contains(&"./dist/index.mjs".to_string()));
911 assert!(entries.contains(&"./dist/index.cjs".to_string()));
912 }
913
914 #[test]
915 fn package_json_dependency_names() {
916 let pkg: PackageJson = serde_json::from_str(
917 r#"{
918 "dependencies": {"react": "^18", "lodash": "^4"},
919 "devDependencies": {"typescript": "^5"},
920 "peerDependencies": {"react-dom": "^18"}
921 }"#,
922 )
923 .unwrap();
924
925 let all = pkg.all_dependency_names();
926 assert!(all.contains(&"react".to_string()));
927 assert!(all.contains(&"lodash".to_string()));
928 assert!(all.contains(&"typescript".to_string()));
929 assert!(all.contains(&"react-dom".to_string()));
930
931 let prod = pkg.production_dependency_names();
932 assert!(prod.contains(&"react".to_string()));
933 assert!(!prod.contains(&"typescript".to_string()));
934
935 let dev = pkg.dev_dependency_names();
936 assert!(dev.contains(&"typescript".to_string()));
937 assert!(!dev.contains(&"react".to_string()));
938 }
939
940 #[test]
941 fn package_json_no_dependencies() {
942 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
943 assert!(pkg.all_dependency_names().is_empty());
944 assert!(pkg.production_dependency_names().is_empty());
945 assert!(pkg.dev_dependency_names().is_empty());
946 assert!(pkg.entry_points().is_empty());
947 }
948
949 #[test]
950 fn rules_deserialize_toml_kebab_case() {
951 let toml_str = r#"
952[rules]
953unused-files = "error"
954unused-exports = "warn"
955unused-types = "off"
956"#;
957 let config: FallowConfig = toml::from_str(toml_str).unwrap();
958 assert_eq!(config.rules.unused_files, Severity::Error);
959 assert_eq!(config.rules.unused_exports, Severity::Warn);
960 assert_eq!(config.rules.unused_types, Severity::Off);
961 assert_eq!(config.rules.unresolved_imports, Severity::Error);
963 }
964
965 #[test]
966 fn config_without_rules_defaults_to_error() {
967 let toml_str = r#"
968entry = ["src/main.ts"]
969"#;
970 let config: FallowConfig = toml::from_str(toml_str).unwrap();
971 assert_eq!(config.rules.unused_files, Severity::Error);
972 assert_eq!(config.rules.unused_exports, Severity::Error);
973 }
974
975 #[test]
976 fn fallow_config_denies_unknown_fields() {
977 let toml_str = r"
978unknown_field = true
979";
980 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
981 assert!(result.is_err());
982 }
983
984 #[test]
985 fn fallow_config_deserialize_json() {
986 let json_str = r#"{"entry": ["src/main.ts"]}"#;
987 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
988 assert_eq!(config.entry, vec!["src/main.ts"]);
989 }
990
991 #[test]
992 fn fallow_config_deserialize_jsonc() {
993 let jsonc_str = r#"{
994 // This is a comment
995 "entry": ["src/main.ts"],
996 "rules": {
997 "unused-files": "warn"
998 }
999 }"#;
1000 let mut stripped = String::new();
1001 json_comments::StripComments::new(jsonc_str.as_bytes())
1002 .read_to_string(&mut stripped)
1003 .unwrap();
1004 let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1005 assert_eq!(config.entry, vec!["src/main.ts"]);
1006 assert_eq!(config.rules.unused_files, Severity::Warn);
1007 }
1008
1009 #[test]
1010 fn fallow_config_json_with_schema_field() {
1011 let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1012 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1013 assert_eq!(config.entry, vec!["src/main.ts"]);
1014 }
1015
1016 #[test]
1017 fn fallow_config_json_schema_generation() {
1018 let schema = FallowConfig::json_schema();
1019 assert!(schema.is_object());
1020 let obj = schema.as_object().unwrap();
1021 assert!(obj.contains_key("properties"));
1022 }
1023
1024 #[test]
1025 fn config_format_detection() {
1026 assert!(matches!(
1027 ConfigFormat::from_path(Path::new("fallow.toml")),
1028 ConfigFormat::Toml
1029 ));
1030 assert!(matches!(
1031 ConfigFormat::from_path(Path::new(".fallowrc.json")),
1032 ConfigFormat::Json
1033 ));
1034 assert!(matches!(
1035 ConfigFormat::from_path(Path::new(".fallow.toml")),
1036 ConfigFormat::Toml
1037 ));
1038 }
1039
1040 #[test]
1041 fn config_names_priority_order() {
1042 assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1043 assert_eq!(CONFIG_NAMES[1], "fallow.toml");
1044 assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
1045 }
1046
1047 #[test]
1048 fn load_json_config_file() {
1049 let dir = test_dir("json-config");
1050 let config_path = dir.path().join(".fallowrc.json");
1051 std::fs::write(
1052 &config_path,
1053 r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1054 )
1055 .unwrap();
1056
1057 let config = FallowConfig::load(&config_path).unwrap();
1058 assert_eq!(config.entry, vec!["src/index.ts"]);
1059 assert_eq!(config.rules.unused_exports, Severity::Warn);
1060 }
1061
1062 #[test]
1063 fn load_jsonc_config_file() {
1064 let dir = test_dir("jsonc-config");
1065 let config_path = dir.path().join(".fallowrc.json");
1066 std::fs::write(
1067 &config_path,
1068 r#"{
1069 // Entry points for analysis
1070 "entry": ["src/index.ts"],
1071 /* Block comment */
1072 "rules": {
1073 "unused-exports": "warn"
1074 }
1075 }"#,
1076 )
1077 .unwrap();
1078
1079 let config = FallowConfig::load(&config_path).unwrap();
1080 assert_eq!(config.entry, vec!["src/index.ts"]);
1081 assert_eq!(config.rules.unused_exports, Severity::Warn);
1082 }
1083
1084 #[test]
1085 fn json_config_ignore_dependencies_camel_case() {
1086 let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1087 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1088 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1089 }
1090
1091 #[test]
1092 fn json_config_all_fields() {
1093 let json_str = r#"{
1094 "ignoreDependencies": ["lodash"],
1095 "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1096 "rules": {
1097 "unused-files": "off",
1098 "unused-exports": "warn",
1099 "unused-dependencies": "error",
1100 "unused-dev-dependencies": "off",
1101 "unused-types": "warn",
1102 "unused-enum-members": "error",
1103 "unused-class-members": "off",
1104 "unresolved-imports": "warn",
1105 "unlisted-dependencies": "error",
1106 "duplicate-exports": "off"
1107 },
1108 "duplicates": {
1109 "minTokens": 100,
1110 "minLines": 10,
1111 "skipLocal": true
1112 }
1113 }"#;
1114 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1115 assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1116 assert_eq!(config.rules.unused_files, Severity::Off);
1117 assert_eq!(config.rules.unused_exports, Severity::Warn);
1118 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1119 assert_eq!(config.duplicates.min_tokens, 100);
1120 assert_eq!(config.duplicates.min_lines, 10);
1121 assert!(config.duplicates.skip_local);
1122 }
1123
1124 #[test]
1127 fn extends_single_base() {
1128 let dir = test_dir("extends-single");
1129
1130 std::fs::write(
1131 dir.path().join("base.json"),
1132 r#"{"rules": {"unused-files": "warn"}}"#,
1133 )
1134 .unwrap();
1135 std::fs::write(
1136 dir.path().join(".fallowrc.json"),
1137 r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1138 )
1139 .unwrap();
1140
1141 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1142 assert_eq!(config.rules.unused_files, Severity::Warn);
1143 assert_eq!(config.entry, vec!["src/index.ts"]);
1144 assert_eq!(config.rules.unused_exports, Severity::Error);
1146 }
1147
1148 #[test]
1149 fn extends_overlay_overrides_base() {
1150 let dir = test_dir("extends-overlay");
1151
1152 std::fs::write(
1153 dir.path().join("base.json"),
1154 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1155 )
1156 .unwrap();
1157 std::fs::write(
1158 dir.path().join(".fallowrc.json"),
1159 r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1160 )
1161 .unwrap();
1162
1163 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1164 assert_eq!(config.rules.unused_files, Severity::Error);
1166 assert_eq!(config.rules.unused_exports, Severity::Off);
1168 }
1169
1170 #[test]
1171 fn extends_chained() {
1172 let dir = test_dir("extends-chained");
1173
1174 std::fs::write(
1175 dir.path().join("grandparent.json"),
1176 r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1177 )
1178 .unwrap();
1179 std::fs::write(
1180 dir.path().join("parent.json"),
1181 r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1182 )
1183 .unwrap();
1184 std::fs::write(
1185 dir.path().join(".fallowrc.json"),
1186 r#"{"extends": ["parent.json"]}"#,
1187 )
1188 .unwrap();
1189
1190 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1191 assert_eq!(config.rules.unused_files, Severity::Warn);
1193 assert_eq!(config.rules.unused_exports, Severity::Warn);
1195 }
1196
1197 #[test]
1198 fn extends_circular_detected() {
1199 let dir = test_dir("extends-circular");
1200
1201 std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1202 std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1203
1204 let result = FallowConfig::load(&dir.path().join("a.json"));
1205 assert!(result.is_err());
1206 let err_msg = format!("{}", result.unwrap_err());
1207 assert!(
1208 err_msg.contains("Circular extends"),
1209 "Expected circular error, got: {err_msg}"
1210 );
1211 }
1212
1213 #[test]
1214 fn extends_missing_file_errors() {
1215 let dir = test_dir("extends-missing");
1216
1217 std::fs::write(
1218 dir.path().join(".fallowrc.json"),
1219 r#"{"extends": ["nonexistent.json"]}"#,
1220 )
1221 .unwrap();
1222
1223 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1224 assert!(result.is_err());
1225 let err_msg = format!("{}", result.unwrap_err());
1226 assert!(
1227 err_msg.contains("not found"),
1228 "Expected not found error, got: {err_msg}"
1229 );
1230 }
1231
1232 #[test]
1233 fn extends_string_sugar() {
1234 let dir = test_dir("extends-string");
1235
1236 std::fs::write(
1237 dir.path().join("base.json"),
1238 r#"{"ignorePatterns": ["gen/**"]}"#,
1239 )
1240 .unwrap();
1241 std::fs::write(
1243 dir.path().join(".fallowrc.json"),
1244 r#"{"extends": "base.json"}"#,
1245 )
1246 .unwrap();
1247
1248 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1249 assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1250 }
1251
1252 #[test]
1253 fn extends_deep_merge_preserves_arrays() {
1254 let dir = test_dir("extends-array");
1255
1256 std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1257 std::fs::write(
1258 dir.path().join(".fallowrc.json"),
1259 r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1260 )
1261 .unwrap();
1262
1263 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1264 assert_eq!(config.entry, vec!["src/b.ts"]);
1266 }
1267
1268 fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1272 let pkg_dir = root.join("node_modules").join(name);
1273 std::fs::create_dir_all(&pkg_dir).unwrap();
1274 std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1275 }
1276
1277 fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1279 let pkg_dir = root.join("node_modules").join(name);
1280 std::fs::create_dir_all(&pkg_dir).unwrap();
1281 std::fs::write(
1282 pkg_dir.join("package.json"),
1283 format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1284 )
1285 .unwrap();
1286 std::fs::write(pkg_dir.join(main), config_json).unwrap();
1287 }
1288
1289 #[test]
1290 fn extends_npm_basic_unscoped() {
1291 let dir = test_dir("npm-basic");
1292 create_npm_package(
1293 dir.path(),
1294 "fallow-config-acme",
1295 r#"{"rules": {"unused-files": "warn"}}"#,
1296 );
1297 std::fs::write(
1298 dir.path().join(".fallowrc.json"),
1299 r#"{"extends": "npm:fallow-config-acme"}"#,
1300 )
1301 .unwrap();
1302
1303 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1304 assert_eq!(config.rules.unused_files, Severity::Warn);
1305 }
1306
1307 #[test]
1308 fn extends_npm_scoped_package() {
1309 let dir = test_dir("npm-scoped");
1310 create_npm_package(
1311 dir.path(),
1312 "@company/fallow-config",
1313 r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1314 );
1315 std::fs::write(
1316 dir.path().join(".fallowrc.json"),
1317 r#"{"extends": "npm:@company/fallow-config"}"#,
1318 )
1319 .unwrap();
1320
1321 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1322 assert_eq!(config.rules.unused_exports, Severity::Off);
1323 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1324 }
1325
1326 #[test]
1327 fn extends_npm_with_subpath() {
1328 let dir = test_dir("npm-subpath");
1329 let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1330 std::fs::create_dir_all(&pkg_dir).unwrap();
1331 std::fs::write(
1332 pkg_dir.join("strict.json"),
1333 r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1334 )
1335 .unwrap();
1336
1337 std::fs::write(
1338 dir.path().join(".fallowrc.json"),
1339 r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1340 )
1341 .unwrap();
1342
1343 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1344 assert_eq!(config.rules.unused_files, Severity::Error);
1345 assert_eq!(config.rules.unused_exports, Severity::Error);
1346 }
1347
1348 #[test]
1349 fn extends_npm_package_json_main() {
1350 let dir = test_dir("npm-main");
1351 create_npm_package_with_main(
1352 dir.path(),
1353 "fallow-config-acme",
1354 "config.json",
1355 r#"{"rules": {"unused-types": "off"}}"#,
1356 );
1357 std::fs::write(
1358 dir.path().join(".fallowrc.json"),
1359 r#"{"extends": "npm:fallow-config-acme"}"#,
1360 )
1361 .unwrap();
1362
1363 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1364 assert_eq!(config.rules.unused_types, Severity::Off);
1365 }
1366
1367 #[test]
1368 fn extends_npm_package_json_exports_string() {
1369 let dir = test_dir("npm-exports-str");
1370 let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1371 std::fs::create_dir_all(&pkg_dir).unwrap();
1372 std::fs::write(
1373 pkg_dir.join("package.json"),
1374 r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1375 )
1376 .unwrap();
1377 std::fs::write(
1378 pkg_dir.join("base.json"),
1379 r#"{"rules": {"circular-dependencies": "warn"}}"#,
1380 )
1381 .unwrap();
1382
1383 std::fs::write(
1384 dir.path().join(".fallowrc.json"),
1385 r#"{"extends": "npm:fallow-config-co"}"#,
1386 )
1387 .unwrap();
1388
1389 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1390 assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1391 }
1392
1393 #[test]
1394 fn extends_npm_package_json_exports_object() {
1395 let dir = test_dir("npm-exports-obj");
1396 let pkg_dir = dir.path().join("node_modules/@co/cfg");
1397 std::fs::create_dir_all(&pkg_dir).unwrap();
1398 std::fs::write(
1399 pkg_dir.join("package.json"),
1400 r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1401 )
1402 .unwrap();
1403 std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1404
1405 std::fs::write(
1406 dir.path().join(".fallowrc.json"),
1407 r#"{"extends": "npm:@co/cfg"}"#,
1408 )
1409 .unwrap();
1410
1411 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1412 assert_eq!(config.entry, vec!["src/app.ts"]);
1413 }
1414
1415 #[test]
1416 fn extends_npm_exports_takes_priority_over_main() {
1417 let dir = test_dir("npm-exports-prio");
1418 let pkg_dir = dir.path().join("node_modules/my-config");
1419 std::fs::create_dir_all(&pkg_dir).unwrap();
1420 std::fs::write(
1421 pkg_dir.join("package.json"),
1422 r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1423 )
1424 .unwrap();
1425 std::fs::write(
1426 pkg_dir.join("old.json"),
1427 r#"{"rules": {"unused-files": "off"}}"#,
1428 )
1429 .unwrap();
1430 std::fs::write(
1431 pkg_dir.join("new.json"),
1432 r#"{"rules": {"unused-files": "warn"}}"#,
1433 )
1434 .unwrap();
1435
1436 std::fs::write(
1437 dir.path().join(".fallowrc.json"),
1438 r#"{"extends": "npm:my-config"}"#,
1439 )
1440 .unwrap();
1441
1442 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1443 assert_eq!(config.rules.unused_files, Severity::Warn);
1445 }
1446
1447 #[test]
1448 fn extends_npm_walk_up_directories() {
1449 let dir = test_dir("npm-walkup");
1450 create_npm_package(
1452 dir.path(),
1453 "shared-config",
1454 r#"{"rules": {"unused-files": "warn"}}"#,
1455 );
1456 let sub = dir.path().join("packages/app");
1458 std::fs::create_dir_all(&sub).unwrap();
1459 std::fs::write(
1460 sub.join(".fallowrc.json"),
1461 r#"{"extends": "npm:shared-config"}"#,
1462 )
1463 .unwrap();
1464
1465 let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1466 assert_eq!(config.rules.unused_files, Severity::Warn);
1467 }
1468
1469 #[test]
1470 fn extends_npm_overlay_overrides_base() {
1471 let dir = test_dir("npm-overlay");
1472 create_npm_package(
1473 dir.path(),
1474 "@company/base",
1475 r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1476 );
1477 std::fs::write(
1478 dir.path().join(".fallowrc.json"),
1479 r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1480 )
1481 .unwrap();
1482
1483 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1484 assert_eq!(config.rules.unused_files, Severity::Error);
1485 assert_eq!(config.rules.unused_exports, Severity::Off);
1486 assert_eq!(config.entry, vec!["src/app.ts"]);
1487 }
1488
1489 #[test]
1490 fn extends_npm_chained_with_relative() {
1491 let dir = test_dir("npm-chained");
1492 let pkg_dir = dir.path().join("node_modules/my-config");
1494 std::fs::create_dir_all(&pkg_dir).unwrap();
1495 std::fs::write(
1496 pkg_dir.join("base.json"),
1497 r#"{"rules": {"unused-files": "warn"}}"#,
1498 )
1499 .unwrap();
1500 std::fs::write(
1501 pkg_dir.join(".fallowrc.json"),
1502 r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1503 )
1504 .unwrap();
1505
1506 std::fs::write(
1507 dir.path().join(".fallowrc.json"),
1508 r#"{"extends": "npm:my-config"}"#,
1509 )
1510 .unwrap();
1511
1512 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1513 assert_eq!(config.rules.unused_files, Severity::Warn);
1514 assert_eq!(config.rules.unused_exports, Severity::Off);
1515 }
1516
1517 #[test]
1518 fn extends_npm_mixed_with_relative_paths() {
1519 let dir = test_dir("npm-mixed");
1520 create_npm_package(
1521 dir.path(),
1522 "shared-base",
1523 r#"{"rules": {"unused-files": "off"}}"#,
1524 );
1525 std::fs::write(
1526 dir.path().join("local-overrides.json"),
1527 r#"{"rules": {"unused-files": "warn"}}"#,
1528 )
1529 .unwrap();
1530 std::fs::write(
1531 dir.path().join(".fallowrc.json"),
1532 r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1533 )
1534 .unwrap();
1535
1536 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1537 assert_eq!(config.rules.unused_files, Severity::Warn);
1539 }
1540
1541 #[test]
1542 fn extends_npm_missing_package_errors() {
1543 let dir = test_dir("npm-missing");
1544 std::fs::write(
1545 dir.path().join(".fallowrc.json"),
1546 r#"{"extends": "npm:nonexistent-package"}"#,
1547 )
1548 .unwrap();
1549
1550 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1551 assert!(result.is_err());
1552 let err_msg = format!("{}", result.unwrap_err());
1553 assert!(
1554 err_msg.contains("not found"),
1555 "Expected 'not found' error, got: {err_msg}"
1556 );
1557 assert!(
1558 err_msg.contains("nonexistent-package"),
1559 "Expected package name in error, got: {err_msg}"
1560 );
1561 assert!(
1562 err_msg.contains("install it"),
1563 "Expected install hint in error, got: {err_msg}"
1564 );
1565 }
1566
1567 #[test]
1568 fn extends_npm_no_config_in_package_errors() {
1569 let dir = test_dir("npm-no-config");
1570 let pkg_dir = dir.path().join("node_modules/empty-pkg");
1571 std::fs::create_dir_all(&pkg_dir).unwrap();
1572 std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1574
1575 std::fs::write(
1576 dir.path().join(".fallowrc.json"),
1577 r#"{"extends": "npm:empty-pkg"}"#,
1578 )
1579 .unwrap();
1580
1581 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1582 assert!(result.is_err());
1583 let err_msg = format!("{}", result.unwrap_err());
1584 assert!(
1585 err_msg.contains("No fallow config found"),
1586 "Expected 'No fallow config found' error, got: {err_msg}"
1587 );
1588 }
1589
1590 #[test]
1591 fn extends_npm_missing_subpath_errors() {
1592 let dir = test_dir("npm-missing-sub");
1593 let pkg_dir = dir.path().join("node_modules/@co/config");
1594 std::fs::create_dir_all(&pkg_dir).unwrap();
1595
1596 std::fs::write(
1597 dir.path().join(".fallowrc.json"),
1598 r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1599 )
1600 .unwrap();
1601
1602 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1603 assert!(result.is_err());
1604 let err_msg = format!("{}", result.unwrap_err());
1605 assert!(
1606 err_msg.contains("nonexistent.json"),
1607 "Expected subpath in error, got: {err_msg}"
1608 );
1609 }
1610
1611 #[test]
1612 fn extends_npm_empty_specifier_errors() {
1613 let dir = test_dir("npm-empty");
1614 std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1615
1616 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1617 assert!(result.is_err());
1618 let err_msg = format!("{}", result.unwrap_err());
1619 assert!(
1620 err_msg.contains("Empty npm specifier"),
1621 "Expected 'Empty npm specifier' error, got: {err_msg}"
1622 );
1623 }
1624
1625 #[test]
1626 fn extends_npm_space_after_colon_trimmed() {
1627 let dir = test_dir("npm-space");
1628 create_npm_package(
1629 dir.path(),
1630 "fallow-config-acme",
1631 r#"{"rules": {"unused-files": "warn"}}"#,
1632 );
1633 std::fs::write(
1635 dir.path().join(".fallowrc.json"),
1636 r#"{"extends": "npm: fallow-config-acme"}"#,
1637 )
1638 .unwrap();
1639
1640 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1641 assert_eq!(config.rules.unused_files, Severity::Warn);
1642 }
1643
1644 #[test]
1645 fn extends_npm_exports_node_condition() {
1646 let dir = test_dir("npm-node-cond");
1647 let pkg_dir = dir.path().join("node_modules/node-config");
1648 std::fs::create_dir_all(&pkg_dir).unwrap();
1649 std::fs::write(
1650 pkg_dir.join("package.json"),
1651 r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1652 )
1653 .unwrap();
1654 std::fs::write(
1655 pkg_dir.join("node.json"),
1656 r#"{"rules": {"unused-files": "off"}}"#,
1657 )
1658 .unwrap();
1659
1660 std::fs::write(
1661 dir.path().join(".fallowrc.json"),
1662 r#"{"extends": "npm:node-config"}"#,
1663 )
1664 .unwrap();
1665
1666 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1667 assert_eq!(config.rules.unused_files, Severity::Off);
1668 }
1669
1670 #[test]
1673 fn parse_npm_specifier_unscoped() {
1674 assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1675 }
1676
1677 #[test]
1678 fn parse_npm_specifier_unscoped_with_subpath() {
1679 assert_eq!(
1680 parse_npm_specifier("my-config/strict.json"),
1681 ("my-config", Some("strict.json"))
1682 );
1683 }
1684
1685 #[test]
1686 fn parse_npm_specifier_scoped() {
1687 assert_eq!(
1688 parse_npm_specifier("@company/fallow-config"),
1689 ("@company/fallow-config", None)
1690 );
1691 }
1692
1693 #[test]
1694 fn parse_npm_specifier_scoped_with_subpath() {
1695 assert_eq!(
1696 parse_npm_specifier("@company/fallow-config/strict.json"),
1697 ("@company/fallow-config", Some("strict.json"))
1698 );
1699 }
1700
1701 #[test]
1702 fn parse_npm_specifier_scoped_with_nested_subpath() {
1703 assert_eq!(
1704 parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1705 ("@company/fallow-config", Some("presets/strict.json"))
1706 );
1707 }
1708
1709 #[test]
1712 fn extends_npm_subpath_traversal_rejected() {
1713 let dir = test_dir("npm-traversal-sub");
1714 let pkg_dir = dir.path().join("node_modules/evil-pkg");
1715 std::fs::create_dir_all(&pkg_dir).unwrap();
1716 std::fs::write(
1718 dir.path().join("secret.json"),
1719 r#"{"entry": ["stolen.ts"]}"#,
1720 )
1721 .unwrap();
1722
1723 std::fs::write(
1724 dir.path().join(".fallowrc.json"),
1725 r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1726 )
1727 .unwrap();
1728
1729 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1730 assert!(result.is_err());
1731 let err_msg = format!("{}", result.unwrap_err());
1732 assert!(
1733 err_msg.contains("traversal") || err_msg.contains("not found"),
1734 "Expected traversal or not-found error, got: {err_msg}"
1735 );
1736 }
1737
1738 #[test]
1739 fn extends_npm_dotdot_package_name_rejected() {
1740 let dir = test_dir("npm-dotdot-name");
1741 std::fs::write(
1742 dir.path().join(".fallowrc.json"),
1743 r#"{"extends": "npm:../relative"}"#,
1744 )
1745 .unwrap();
1746
1747 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1748 assert!(result.is_err());
1749 let err_msg = format!("{}", result.unwrap_err());
1750 assert!(
1751 err_msg.contains("path traversal"),
1752 "Expected 'path traversal' error, got: {err_msg}"
1753 );
1754 }
1755
1756 #[test]
1757 fn extends_npm_scoped_without_name_rejected() {
1758 let dir = test_dir("npm-scope-only");
1759 std::fs::write(
1760 dir.path().join(".fallowrc.json"),
1761 r#"{"extends": "npm:@scope"}"#,
1762 )
1763 .unwrap();
1764
1765 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1766 assert!(result.is_err());
1767 let err_msg = format!("{}", result.unwrap_err());
1768 assert!(
1769 err_msg.contains("@scope/name"),
1770 "Expected scoped name format error, got: {err_msg}"
1771 );
1772 }
1773
1774 #[test]
1775 fn extends_npm_malformed_package_json_errors() {
1776 let dir = test_dir("npm-bad-pkgjson");
1777 let pkg_dir = dir.path().join("node_modules/bad-pkg");
1778 std::fs::create_dir_all(&pkg_dir).unwrap();
1779 std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1780
1781 std::fs::write(
1782 dir.path().join(".fallowrc.json"),
1783 r#"{"extends": "npm:bad-pkg"}"#,
1784 )
1785 .unwrap();
1786
1787 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1788 assert!(result.is_err());
1789 let err_msg = format!("{}", result.unwrap_err());
1790 assert!(
1791 err_msg.contains("Failed to parse"),
1792 "Expected parse error, got: {err_msg}"
1793 );
1794 }
1795
1796 #[test]
1797 fn extends_npm_exports_traversal_rejected() {
1798 let dir = test_dir("npm-exports-escape");
1799 let pkg_dir = dir.path().join("node_modules/evil-exports");
1800 std::fs::create_dir_all(&pkg_dir).unwrap();
1801 std::fs::write(
1802 pkg_dir.join("package.json"),
1803 r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1804 )
1805 .unwrap();
1806 std::fs::write(
1808 dir.path().join("secret.json"),
1809 r#"{"entry": ["stolen.ts"]}"#,
1810 )
1811 .unwrap();
1812
1813 std::fs::write(
1814 dir.path().join(".fallowrc.json"),
1815 r#"{"extends": "npm:evil-exports"}"#,
1816 )
1817 .unwrap();
1818
1819 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1820 assert!(result.is_err());
1821 let err_msg = format!("{}", result.unwrap_err());
1822 assert!(
1823 err_msg.contains("traversal"),
1824 "Expected traversal error, got: {err_msg}"
1825 );
1826 }
1827
1828 #[test]
1831 fn deep_merge_scalar_overlay_replaces_base() {
1832 let mut base = serde_json::json!("hello");
1833 deep_merge_json(&mut base, serde_json::json!("world"));
1834 assert_eq!(base, serde_json::json!("world"));
1835 }
1836
1837 #[test]
1838 fn deep_merge_array_overlay_replaces_base() {
1839 let mut base = serde_json::json!(["a", "b"]);
1840 deep_merge_json(&mut base, serde_json::json!(["c"]));
1841 assert_eq!(base, serde_json::json!(["c"]));
1842 }
1843
1844 #[test]
1845 fn deep_merge_nested_object_merge() {
1846 let mut base = serde_json::json!({
1847 "level1": {
1848 "level2": {
1849 "a": 1,
1850 "b": 2
1851 }
1852 }
1853 });
1854 let overlay = serde_json::json!({
1855 "level1": {
1856 "level2": {
1857 "b": 99,
1858 "c": 3
1859 }
1860 }
1861 });
1862 deep_merge_json(&mut base, overlay);
1863 assert_eq!(base["level1"]["level2"]["a"], 1);
1864 assert_eq!(base["level1"]["level2"]["b"], 99);
1865 assert_eq!(base["level1"]["level2"]["c"], 3);
1866 }
1867
1868 #[test]
1869 fn deep_merge_overlay_adds_new_fields() {
1870 let mut base = serde_json::json!({"existing": true});
1871 let overlay = serde_json::json!({"new_field": "added", "another": 42});
1872 deep_merge_json(&mut base, overlay);
1873 assert_eq!(base["existing"], true);
1874 assert_eq!(base["new_field"], "added");
1875 assert_eq!(base["another"], 42);
1876 }
1877
1878 #[test]
1879 fn deep_merge_null_overlay_replaces_object() {
1880 let mut base = serde_json::json!({"key": "value"});
1881 deep_merge_json(&mut base, serde_json::json!(null));
1882 assert_eq!(base, serde_json::json!(null));
1883 }
1884
1885 #[test]
1886 fn deep_merge_empty_object_overlay_preserves_base() {
1887 let mut base = serde_json::json!({"a": 1, "b": 2});
1888 deep_merge_json(&mut base, serde_json::json!({}));
1889 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1890 }
1891
1892 #[test]
1895 fn rules_severity_error_warn_off_from_json() {
1896 let json_str = r#"{
1897 "rules": {
1898 "unused-files": "error",
1899 "unused-exports": "warn",
1900 "unused-types": "off"
1901 }
1902 }"#;
1903 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1904 assert_eq!(config.rules.unused_files, Severity::Error);
1905 assert_eq!(config.rules.unused_exports, Severity::Warn);
1906 assert_eq!(config.rules.unused_types, Severity::Off);
1907 }
1908
1909 #[test]
1910 fn rules_omitted_default_to_error() {
1911 let json_str = r#"{
1912 "rules": {
1913 "unused-files": "warn"
1914 }
1915 }"#;
1916 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1917 assert_eq!(config.rules.unused_files, Severity::Warn);
1918 assert_eq!(config.rules.unused_exports, Severity::Error);
1920 assert_eq!(config.rules.unused_types, Severity::Error);
1921 assert_eq!(config.rules.unused_dependencies, Severity::Error);
1922 assert_eq!(config.rules.unresolved_imports, Severity::Error);
1923 assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
1924 assert_eq!(config.rules.duplicate_exports, Severity::Error);
1925 assert_eq!(config.rules.circular_dependencies, Severity::Error);
1926 assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
1928 }
1929
1930 #[test]
1933 fn find_and_load_returns_none_when_no_config() {
1934 let dir = test_dir("find-none");
1935 std::fs::create_dir(dir.path().join(".git")).unwrap();
1937
1938 let result = FallowConfig::find_and_load(dir.path()).unwrap();
1939 assert!(result.is_none());
1940 }
1941
1942 #[test]
1943 fn find_and_load_finds_fallowrc_json() {
1944 let dir = test_dir("find-json");
1945 std::fs::create_dir(dir.path().join(".git")).unwrap();
1946 std::fs::write(
1947 dir.path().join(".fallowrc.json"),
1948 r#"{"entry": ["src/main.ts"]}"#,
1949 )
1950 .unwrap();
1951
1952 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1953 assert_eq!(config.entry, vec!["src/main.ts"]);
1954 assert!(path.ends_with(".fallowrc.json"));
1955 }
1956
1957 #[test]
1958 fn find_and_load_prefers_fallowrc_json_over_toml() {
1959 let dir = test_dir("find-priority");
1960 std::fs::create_dir(dir.path().join(".git")).unwrap();
1961 std::fs::write(
1962 dir.path().join(".fallowrc.json"),
1963 r#"{"entry": ["from-json.ts"]}"#,
1964 )
1965 .unwrap();
1966 std::fs::write(
1967 dir.path().join("fallow.toml"),
1968 "entry = [\"from-toml.ts\"]\n",
1969 )
1970 .unwrap();
1971
1972 let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1973 assert_eq!(config.entry, vec!["from-json.ts"]);
1974 assert!(path.ends_with(".fallowrc.json"));
1975 }
1976
1977 #[test]
1978 fn find_and_load_finds_fallow_toml() {
1979 let dir = test_dir("find-toml");
1980 std::fs::create_dir(dir.path().join(".git")).unwrap();
1981 std::fs::write(
1982 dir.path().join("fallow.toml"),
1983 "entry = [\"src/index.ts\"]\n",
1984 )
1985 .unwrap();
1986
1987 let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1988 assert_eq!(config.entry, vec!["src/index.ts"]);
1989 }
1990
1991 #[test]
1992 fn find_and_load_stops_at_git_dir() {
1993 let dir = test_dir("find-git-stop");
1994 let sub = dir.path().join("sub");
1995 std::fs::create_dir(&sub).unwrap();
1996 std::fs::create_dir(dir.path().join(".git")).unwrap();
1998 let result = FallowConfig::find_and_load(&sub).unwrap();
2002 assert!(result.is_none());
2003 }
2004
2005 #[test]
2006 fn find_and_load_stops_at_package_json() {
2007 let dir = test_dir("find-pkg-stop");
2008 std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
2009
2010 let result = FallowConfig::find_and_load(dir.path()).unwrap();
2011 assert!(result.is_none());
2012 }
2013
2014 #[test]
2015 fn find_and_load_returns_error_for_invalid_config() {
2016 let dir = test_dir("find-invalid");
2017 std::fs::create_dir(dir.path().join(".git")).unwrap();
2018 std::fs::write(
2019 dir.path().join(".fallowrc.json"),
2020 r"{ this is not valid json }",
2021 )
2022 .unwrap();
2023
2024 let result = FallowConfig::find_and_load(dir.path());
2025 assert!(result.is_err());
2026 }
2027
2028 #[test]
2031 fn load_toml_config_file() {
2032 let dir = test_dir("toml-config");
2033 let config_path = dir.path().join("fallow.toml");
2034 std::fs::write(
2035 &config_path,
2036 r#"
2037entry = ["src/index.ts"]
2038ignorePatterns = ["dist/**"]
2039
2040[rules]
2041unused-files = "warn"
2042
2043[duplicates]
2044minTokens = 100
2045"#,
2046 )
2047 .unwrap();
2048
2049 let config = FallowConfig::load(&config_path).unwrap();
2050 assert_eq!(config.entry, vec!["src/index.ts"]);
2051 assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2052 assert_eq!(config.rules.unused_files, Severity::Warn);
2053 assert_eq!(config.duplicates.min_tokens, 100);
2054 }
2055
2056 #[test]
2059 fn extends_absolute_path_rejected() {
2060 let dir = test_dir("extends-absolute");
2061
2062 #[cfg(unix)]
2064 let abs_path = "/absolute/path/config.json";
2065 #[cfg(windows)]
2066 let abs_path = "C:\\absolute\\path\\config.json";
2067
2068 let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2069 std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2070
2071 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2072 assert!(result.is_err());
2073 let err_msg = format!("{}", result.unwrap_err());
2074 assert!(
2075 err_msg.contains("must be relative"),
2076 "Expected 'must be relative' error, got: {err_msg}"
2077 );
2078 }
2079
2080 #[test]
2083 fn resolve_production_mode_disables_dev_deps() {
2084 let config = FallowConfig {
2085 schema: None,
2086 extends: vec![],
2087 entry: vec![],
2088 ignore_patterns: vec![],
2089 framework: vec![],
2090 workspaces: None,
2091 ignore_dependencies: vec![],
2092 ignore_exports: vec![],
2093 duplicates: DuplicatesConfig::default(),
2094 health: HealthConfig::default(),
2095 rules: RulesConfig::default(),
2096 boundaries: BoundaryConfig::default(),
2097 production: true,
2098 plugins: vec![],
2099 dynamically_loaded: vec![],
2100 overrides: vec![],
2101 regression: None,
2102 codeowners: None,
2103 public_packages: vec![],
2104 };
2105 let resolved = config.resolve(
2106 PathBuf::from("/tmp/test"),
2107 OutputFormat::Human,
2108 4,
2109 false,
2110 true,
2111 );
2112 assert!(resolved.production);
2113 assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2114 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2115 assert_eq!(resolved.rules.unused_files, Severity::Error);
2117 assert_eq!(resolved.rules.unused_exports, Severity::Error);
2118 }
2119
2120 #[test]
2123 fn config_format_defaults_to_toml_for_unknown() {
2124 assert!(matches!(
2125 ConfigFormat::from_path(Path::new("config.yaml")),
2126 ConfigFormat::Toml
2127 ));
2128 assert!(matches!(
2129 ConfigFormat::from_path(Path::new("config")),
2130 ConfigFormat::Toml
2131 ));
2132 }
2133
2134 #[test]
2137 fn deep_merge_object_over_scalar_replaces() {
2138 let mut base = serde_json::json!("just a string");
2139 let overlay = serde_json::json!({"key": "value"});
2140 deep_merge_json(&mut base, overlay);
2141 assert_eq!(base, serde_json::json!({"key": "value"}));
2142 }
2143
2144 #[test]
2145 fn deep_merge_scalar_over_object_replaces() {
2146 let mut base = serde_json::json!({"key": "value"});
2147 let overlay = serde_json::json!(42);
2148 deep_merge_json(&mut base, overlay);
2149 assert_eq!(base, serde_json::json!(42));
2150 }
2151
2152 #[test]
2155 fn extends_non_string_non_array_ignored() {
2156 let dir = test_dir("extends-numeric");
2157 std::fs::write(
2158 dir.path().join(".fallowrc.json"),
2159 r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2160 )
2161 .unwrap();
2162
2163 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2165 assert_eq!(config.entry, vec!["src/index.ts"]);
2166 }
2167
2168 #[test]
2171 fn extends_multiple_bases_later_wins() {
2172 let dir = test_dir("extends-multi-base");
2173
2174 std::fs::write(
2175 dir.path().join("base-a.json"),
2176 r#"{"rules": {"unused-files": "warn"}}"#,
2177 )
2178 .unwrap();
2179 std::fs::write(
2180 dir.path().join("base-b.json"),
2181 r#"{"rules": {"unused-files": "off"}}"#,
2182 )
2183 .unwrap();
2184 std::fs::write(
2185 dir.path().join(".fallowrc.json"),
2186 r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2187 )
2188 .unwrap();
2189
2190 let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2191 assert_eq!(config.rules.unused_files, Severity::Off);
2193 }
2194
2195 #[test]
2198 fn fallow_config_deserialize_production() {
2199 let json_str = r#"{"production": true}"#;
2200 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2201 assert!(config.production);
2202 }
2203
2204 #[test]
2205 fn fallow_config_production_defaults_false() {
2206 let config: FallowConfig = serde_json::from_str("{}").unwrap();
2207 assert!(!config.production);
2208 }
2209
2210 #[test]
2213 fn package_json_optional_dependency_names() {
2214 let pkg: PackageJson = serde_json::from_str(
2215 r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2216 )
2217 .unwrap();
2218 let opt = pkg.optional_dependency_names();
2219 assert_eq!(opt.len(), 2);
2220 assert!(opt.contains(&"fsevents".to_string()));
2221 assert!(opt.contains(&"chokidar".to_string()));
2222 }
2223
2224 #[test]
2225 fn package_json_optional_deps_empty_when_missing() {
2226 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2227 assert!(pkg.optional_dependency_names().is_empty());
2228 }
2229
2230 #[test]
2233 fn find_config_path_returns_fallowrc_json() {
2234 let dir = test_dir("find-path-json");
2235 std::fs::create_dir(dir.path().join(".git")).unwrap();
2236 std::fs::write(
2237 dir.path().join(".fallowrc.json"),
2238 r#"{"entry": ["src/main.ts"]}"#,
2239 )
2240 .unwrap();
2241
2242 let path = FallowConfig::find_config_path(dir.path());
2243 assert!(path.is_some());
2244 assert!(path.unwrap().ends_with(".fallowrc.json"));
2245 }
2246
2247 #[test]
2248 fn find_config_path_returns_fallow_toml() {
2249 let dir = test_dir("find-path-toml");
2250 std::fs::create_dir(dir.path().join(".git")).unwrap();
2251 std::fs::write(
2252 dir.path().join("fallow.toml"),
2253 "entry = [\"src/main.ts\"]\n",
2254 )
2255 .unwrap();
2256
2257 let path = FallowConfig::find_config_path(dir.path());
2258 assert!(path.is_some());
2259 assert!(path.unwrap().ends_with("fallow.toml"));
2260 }
2261
2262 #[test]
2263 fn find_config_path_returns_dot_fallow_toml() {
2264 let dir = test_dir("find-path-dot-toml");
2265 std::fs::create_dir(dir.path().join(".git")).unwrap();
2266 std::fs::write(
2267 dir.path().join(".fallow.toml"),
2268 "entry = [\"src/main.ts\"]\n",
2269 )
2270 .unwrap();
2271
2272 let path = FallowConfig::find_config_path(dir.path());
2273 assert!(path.is_some());
2274 assert!(path.unwrap().ends_with(".fallow.toml"));
2275 }
2276
2277 #[test]
2278 fn find_config_path_prefers_json_over_toml() {
2279 let dir = test_dir("find-path-priority");
2280 std::fs::create_dir(dir.path().join(".git")).unwrap();
2281 std::fs::write(
2282 dir.path().join(".fallowrc.json"),
2283 r#"{"entry": ["json.ts"]}"#,
2284 )
2285 .unwrap();
2286 std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2287
2288 let path = FallowConfig::find_config_path(dir.path());
2289 assert!(path.unwrap().ends_with(".fallowrc.json"));
2290 }
2291
2292 #[test]
2293 fn find_config_path_none_when_no_config() {
2294 let dir = test_dir("find-path-none");
2295 std::fs::create_dir(dir.path().join(".git")).unwrap();
2296
2297 let path = FallowConfig::find_config_path(dir.path());
2298 assert!(path.is_none());
2299 }
2300
2301 #[test]
2302 fn find_config_path_stops_at_package_json() {
2303 let dir = test_dir("find-path-pkg-stop");
2304 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
2305
2306 let path = FallowConfig::find_config_path(dir.path());
2307 assert!(path.is_none());
2308 }
2309
2310 #[test]
2313 fn extends_toml_base() {
2314 let dir = test_dir("extends-toml");
2315
2316 std::fs::write(
2317 dir.path().join("base.json"),
2318 r#"{"rules": {"unused-files": "warn"}}"#,
2319 )
2320 .unwrap();
2321 std::fs::write(
2322 dir.path().join("fallow.toml"),
2323 "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2324 )
2325 .unwrap();
2326
2327 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2328 assert_eq!(config.rules.unused_files, Severity::Warn);
2329 assert_eq!(config.entry, vec!["src/index.ts"]);
2330 }
2331
2332 #[test]
2335 fn deep_merge_boolean_overlay() {
2336 let mut base = serde_json::json!(true);
2337 deep_merge_json(&mut base, serde_json::json!(false));
2338 assert_eq!(base, serde_json::json!(false));
2339 }
2340
2341 #[test]
2342 fn deep_merge_number_overlay() {
2343 let mut base = serde_json::json!(42);
2344 deep_merge_json(&mut base, serde_json::json!(99));
2345 assert_eq!(base, serde_json::json!(99));
2346 }
2347
2348 #[test]
2349 fn deep_merge_disjoint_objects() {
2350 let mut base = serde_json::json!({"a": 1});
2351 let overlay = serde_json::json!({"b": 2});
2352 deep_merge_json(&mut base, overlay);
2353 assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2354 }
2355
2356 #[test]
2359 fn max_extends_depth_is_reasonable() {
2360 assert_eq!(MAX_EXTENDS_DEPTH, 10);
2361 }
2362
2363 #[test]
2366 fn config_names_has_three_entries() {
2367 assert_eq!(CONFIG_NAMES.len(), 3);
2368 for name in CONFIG_NAMES {
2370 assert!(
2371 name.starts_with('.') || name.starts_with("fallow"),
2372 "unexpected config name: {name}"
2373 );
2374 }
2375 }
2376
2377 #[test]
2380 fn package_json_peer_dependency_names() {
2381 let pkg: PackageJson = serde_json::from_str(
2382 r#"{
2383 "dependencies": {"react": "^18"},
2384 "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2385 }"#,
2386 )
2387 .unwrap();
2388 let all = pkg.all_dependency_names();
2389 assert!(all.contains(&"react".to_string()));
2390 assert!(all.contains(&"react-dom".to_string()));
2391 assert!(all.contains(&"react-native".to_string()));
2392 }
2393
2394 #[test]
2397 fn package_json_scripts_field() {
2398 let pkg: PackageJson = serde_json::from_str(
2399 r#"{
2400 "scripts": {
2401 "build": "tsc",
2402 "test": "vitest",
2403 "lint": "fallow check"
2404 }
2405 }"#,
2406 )
2407 .unwrap();
2408 let scripts = pkg.scripts.unwrap();
2409 assert_eq!(scripts.len(), 3);
2410 assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2411 assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2412 }
2413
2414 #[test]
2417 fn extends_toml_chain() {
2418 let dir = test_dir("extends-toml-chain");
2419
2420 std::fs::write(
2421 dir.path().join("base.json"),
2422 r#"{"entry": ["src/base.ts"]}"#,
2423 )
2424 .unwrap();
2425 std::fs::write(
2426 dir.path().join("middle.json"),
2427 r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2428 )
2429 .unwrap();
2430 std::fs::write(
2431 dir.path().join("fallow.toml"),
2432 "extends = [\"middle.json\"]\n",
2433 )
2434 .unwrap();
2435
2436 let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2437 assert_eq!(config.entry, vec!["src/base.ts"]);
2438 assert_eq!(config.rules.unused_files, Severity::Off);
2439 }
2440
2441 #[test]
2444 fn find_and_load_walks_up_directories() {
2445 let dir = test_dir("find-walk-up");
2446 let sub = dir.path().join("src").join("deep");
2447 std::fs::create_dir_all(&sub).unwrap();
2448 std::fs::write(
2449 dir.path().join(".fallowrc.json"),
2450 r#"{"entry": ["src/main.ts"]}"#,
2451 )
2452 .unwrap();
2453 std::fs::create_dir(dir.path().join(".git")).unwrap();
2455
2456 let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2457 assert_eq!(config.entry, vec!["src/main.ts"]);
2458 assert!(path.ends_with(".fallowrc.json"));
2459 }
2460
2461 #[test]
2464 fn json_schema_contains_entry_field() {
2465 let schema = FallowConfig::json_schema();
2466 let obj = schema.as_object().unwrap();
2467 let props = obj.get("properties").and_then(|v| v.as_object());
2468 assert!(props.is_some(), "schema should have properties");
2469 assert!(
2470 props.unwrap().contains_key("entry"),
2471 "schema should contain entry property"
2472 );
2473 }
2474
2475 #[test]
2478 fn fallow_config_json_duplicates_all_fields() {
2479 let json = r#"{
2480 "duplicates": {
2481 "enabled": true,
2482 "mode": "semantic",
2483 "minTokens": 200,
2484 "minLines": 20,
2485 "threshold": 10.5,
2486 "ignore": ["**/*.test.ts"],
2487 "skipLocal": true,
2488 "crossLanguage": true,
2489 "normalization": {
2490 "ignoreIdentifiers": true,
2491 "ignoreStringValues": false
2492 }
2493 }
2494 }"#;
2495 let config: FallowConfig = serde_json::from_str(json).unwrap();
2496 assert!(config.duplicates.enabled);
2497 assert_eq!(
2498 config.duplicates.mode,
2499 crate::config::DetectionMode::Semantic
2500 );
2501 assert_eq!(config.duplicates.min_tokens, 200);
2502 assert_eq!(config.duplicates.min_lines, 20);
2503 assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2504 assert!(config.duplicates.skip_local);
2505 assert!(config.duplicates.cross_language);
2506 assert_eq!(
2507 config.duplicates.normalization.ignore_identifiers,
2508 Some(true)
2509 );
2510 assert_eq!(
2511 config.duplicates.normalization.ignore_string_values,
2512 Some(false)
2513 );
2514 }
2515
2516 #[test]
2519 fn normalize_url_basic() {
2520 assert_eq!(
2521 normalize_url_for_dedup("https://example.com/config.json"),
2522 "https://example.com/config.json"
2523 );
2524 }
2525
2526 #[test]
2527 fn normalize_url_trailing_slash() {
2528 assert_eq!(
2529 normalize_url_for_dedup("https://example.com/config/"),
2530 "https://example.com/config"
2531 );
2532 }
2533
2534 #[test]
2535 fn normalize_url_uppercase_scheme_and_host() {
2536 assert_eq!(
2537 normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2538 "https://example.com/Config.json"
2539 );
2540 }
2541
2542 #[test]
2543 fn normalize_url_root_path() {
2544 assert_eq!(
2545 normalize_url_for_dedup("https://example.com/"),
2546 "https://example.com"
2547 );
2548 assert_eq!(
2549 normalize_url_for_dedup("https://example.com"),
2550 "https://example.com"
2551 );
2552 }
2553
2554 #[test]
2555 fn normalize_url_preserves_path_case() {
2556 assert_eq!(
2558 normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2559 "https://github.com/Org/Repo/Fallow.json"
2560 );
2561 }
2562
2563 #[test]
2564 fn normalize_url_strips_query_string() {
2565 assert_eq!(
2566 normalize_url_for_dedup("https://example.com/config.json?v=1"),
2567 "https://example.com/config.json"
2568 );
2569 }
2570
2571 #[test]
2572 fn normalize_url_strips_fragment() {
2573 assert_eq!(
2574 normalize_url_for_dedup("https://example.com/config.json#section"),
2575 "https://example.com/config.json"
2576 );
2577 }
2578
2579 #[test]
2580 fn normalize_url_strips_query_and_fragment() {
2581 assert_eq!(
2582 normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2583 "https://example.com/config.json"
2584 );
2585 }
2586
2587 #[test]
2588 fn normalize_url_default_https_port() {
2589 assert_eq!(
2590 normalize_url_for_dedup("https://example.com:443/config.json"),
2591 "https://example.com/config.json"
2592 );
2593 assert_eq!(
2595 normalize_url_for_dedup("https://example.com:8443/config.json"),
2596 "https://example.com:8443/config.json"
2597 );
2598 }
2599
2600 #[test]
2601 fn extends_http_rejected() {
2602 let dir = test_dir("http-rejected");
2603 std::fs::write(
2604 dir.path().join(".fallowrc.json"),
2605 r#"{"extends": "http://example.com/config.json"}"#,
2606 )
2607 .unwrap();
2608
2609 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2610 assert!(result.is_err());
2611 let err_msg = format!("{}", result.unwrap_err());
2612 assert!(
2613 err_msg.contains("https://"),
2614 "Expected https hint in error, got: {err_msg}"
2615 );
2616 assert!(
2617 err_msg.contains("http://"),
2618 "Expected http:// mention in error, got: {err_msg}"
2619 );
2620 }
2621
2622 #[test]
2623 fn extends_url_circular_detection() {
2624 let mut visited = FxHashSet::default();
2626 let url = "https://example.com/config.json";
2627 let normalized = normalize_url_for_dedup(url);
2628 visited.insert(normalized.clone());
2629
2630 assert!(
2632 !visited.insert(normalized),
2633 "Same URL should be detected as duplicate"
2634 );
2635 }
2636
2637 #[test]
2638 fn extends_url_circular_case_insensitive() {
2639 let mut visited = FxHashSet::default();
2641 visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2642
2643 let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2644 assert!(
2645 !visited.insert(normalized),
2646 "Case-different URLs should normalize to the same key"
2647 );
2648 }
2649
2650 #[test]
2651 fn extract_extends_array() {
2652 let mut value = serde_json::json!({
2653 "extends": ["a.json", "b.json"],
2654 "entry": ["src/index.ts"]
2655 });
2656 let extends = extract_extends(&mut value);
2657 assert_eq!(extends, vec!["a.json", "b.json"]);
2658 assert!(value.get("extends").is_none());
2660 assert!(value.get("entry").is_some());
2661 }
2662
2663 #[test]
2664 fn extract_extends_string_sugar() {
2665 let mut value = serde_json::json!({
2666 "extends": "base.json",
2667 "entry": ["src/index.ts"]
2668 });
2669 let extends = extract_extends(&mut value);
2670 assert_eq!(extends, vec!["base.json"]);
2671 }
2672
2673 #[test]
2674 fn extract_extends_none() {
2675 let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2676 let extends = extract_extends(&mut value);
2677 assert!(extends.is_empty());
2678 }
2679
2680 #[test]
2681 fn url_timeout_default() {
2682 let timeout = url_timeout();
2684 assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2687 }
2688
2689 #[test]
2690 fn extends_url_mixed_with_file_and_npm() {
2691 let dir = test_dir("url-mixed");
2694 std::fs::write(
2695 dir.path().join("local.json"),
2696 r#"{"rules": {"unused-files": "warn"}}"#,
2697 )
2698 .unwrap();
2699 std::fs::write(
2700 dir.path().join(".fallowrc.json"),
2701 r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2702 )
2703 .unwrap();
2704
2705 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2706 assert!(result.is_err());
2707 let err_msg = format!("{}", result.unwrap_err());
2708 assert!(
2709 err_msg.contains("unreachable.invalid"),
2710 "Expected URL in error message, got: {err_msg}"
2711 );
2712 }
2713
2714 #[test]
2715 fn extends_https_url_unreachable_errors() {
2716 let dir = test_dir("url-unreachable");
2717 std::fs::write(
2718 dir.path().join(".fallowrc.json"),
2719 r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2720 )
2721 .unwrap();
2722
2723 let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2724 assert!(result.is_err());
2725 let err_msg = format!("{}", result.unwrap_err());
2726 assert!(
2727 err_msg.contains("unreachable.invalid"),
2728 "Expected URL in error, got: {err_msg}"
2729 );
2730 assert!(
2731 err_msg.contains("local path or npm:"),
2732 "Expected remediation hint, got: {err_msg}"
2733 );
2734 }
2735}