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