Skip to main content

fallow_config/config/
parsing.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9/// Supported config file names in priority order.
10///
11/// `find_and_load` checks these names in order within each directory,
12/// returning the first match found. `.fallowrc.json` wins over
13/// `.fallowrc.jsonc` if both exist (mirrors `tsconfig.json` >
14/// `tsconfig.jsonc` precedence).
15pub(super) const CONFIG_NAMES: &[&str] = &[
16    ".fallowrc.json",
17    ".fallowrc.jsonc",
18    "fallow.toml",
19    ".fallow.toml",
20];
21
22pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
23
24/// Prefix for npm package specifiers in the `extends` field.
25const NPM_PREFIX: &str = "npm:";
26
27/// Prefix for HTTPS URL specifiers in the `extends` field.
28const HTTPS_PREFIX: &str = "https://";
29
30/// Prefix for HTTP URL specifiers (rejected with a clear error).
31const HTTP_PREFIX: &str = "http://";
32
33/// Default timeout for fetching remote configs via URL extends.
34const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
35
36/// Detect config format from file extension.
37pub(super) enum ConfigFormat {
38    Toml,
39    Json,
40}
41
42impl ConfigFormat {
43    pub(super) fn from_path(path: &Path) -> Self {
44        match path.extension().and_then(|e| e.to_str()) {
45            Some("json" | "jsonc") => Self::Json,
46            _ => Self::Toml,
47        }
48    }
49}
50
51/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
52/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
53pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
54    match (base, overlay) {
55        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
56            for (key, value) in overlay_map {
57                if let Some(base_value) = base_map.get_mut(&key) {
58                    deep_merge_json(base_value, value);
59                } else {
60                    base_map.insert(key, value);
61                }
62            }
63        }
64        (base, overlay) => {
65            *base = overlay;
66        }
67    }
68}
69
70pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
71    let content = std::fs::read_to_string(path)
72        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
73
74    match ConfigFormat::from_path(path) {
75        ConfigFormat::Toml => {
76            let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
77                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
78            })?;
79            serde_json::to_value(toml_value).map_err(|e| {
80                miette::miette!(
81                    "Failed to convert TOML to JSON for {}: {}",
82                    path.display(),
83                    e
84                )
85            })
86        }
87        ConfigFormat::Json => {
88            let mut stripped = String::new();
89            json_comments::StripComments::new(content.as_bytes())
90                .read_to_string(&mut stripped)
91                .map_err(|e| {
92                    miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
93                })?;
94            serde_json::from_str(&stripped).map_err(|e| {
95                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
96            })
97        }
98    }
99}
100
101/// Return `true` if `dir` contains a VCS marker indicating a repository root.
102///
103/// Used as the walk-up stop condition for config discovery. Matches `.git`
104/// (directory for normal repos, file for git submodules/worktrees), `.hg`
105/// (Mercurial), and `.svn` (Subversion). We intentionally do NOT treat
106/// `package.json` as a stop boundary so monorepo sub-packages can inherit a
107/// root config. This matches Prettier/ESLint/Biome behavior.
108fn is_repo_root(dir: &Path) -> bool {
109    dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
110}
111
112/// Verify that `resolved` stays within `base_dir` after canonicalization.
113///
114/// Prevents path traversal attacks where a subpath or `package.json` field
115/// like `../../etc/passwd` escapes the intended directory.
116fn resolve_confined(
117    base_dir: &Path,
118    resolved: &Path,
119    context: &str,
120    source_config: &Path,
121) -> Result<PathBuf, miette::Report> {
122    let canonical_base = dunce::canonicalize(base_dir)
123        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
124    let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
125        miette::miette!(
126            "Config file not found: {} ({}, referenced from {}): {}",
127            resolved.display(),
128            context,
129            source_config.display(),
130            e
131        )
132    })?;
133    if !canonical_file.starts_with(&canonical_base) {
134        return Err(miette::miette!(
135            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
136            resolved.display(),
137            base_dir.display(),
138            context,
139            source_config.display()
140        ));
141    }
142    Ok(canonical_file)
143}
144
145/// Validate that a parsed package name is a legal npm package name.
146fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
147    if name.starts_with('@') && !name.contains('/') {
148        return Err(miette::miette!(
149            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
150            name,
151            source_config.display()
152        ));
153    }
154    if name.split('/').any(|c| c == ".." || c == ".") {
155        return Err(miette::miette!(
156            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
157            name,
158            source_config.display()
159        ));
160    }
161    Ok(())
162}
163
164/// Parse an npm specifier into `(package_name, optional_subpath)`.
165///
166/// Scoped: `@scope/name` → `("@scope/name", None)`,
167///         `@scope/name/strict.json` → `("@scope/name", Some("strict.json"))`.
168/// Unscoped: `name` → `("name", None)`,
169///           `name/strict.json` → `("name", Some("strict.json"))`.
170fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
171    if specifier.starts_with('@') {
172        // Scoped: @scope/name[/subpath]
173        // Find the second '/' which separates name from subpath.
174        let mut slashes = 0;
175        for (i, ch) in specifier.char_indices() {
176            if ch == '/' {
177                slashes += 1;
178                if slashes == 2 {
179                    return (&specifier[..i], Some(&specifier[i + 1..]));
180                }
181            }
182        }
183        // No subpath — entire string is the package name.
184        (specifier, None)
185    } else if let Some(slash) = specifier.find('/') {
186        (&specifier[..slash], Some(&specifier[slash + 1..]))
187    } else {
188        (specifier, None)
189    }
190}
191
192/// Resolve the default export path from a `package.json` `exports` field.
193///
194/// Handles the common patterns:
195/// - `"exports": "./config.json"` (string shorthand)
196/// - `"exports": {".": "./config.json"}` (object with default entry point)
197/// - `"exports": {".": {"default": "./config.json"}}` (conditional exports)
198fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
199    let exports = pkg.get("exports")?;
200    match exports {
201        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
202        serde_json::Value::Object(map) => {
203            let dot_export = map.get(".")?;
204            match dot_export {
205                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
206                serde_json::Value::Object(conditions) => {
207                    for key in ["default", "node", "import", "require"] {
208                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
209                            return Some(package_dir.join(s.as_str()));
210                        }
211                    }
212                    None
213                }
214                _ => None,
215            }
216        }
217        // Array export fallback form (e.g., `[\"./config.json\", null]`) is not supported;
218        // falls through to main/config name scan.
219        _ => None,
220    }
221}
222
223/// Find a fallow config file inside an npm package directory.
224///
225/// Resolution order:
226/// 1. `package.json` `exports` field (default entry point)
227/// 2. `package.json` `main` field
228/// 3. Standard config file names (`.fallowrc.json`, `.fallowrc.jsonc`, `fallow.toml`, `.fallow.toml`)
229///
230/// Paths from `exports`/`main` are confined to the package directory to prevent
231/// path traversal attacks from malicious packages.
232fn find_config_in_npm_package(
233    package_dir: &Path,
234    source_config: &Path,
235) -> Result<PathBuf, miette::Report> {
236    let pkg_json_path = package_dir.join("package.json");
237    if pkg_json_path.exists() {
238        let content = std::fs::read_to_string(&pkg_json_path)
239            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
240        let pkg: serde_json::Value = serde_json::from_str(&content)
241            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
242        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
243            && config_path.exists()
244        {
245            return resolve_confined(
246                package_dir,
247                &config_path,
248                "package.json exports",
249                source_config,
250            );
251        }
252        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
253            let main_path = package_dir.join(main);
254            if main_path.exists() {
255                return resolve_confined(
256                    package_dir,
257                    &main_path,
258                    "package.json main",
259                    source_config,
260                );
261            }
262        }
263    }
264
265    for config_name in CONFIG_NAMES {
266        let config_path = package_dir.join(config_name);
267        if config_path.exists() {
268            return resolve_confined(
269                package_dir,
270                &config_path,
271                "config name fallback",
272                source_config,
273            );
274        }
275    }
276
277    Err(miette::miette!(
278        "No fallow config found in npm package at {}. \
279         Expected package.json with main/exports pointing to a config file, \
280         or one of: {}",
281        package_dir.display(),
282        CONFIG_NAMES.join(", ")
283    ))
284}
285
286/// Resolve an npm package specifier to a config file path.
287///
288/// Walks up from `config_dir` looking for `node_modules/<package_name>`.
289/// If a subpath is given (e.g., `@scope/name/strict.json`), resolves that file directly.
290/// Otherwise, finds the config file inside the package via [`find_config_in_npm_package`].
291fn resolve_npm_package(
292    config_dir: &Path,
293    specifier: &str,
294    source_config: &Path,
295) -> Result<PathBuf, miette::Report> {
296    let specifier = specifier.trim();
297    if specifier.is_empty() {
298        return Err(miette::miette!(
299            "Empty npm specifier in extends (in {})",
300            source_config.display()
301        ));
302    }
303
304    let (package_name, subpath) = parse_npm_specifier(specifier);
305    validate_npm_package_name(package_name, source_config)?;
306
307    let mut dir = Some(config_dir);
308    while let Some(d) = dir {
309        let candidate = d.join("node_modules").join(package_name);
310        if candidate.is_dir() {
311            return if let Some(sub) = subpath {
312                let file = candidate.join(sub);
313                if file.exists() {
314                    resolve_confined(
315                        &candidate,
316                        &file,
317                        &format!("subpath '{sub}'"),
318                        source_config,
319                    )
320                } else {
321                    Err(miette::miette!(
322                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
323                        file.display(),
324                        sub,
325                        candidate.display(),
326                        source_config.display()
327                    ))
328                }
329            } else {
330                find_config_in_npm_package(&candidate, source_config)
331            };
332        }
333        dir = d.parent();
334    }
335
336    Err(miette::miette!(
337        "npm package '{}' not found. \
338         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
339         If this package should be available, install it and ensure it is listed in your project's dependencies",
340        package_name,
341        package_name,
342        config_dir.display(),
343        source_config.display()
344    ))
345}
346
347/// Normalize a URL for deduplication.
348///
349/// - Lowercase scheme and host (path casing is preserved — it's server-dependent).
350/// - Strip fragment (`#...`) and query string (`?...`).
351/// - Strip trailing slash from path.
352/// - Normalize default HTTPS port (`:443` → omitted).
353fn normalize_url_for_dedup(url: &str) -> String {
354    // Split at the first `://` to get scheme, then find host boundary.
355    let Some((scheme, rest)) = url.split_once("://") else {
356        return url.to_string();
357    };
358    let scheme = scheme.to_ascii_lowercase();
359
360    // Split host from path at the first `/` after the authority.
361    let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
362    let authority = authority.to_ascii_lowercase();
363
364    // Strip default HTTPS port.
365    let authority = authority.strip_suffix(":443").unwrap_or(&authority);
366
367    // Strip fragment and query string from path, then trailing slash.
368    let path = path.split_once('#').map_or(path, |(p, _)| p);
369    let path = path.split_once('?').map_or(path, |(p, _)| p);
370    let path = path.strip_suffix('/').unwrap_or(path);
371
372    if path.is_empty() {
373        format!("{scheme}://{authority}")
374    } else {
375        format!("{scheme}://{authority}/{path}")
376    }
377}
378
379/// Read the `FALLOW_EXTENDS_TIMEOUT_SECS` env var, falling back to [`DEFAULT_URL_TIMEOUT_SECS`].
380///
381/// A value of `0` is treated as invalid and falls back to the default (a zero-duration
382/// timeout would make every request fail immediately with an opaque timeout error).
383fn url_timeout() -> Duration {
384    std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
385        .ok()
386        .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
387        .map_or(
388            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
389            Duration::from_secs,
390        )
391}
392
393/// Maximum response body size for fetched config files (1 MB).
394/// Config files are never legitimately larger than a few kilobytes.
395const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
396
397/// Fetch a remote JSON config from an HTTPS URL.
398///
399/// Returns the parsed `serde_json::Value`. Only JSON (with optional JSONC comments) is
400/// supported for URL-sourced configs — TOML cannot be detected without a file extension.
401fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
402    let timeout = url_timeout();
403    let agent = ureq::Agent::config_builder()
404        .timeout_global(Some(timeout))
405        .https_only(true)
406        .build()
407        .new_agent();
408
409    let mut response = agent.get(url).call().map_err(|e| {
410        miette::miette!(
411            "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
412             If this URL is unavailable, use a local path or npm: specifier instead"
413        )
414    })?;
415
416    let body = response
417        .body_mut()
418        .with_config()
419        .limit(MAX_URL_CONFIG_BYTES)
420        .read_to_string()
421        .map_err(|e| {
422            miette::miette!(
423                "Failed to read response body from {url} (referenced from {source}): {e}"
424            )
425        })?;
426
427    // Strip JSONC comments before parsing.
428    let mut stripped = String::new();
429    json_comments::StripComments::new(body.as_bytes())
430        .read_to_string(&mut stripped)
431        .map_err(|e| {
432            miette::miette!(
433                "Failed to strip comments from remote config {url} (referenced from {source}): {e}"
434            )
435        })?;
436
437    serde_json::from_str(&stripped).map_err(|e| {
438        miette::miette!(
439            "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
440             Only JSON/JSONC is supported for URL-sourced configs"
441        )
442    })
443}
444
445/// Extract the `extends` array from a parsed JSON config value, removing it from the object.
446fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
447    value
448        .as_object_mut()
449        .and_then(|obj| obj.remove("extends"))
450        .and_then(|v| match v {
451            serde_json::Value::Array(arr) => Some(
452                arr.into_iter()
453                    .filter_map(|v| v.as_str().map(String::from))
454                    .collect::<Vec<_>>(),
455            ),
456            serde_json::Value::String(s) => Some(vec![s]),
457            _ => None,
458        })
459        .unwrap_or_default()
460}
461
462/// Resolve extends entries from a URL-sourced config.
463///
464/// URL-sourced configs may extend other URLs or `npm:` packages, but NOT relative
465/// paths (there is no filesystem base directory for a URL).
466fn resolve_url_extends(
467    url: &str,
468    visited: &mut FxHashSet<String>,
469    depth: usize,
470) -> Result<serde_json::Value, miette::Report> {
471    if depth >= MAX_EXTENDS_DEPTH {
472        return Err(miette::miette!(
473            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
474        ));
475    }
476
477    let normalized = normalize_url_for_dedup(url);
478    if !visited.insert(normalized) {
479        return Err(miette::miette!(
480            "Circular extends detected: {url} was already visited in the extends chain"
481        ));
482    }
483
484    let mut value = fetch_url_config(url, url)?;
485    let extends = extract_extends(&mut value);
486
487    if extends.is_empty() {
488        return Ok(value);
489    }
490
491    let mut merged = serde_json::Value::Object(serde_json::Map::new());
492
493    for entry in &extends {
494        let base = if entry.starts_with(HTTPS_PREFIX) {
495            resolve_url_extends(entry, visited, depth + 1)?
496        } else if entry.starts_with(HTTP_PREFIX) {
497            return Err(miette::miette!(
498                "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
499                 Change the URL to use https:// instead",
500                entry,
501                url
502            ));
503        } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
504            // npm: from URL context — no config_dir to walk up from, so we use the cwd.
505            // This is a best-effort fallback; the npm package must be available in the
506            // working directory's node_modules tree.
507            let cwd = std::env::current_dir().map_err(|e| {
508                miette::miette!(
509                    "Cannot resolve npm: specifier from URL-sourced config: \
510                     failed to determine current directory: {e}"
511                )
512            })?;
513            tracing::warn!(
514                "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
515                 current working directory for node_modules lookup"
516            );
517            let path_placeholder = PathBuf::from(url);
518            let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
519            resolve_extends_file(&npm_path, visited, depth + 1)?
520        } else {
521            return Err(miette::miette!(
522                "Relative paths in 'extends' are not supported when the base config was \
523                 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
524                 instead. Got: '{entry}'"
525            ));
526        };
527        deep_merge_json(&mut merged, base);
528    }
529
530    deep_merge_json(&mut merged, value);
531    Ok(merged)
532}
533
534/// Resolve extends from a local config file.
535///
536/// This is the main recursive resolver for file-based configs. It reads the file,
537/// extracts `extends`, and recursively resolves each entry (relative paths, npm
538/// packages, or HTTPS URLs).
539fn resolve_extends_file(
540    path: &Path,
541    visited: &mut FxHashSet<String>,
542    depth: usize,
543) -> Result<serde_json::Value, miette::Report> {
544    if depth >= MAX_EXTENDS_DEPTH {
545        return Err(miette::miette!(
546            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
547            path.display()
548        ));
549    }
550
551    let canonical = dunce::canonicalize(path).map_err(|e| {
552        miette::miette!(
553            "Config file not found or unresolvable: {}: {}",
554            path.display(),
555            e
556        )
557    })?;
558
559    if !visited.insert(canonical.to_string_lossy().into_owned()) {
560        return Err(miette::miette!(
561            "Circular extends detected: {} was already visited in the extends chain",
562            path.display()
563        ));
564    }
565
566    let mut value = parse_config_to_value(path)?;
567    let extends = extract_extends(&mut value);
568
569    if extends.is_empty() {
570        return Ok(value);
571    }
572
573    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
574    let sealed = value
575        .get("sealed")
576        .and_then(serde_json::Value::as_bool)
577        .unwrap_or(false);
578    // Canonicalize the config directory once when sealed; reused inside the
579    // loop for each `extends` confinement check.
580    let sealed_dir_canonical = if sealed {
581        Some(dunce::canonicalize(config_dir).map_err(|e| {
582            miette::miette!(
583                "Sealed config directory '{}' could not be canonicalized: {e}",
584                config_dir.display()
585            )
586        })?)
587    } else {
588        None
589    };
590    let mut merged = serde_json::Value::Object(serde_json::Map::new());
591
592    for extend_path_str in &extends {
593        let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
594            if sealed {
595                return Err(miette::miette!(
596                    "'sealed: true' config at {} rejects URL extends '{}'. \
597                     Sealed configs only allow file-relative extends within \
598                     the config's directory",
599                    path.display(),
600                    extend_path_str
601                ));
602            }
603            resolve_url_extends(extend_path_str, visited, depth + 1)?
604        } else if extend_path_str.starts_with(HTTP_PREFIX) {
605            return Err(miette::miette!(
606                "URL extends must use https://, got http:// URL '{}' (in {}). \
607                 Change the URL to use https:// instead",
608                extend_path_str,
609                path.display()
610            ));
611        } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
612            if sealed {
613                return Err(miette::miette!(
614                    "'sealed: true' config at {} rejects npm extends '{}'. \
615                     Sealed configs only allow file-relative extends within \
616                     the config's directory",
617                    path.display(),
618                    extend_path_str
619                ));
620            }
621            let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
622            resolve_extends_file(&npm_path, visited, depth + 1)?
623        } else {
624            if Path::new(extend_path_str).is_absolute() {
625                return Err(miette::miette!(
626                    "extends paths must be relative, got absolute path: {} (in {})",
627                    extend_path_str,
628                    path.display()
629                ));
630            }
631            let p = config_dir.join(extend_path_str);
632            if !p.exists() {
633                return Err(miette::miette!(
634                    "Extended config file not found: {} (referenced from {})",
635                    p.display(),
636                    path.display()
637                ));
638            }
639            if let Some(dir_canonical) = &sealed_dir_canonical {
640                let p_canonical = dunce::canonicalize(&p).map_err(|e| {
641                    miette::miette!(
642                        "Sealed config extends path '{}' could not be canonicalized: {e}",
643                        p.display()
644                    )
645                })?;
646                if !p_canonical.starts_with(dir_canonical) {
647                    return Err(miette::miette!(
648                        "'sealed: true' config at {} rejects extends '{}' which resolves \
649                         outside the config's directory ({}). Sealed configs only allow \
650                         extends within the config's directory",
651                        path.display(),
652                        extend_path_str,
653                        p_canonical.display()
654                    ));
655                }
656            }
657            resolve_extends_file(&p, visited, depth + 1)?
658        };
659        deep_merge_json(&mut merged, base);
660    }
661
662    deep_merge_json(&mut merged, value);
663    Ok(merged)
664}
665
666/// Public entry point: resolve a config file with all its extends chain.
667///
668/// Delegates to [`resolve_extends_file`] with a fresh visited set.
669pub(super) fn resolve_extends(
670    path: &Path,
671    visited: &mut FxHashSet<String>,
672    depth: usize,
673) -> Result<serde_json::Value, miette::Report> {
674    resolve_extends_file(path, visited, depth)
675}
676
677impl FallowConfig {
678    /// Load config from a fallow config file (TOML or JSON/JSONC).
679    ///
680    /// The format is detected from the file extension:
681    /// - `.toml` → TOML
682    /// - `.json` → JSON (with JSONC comment stripping)
683    ///
684    /// Supports `extends` for config inheritance. Extended configs are loaded
685    /// and deep-merged before this config's values are applied.
686    ///
687    /// # Errors
688    ///
689    /// Returns an error when the config file cannot be read, merged, or deserialized.
690    pub fn load(path: &Path) -> Result<Self, miette::Report> {
691        let mut visited = FxHashSet::default();
692        let merged = resolve_extends(path, &mut visited, 0)?;
693
694        serde_json::from_value(merged).map_err(|e| {
695            miette::miette!(
696                "Failed to deserialize config from {}: {}",
697                path.display(),
698                e
699            )
700        })
701    }
702
703    /// Find the config file path without loading it.
704    /// Searches the same locations as `find_and_load`.
705    #[must_use]
706    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
707        let mut dir = start;
708        loop {
709            for name in CONFIG_NAMES {
710                let candidate = dir.join(name);
711                if candidate.exists() {
712                    return Some(candidate);
713                }
714            }
715            if is_repo_root(dir) {
716                break;
717            }
718            dir = dir.parent()?;
719        }
720        None
721    }
722
723    /// Find and load config, searching from `start` up to the project root.
724    ///
725    /// # Errors
726    ///
727    /// Returns an error if a config file is found but cannot be read or parsed.
728    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
729        let mut dir = start;
730        loop {
731            for name in CONFIG_NAMES {
732                let candidate = dir.join(name);
733                if candidate.exists() {
734                    match Self::load(&candidate) {
735                        Ok(config) => return Ok(Some((config, candidate))),
736                        Err(e) => {
737                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
738                        }
739                    }
740                }
741            }
742            // Stop at project root indicators (VCS markers). We intentionally
743            // do NOT stop at `package.json` so that monorepo sub-packages
744            // inherit a root config placed alongside the workspace root.
745            if is_repo_root(dir) {
746                break;
747            }
748            dir = match dir.parent() {
749                Some(parent) => parent,
750                None => break,
751            };
752        }
753        Ok(None)
754    }
755
756    /// Generate JSON Schema for the configuration format.
757    #[must_use]
758    pub fn json_schema() -> serde_json::Value {
759        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use std::io::Read as _;
766
767    use super::*;
768    use crate::PackageJson;
769    use crate::config::format::OutputFormat;
770    use crate::config::rules::Severity;
771
772    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
773    fn test_dir(_name: &str) -> tempfile::TempDir {
774        tempfile::tempdir().expect("create temp dir")
775    }
776
777    #[test]
778    fn fallow_config_deserialize_minimal() {
779        let toml_str = r#"
780entry = ["src/main.ts"]
781"#;
782        let config: FallowConfig = toml::from_str(toml_str).unwrap();
783        assert_eq!(config.entry, vec!["src/main.ts"]);
784        assert!(config.ignore_patterns.is_empty());
785    }
786
787    #[test]
788    fn fallow_config_deserialize_ignore_exports() {
789        let toml_str = r#"
790[[ignoreExports]]
791file = "src/types/*.ts"
792exports = ["*"]
793
794[[ignoreExports]]
795file = "src/constants.ts"
796exports = ["FOO", "BAR"]
797"#;
798        let config: FallowConfig = toml::from_str(toml_str).unwrap();
799        assert_eq!(config.ignore_exports.len(), 2);
800        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
801        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
802        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
803    }
804
805    #[test]
806    fn fallow_config_deserialize_ignore_dependencies() {
807        let toml_str = r#"
808ignoreDependencies = ["autoprefixer", "postcss"]
809"#;
810        let config: FallowConfig = toml::from_str(toml_str).unwrap();
811        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
812    }
813
814    #[test]
815    fn fallow_config_resolve_default_ignores() {
816        let config = FallowConfig::default();
817        let resolved = config.resolve(
818            PathBuf::from("/tmp/test"),
819            OutputFormat::Human,
820            4,
821            true,
822            true,
823        );
824
825        // Default ignores should be compiled
826        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
827        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
828        assert!(resolved.ignore_patterns.is_match("build/output.js"));
829        assert!(resolved.ignore_patterns.is_match(".git/config"));
830        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
831        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
832        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
833    }
834
835    #[test]
836    fn fallow_config_resolve_custom_ignores() {
837        let config = FallowConfig {
838            entry: vec!["src/**/*.ts".to_string()],
839            ignore_patterns: vec!["**/*.generated.ts".to_string()],
840            ..Default::default()
841        };
842        let resolved = config.resolve(
843            PathBuf::from("/tmp/test"),
844            OutputFormat::Json,
845            4,
846            false,
847            true,
848        );
849
850        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
851        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
852        assert!(matches!(resolved.output, OutputFormat::Json));
853        assert!(!resolved.no_cache);
854    }
855
856    #[test]
857    fn fallow_config_resolve_cache_dir() {
858        let config = FallowConfig::default();
859        let resolved = config.resolve(
860            PathBuf::from("/tmp/project"),
861            OutputFormat::Human,
862            4,
863            true,
864            true,
865        );
866        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
867        assert!(resolved.no_cache);
868    }
869
870    #[test]
871    fn package_json_entry_points_main() {
872        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
873        let entries = pkg.entry_points();
874        assert!(entries.contains(&"dist/index.js".to_string()));
875    }
876
877    #[test]
878    fn package_json_entry_points_module() {
879        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
880        let entries = pkg.entry_points();
881        assert!(entries.contains(&"dist/index.mjs".to_string()));
882    }
883
884    #[test]
885    fn package_json_entry_points_types() {
886        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
887        let entries = pkg.entry_points();
888        assert!(entries.contains(&"dist/index.d.ts".to_string()));
889    }
890
891    #[test]
892    fn package_json_entry_points_bin_string() {
893        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
894        let entries = pkg.entry_points();
895        assert!(entries.contains(&"bin/cli.js".to_string()));
896    }
897
898    #[test]
899    fn package_json_entry_points_bin_object() {
900        let pkg: PackageJson =
901            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
902                .unwrap();
903        let entries = pkg.entry_points();
904        assert!(entries.contains(&"bin/cli.js".to_string()));
905        assert!(entries.contains(&"bin/serve.js".to_string()));
906    }
907
908    #[test]
909    fn package_json_entry_points_exports_string() {
910        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
911        let entries = pkg.entry_points();
912        assert!(entries.contains(&"./dist/index.js".to_string()));
913    }
914
915    #[test]
916    fn package_json_entry_points_exports_object() {
917        let pkg: PackageJson = serde_json::from_str(
918            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
919        )
920        .unwrap();
921        let entries = pkg.entry_points();
922        assert!(entries.contains(&"./dist/index.mjs".to_string()));
923        assert!(entries.contains(&"./dist/index.cjs".to_string()));
924    }
925
926    #[test]
927    fn package_json_dependency_names() {
928        let pkg: PackageJson = serde_json::from_str(
929            r#"{
930            "dependencies": {"react": "^18", "lodash": "^4"},
931            "devDependencies": {"typescript": "^5"},
932            "peerDependencies": {"react-dom": "^18"}
933        }"#,
934        )
935        .unwrap();
936
937        let all = pkg.all_dependency_names();
938        assert!(all.contains(&"react".to_string()));
939        assert!(all.contains(&"lodash".to_string()));
940        assert!(all.contains(&"typescript".to_string()));
941        assert!(all.contains(&"react-dom".to_string()));
942
943        let prod = pkg.production_dependency_names();
944        assert!(prod.contains(&"react".to_string()));
945        assert!(!prod.contains(&"typescript".to_string()));
946
947        let dev = pkg.dev_dependency_names();
948        assert!(dev.contains(&"typescript".to_string()));
949        assert!(!dev.contains(&"react".to_string()));
950    }
951
952    #[test]
953    fn package_json_no_dependencies() {
954        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
955        assert!(pkg.all_dependency_names().is_empty());
956        assert!(pkg.production_dependency_names().is_empty());
957        assert!(pkg.dev_dependency_names().is_empty());
958        assert!(pkg.entry_points().is_empty());
959    }
960
961    #[test]
962    fn rules_deserialize_toml_kebab_case() {
963        let toml_str = r#"
964[rules]
965unused-files = "error"
966unused-exports = "warn"
967unused-types = "off"
968"#;
969        let config: FallowConfig = toml::from_str(toml_str).unwrap();
970        assert_eq!(config.rules.unused_files, Severity::Error);
971        assert_eq!(config.rules.unused_exports, Severity::Warn);
972        assert_eq!(config.rules.unused_types, Severity::Off);
973        // Unset fields default to error
974        assert_eq!(config.rules.unresolved_imports, Severity::Error);
975    }
976
977    #[test]
978    fn config_without_rules_defaults_to_error() {
979        let toml_str = r#"
980entry = ["src/main.ts"]
981"#;
982        let config: FallowConfig = toml::from_str(toml_str).unwrap();
983        assert_eq!(config.rules.unused_files, Severity::Error);
984        assert_eq!(config.rules.unused_exports, Severity::Error);
985    }
986
987    #[test]
988    fn fallow_config_denies_unknown_fields() {
989        let toml_str = r"
990unknown_field = true
991";
992        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
993        assert!(result.is_err());
994    }
995
996    #[test]
997    fn fallow_config_deserialize_json() {
998        let json_str = r#"{"entry": ["src/main.ts"]}"#;
999        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1000        assert_eq!(config.entry, vec!["src/main.ts"]);
1001    }
1002
1003    #[test]
1004    fn fallow_config_deserialize_jsonc() {
1005        let jsonc_str = r#"{
1006            // This is a comment
1007            "entry": ["src/main.ts"],
1008            "rules": {
1009                "unused-files": "warn"
1010            }
1011        }"#;
1012        let mut stripped = String::new();
1013        json_comments::StripComments::new(jsonc_str.as_bytes())
1014            .read_to_string(&mut stripped)
1015            .unwrap();
1016        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1017        assert_eq!(config.entry, vec!["src/main.ts"]);
1018        assert_eq!(config.rules.unused_files, Severity::Warn);
1019    }
1020
1021    #[test]
1022    fn fallow_config_json_with_schema_field() {
1023        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1024        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1025        assert_eq!(config.entry, vec!["src/main.ts"]);
1026    }
1027
1028    #[test]
1029    fn fallow_config_json_schema_generation() {
1030        let schema = FallowConfig::json_schema();
1031        assert!(schema.is_object());
1032        let obj = schema.as_object().unwrap();
1033        assert!(obj.contains_key("properties"));
1034    }
1035
1036    #[test]
1037    fn config_format_detection() {
1038        assert!(matches!(
1039            ConfigFormat::from_path(Path::new("fallow.toml")),
1040            ConfigFormat::Toml
1041        ));
1042        assert!(matches!(
1043            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1044            ConfigFormat::Json
1045        ));
1046        assert!(matches!(
1047            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1048            ConfigFormat::Json
1049        ));
1050        assert!(matches!(
1051            ConfigFormat::from_path(Path::new(".fallow.toml")),
1052            ConfigFormat::Toml
1053        ));
1054    }
1055
1056    #[test]
1057    fn config_names_priority_order() {
1058        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1059        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1060        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1061        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1062    }
1063
1064    #[test]
1065    fn load_json_config_file() {
1066        let dir = test_dir("json-config");
1067        let config_path = dir.path().join(".fallowrc.json");
1068        std::fs::write(
1069            &config_path,
1070            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1071        )
1072        .unwrap();
1073
1074        let config = FallowConfig::load(&config_path).unwrap();
1075        assert_eq!(config.entry, vec!["src/index.ts"]);
1076        assert_eq!(config.rules.unused_exports, Severity::Warn);
1077    }
1078
1079    #[test]
1080    fn load_jsonc_config_file() {
1081        let dir = test_dir("jsonc-config");
1082        let config_path = dir.path().join(".fallowrc.json");
1083        std::fs::write(
1084            &config_path,
1085            r#"{
1086                // Entry points for analysis
1087                "entry": ["src/index.ts"],
1088                /* Block comment */
1089                "rules": {
1090                    "unused-exports": "warn"
1091                }
1092            }"#,
1093        )
1094        .unwrap();
1095
1096        let config = FallowConfig::load(&config_path).unwrap();
1097        assert_eq!(config.entry, vec!["src/index.ts"]);
1098        assert_eq!(config.rules.unused_exports, Severity::Warn);
1099    }
1100
1101    #[test]
1102    fn load_fallowrc_jsonc_extension() {
1103        let dir = test_dir("jsonc-extension");
1104        let config_path = dir.path().join(".fallowrc.jsonc");
1105        std::fs::write(
1106            &config_path,
1107            r#"{
1108                // editors that recognize the .jsonc extension show
1109                // proper JSON-with-comments syntax highlighting
1110                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1111                "entry": ["src/index.ts"]
1112            }"#,
1113        )
1114        .unwrap();
1115
1116        let config = FallowConfig::load(&config_path).unwrap();
1117        assert_eq!(config.entry, vec!["src/index.ts"]);
1118        assert_eq!(
1119            config.ignore_dependencies,
1120            vec!["tailwindcss-react-aria-components"]
1121        );
1122    }
1123
1124    #[test]
1125    fn json_config_ignore_dependencies_camel_case() {
1126        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1127        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1128        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1129    }
1130
1131    #[test]
1132    fn json_config_all_fields() {
1133        let json_str = r#"{
1134            "ignoreDependencies": ["lodash"],
1135            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1136            "rules": {
1137                "unused-files": "off",
1138                "unused-exports": "warn",
1139                "unused-dependencies": "error",
1140                "unused-dev-dependencies": "off",
1141                "unused-types": "warn",
1142                "unused-enum-members": "error",
1143                "unused-class-members": "off",
1144                "unresolved-imports": "warn",
1145                "unlisted-dependencies": "error",
1146                "duplicate-exports": "off"
1147            },
1148            "duplicates": {
1149                "minTokens": 100,
1150                "minLines": 10,
1151                "skipLocal": true
1152            }
1153        }"#;
1154        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1155        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1156        assert_eq!(config.rules.unused_files, Severity::Off);
1157        assert_eq!(config.rules.unused_exports, Severity::Warn);
1158        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1159        assert_eq!(config.duplicates.min_tokens, 100);
1160        assert_eq!(config.duplicates.min_lines, 10);
1161        assert!(config.duplicates.skip_local);
1162    }
1163
1164    // ── extends tests ──────────────────────────────────────────────
1165
1166    #[test]
1167    fn extends_single_base() {
1168        let dir = test_dir("extends-single");
1169
1170        std::fs::write(
1171            dir.path().join("base.json"),
1172            r#"{"rules": {"unused-files": "warn"}}"#,
1173        )
1174        .unwrap();
1175        std::fs::write(
1176            dir.path().join(".fallowrc.json"),
1177            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1178        )
1179        .unwrap();
1180
1181        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1182        assert_eq!(config.rules.unused_files, Severity::Warn);
1183        assert_eq!(config.entry, vec!["src/index.ts"]);
1184        // Unset fields from base still default
1185        assert_eq!(config.rules.unused_exports, Severity::Error);
1186    }
1187
1188    #[test]
1189    fn extends_overlay_overrides_base() {
1190        let dir = test_dir("extends-overlay");
1191
1192        std::fs::write(
1193            dir.path().join("base.json"),
1194            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1195        )
1196        .unwrap();
1197        std::fs::write(
1198            dir.path().join(".fallowrc.json"),
1199            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1200        )
1201        .unwrap();
1202
1203        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1204        // Overlay overrides base
1205        assert_eq!(config.rules.unused_files, Severity::Error);
1206        // Base value preserved when not overridden
1207        assert_eq!(config.rules.unused_exports, Severity::Off);
1208    }
1209
1210    #[test]
1211    fn extends_chained() {
1212        let dir = test_dir("extends-chained");
1213
1214        std::fs::write(
1215            dir.path().join("grandparent.json"),
1216            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1217        )
1218        .unwrap();
1219        std::fs::write(
1220            dir.path().join("parent.json"),
1221            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1222        )
1223        .unwrap();
1224        std::fs::write(
1225            dir.path().join(".fallowrc.json"),
1226            r#"{"extends": ["parent.json"]}"#,
1227        )
1228        .unwrap();
1229
1230        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1231        // grandparent: off -> parent: warn -> child: inherits warn
1232        assert_eq!(config.rules.unused_files, Severity::Warn);
1233        // grandparent: warn, not overridden
1234        assert_eq!(config.rules.unused_exports, Severity::Warn);
1235    }
1236
1237    #[test]
1238    fn extends_circular_detected() {
1239        let dir = test_dir("extends-circular");
1240
1241        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1242        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1243
1244        let result = FallowConfig::load(&dir.path().join("a.json"));
1245        assert!(result.is_err());
1246        let err_msg = format!("{}", result.unwrap_err());
1247        assert!(
1248            err_msg.contains("Circular extends"),
1249            "Expected circular error, got: {err_msg}"
1250        );
1251    }
1252
1253    #[test]
1254    fn extends_missing_file_errors() {
1255        let dir = test_dir("extends-missing");
1256
1257        std::fs::write(
1258            dir.path().join(".fallowrc.json"),
1259            r#"{"extends": ["nonexistent.json"]}"#,
1260        )
1261        .unwrap();
1262
1263        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1264        assert!(result.is_err());
1265        let err_msg = format!("{}", result.unwrap_err());
1266        assert!(
1267            err_msg.contains("not found"),
1268            "Expected not found error, got: {err_msg}"
1269        );
1270    }
1271
1272    // ── sealed: true tests ──────────────────────────────────────────
1273
1274    #[test]
1275    fn sealed_allows_in_directory_extends() {
1276        let dir = test_dir("sealed-allows-local");
1277        std::fs::write(
1278            dir.path().join("base.json"),
1279            r#"{"ignorePatterns": ["gen/**"]}"#,
1280        )
1281        .unwrap();
1282        std::fs::write(
1283            dir.path().join(".fallowrc.json"),
1284            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1285        )
1286        .unwrap();
1287
1288        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1289        assert!(config.sealed);
1290        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1291    }
1292
1293    #[test]
1294    fn sealed_rejects_extends_escaping_directory() {
1295        let dir = test_dir("sealed-rejects-escape");
1296        let sub = dir.path().join("packages").join("app");
1297        std::fs::create_dir_all(&sub).unwrap();
1298
1299        // Base config above the sealed config's directory
1300        std::fs::write(
1301            dir.path().join("base.json"),
1302            r#"{"ignorePatterns": ["dist/**"]}"#,
1303        )
1304        .unwrap();
1305        std::fs::write(
1306            sub.join(".fallowrc.json"),
1307            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1308        )
1309        .unwrap();
1310
1311        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1312        assert!(
1313            result.is_err(),
1314            "Expected sealed config to reject escaping extends"
1315        );
1316        let err_msg = format!("{}", result.unwrap_err());
1317        assert!(
1318            err_msg.contains("sealed"),
1319            "Error must mention sealed: {err_msg}"
1320        );
1321        assert!(
1322            err_msg.contains("outside the config's directory"),
1323            "Error must explain the constraint: {err_msg}"
1324        );
1325    }
1326
1327    #[test]
1328    fn sealed_rejects_https_extends() {
1329        let dir = test_dir("sealed-rejects-https");
1330        std::fs::write(
1331            dir.path().join(".fallowrc.json"),
1332            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1333        )
1334        .unwrap();
1335
1336        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1337        assert!(result.is_err());
1338        let err_msg = format!("{}", result.unwrap_err());
1339        assert!(
1340            err_msg.contains("sealed"),
1341            "Error must mention sealed: {err_msg}"
1342        );
1343        assert!(
1344            err_msg.contains("URL extends"),
1345            "Error must mention URL: {err_msg}"
1346        );
1347    }
1348
1349    #[test]
1350    fn sealed_rejects_npm_extends() {
1351        let dir = test_dir("sealed-rejects-npm");
1352        std::fs::write(
1353            dir.path().join(".fallowrc.json"),
1354            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1355        )
1356        .unwrap();
1357
1358        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1359        assert!(result.is_err());
1360        let err_msg = format!("{}", result.unwrap_err());
1361        assert!(
1362            err_msg.contains("sealed"),
1363            "Error must mention sealed: {err_msg}"
1364        );
1365        assert!(
1366            err_msg.contains("npm extends"),
1367            "Error must mention npm: {err_msg}"
1368        );
1369    }
1370
1371    #[test]
1372    fn sealed_default_is_false() {
1373        let dir = test_dir("sealed-default");
1374        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1375        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1376        assert!(!config.sealed);
1377    }
1378
1379    #[test]
1380    fn sealed_false_allows_escaping_extends() {
1381        // Without sealed (or sealed: false), escaping extends works fine
1382        let dir = test_dir("sealed-false-allows");
1383        let sub = dir.path().join("packages").join("app");
1384        std::fs::create_dir_all(&sub).unwrap();
1385
1386        std::fs::write(
1387            dir.path().join("base.json"),
1388            r#"{"ignorePatterns": ["dist/**"]}"#,
1389        )
1390        .unwrap();
1391        std::fs::write(
1392            sub.join(".fallowrc.json"),
1393            r#"{"extends": ["../../base.json"]}"#,
1394        )
1395        .unwrap();
1396
1397        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1398        assert!(!config.sealed);
1399        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1400    }
1401
1402    #[test]
1403    fn extends_string_sugar() {
1404        let dir = test_dir("extends-string");
1405
1406        std::fs::write(
1407            dir.path().join("base.json"),
1408            r#"{"ignorePatterns": ["gen/**"]}"#,
1409        )
1410        .unwrap();
1411        // String form instead of array
1412        std::fs::write(
1413            dir.path().join(".fallowrc.json"),
1414            r#"{"extends": "base.json"}"#,
1415        )
1416        .unwrap();
1417
1418        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1419        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1420    }
1421
1422    #[test]
1423    fn extends_deep_merge_preserves_arrays() {
1424        let dir = test_dir("extends-array");
1425
1426        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1427        std::fs::write(
1428            dir.path().join(".fallowrc.json"),
1429            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1430        )
1431        .unwrap();
1432
1433        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1434        // Arrays are replaced, not merged (overlay replaces base)
1435        assert_eq!(config.entry, vec!["src/b.ts"]);
1436    }
1437
1438    // ── npm extends tests ────────────────────────────────────────────
1439
1440    /// Set up a fake npm package in `node_modules/<name>` under `root`.
1441    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1442        let pkg_dir = root.join("node_modules").join(name);
1443        std::fs::create_dir_all(&pkg_dir).unwrap();
1444        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1445    }
1446
1447    /// Set up a fake npm package with `package.json` `main` field.
1448    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1449        let pkg_dir = root.join("node_modules").join(name);
1450        std::fs::create_dir_all(&pkg_dir).unwrap();
1451        std::fs::write(
1452            pkg_dir.join("package.json"),
1453            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1454        )
1455        .unwrap();
1456        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1457    }
1458
1459    #[test]
1460    fn extends_npm_basic_unscoped() {
1461        let dir = test_dir("npm-basic");
1462        create_npm_package(
1463            dir.path(),
1464            "fallow-config-acme",
1465            r#"{"rules": {"unused-files": "warn"}}"#,
1466        );
1467        std::fs::write(
1468            dir.path().join(".fallowrc.json"),
1469            r#"{"extends": "npm:fallow-config-acme"}"#,
1470        )
1471        .unwrap();
1472
1473        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1474        assert_eq!(config.rules.unused_files, Severity::Warn);
1475    }
1476
1477    #[test]
1478    fn extends_npm_scoped_package() {
1479        let dir = test_dir("npm-scoped");
1480        create_npm_package(
1481            dir.path(),
1482            "@company/fallow-config",
1483            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1484        );
1485        std::fs::write(
1486            dir.path().join(".fallowrc.json"),
1487            r#"{"extends": "npm:@company/fallow-config"}"#,
1488        )
1489        .unwrap();
1490
1491        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1492        assert_eq!(config.rules.unused_exports, Severity::Off);
1493        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1494    }
1495
1496    #[test]
1497    fn extends_npm_with_subpath() {
1498        let dir = test_dir("npm-subpath");
1499        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1500        std::fs::create_dir_all(&pkg_dir).unwrap();
1501        std::fs::write(
1502            pkg_dir.join("strict.json"),
1503            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1504        )
1505        .unwrap();
1506
1507        std::fs::write(
1508            dir.path().join(".fallowrc.json"),
1509            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1510        )
1511        .unwrap();
1512
1513        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1514        assert_eq!(config.rules.unused_files, Severity::Error);
1515        assert_eq!(config.rules.unused_exports, Severity::Error);
1516    }
1517
1518    #[test]
1519    fn extends_npm_package_json_main() {
1520        let dir = test_dir("npm-main");
1521        create_npm_package_with_main(
1522            dir.path(),
1523            "fallow-config-acme",
1524            "config.json",
1525            r#"{"rules": {"unused-types": "off"}}"#,
1526        );
1527        std::fs::write(
1528            dir.path().join(".fallowrc.json"),
1529            r#"{"extends": "npm:fallow-config-acme"}"#,
1530        )
1531        .unwrap();
1532
1533        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1534        assert_eq!(config.rules.unused_types, Severity::Off);
1535    }
1536
1537    #[test]
1538    fn extends_npm_package_json_exports_string() {
1539        let dir = test_dir("npm-exports-str");
1540        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1541        std::fs::create_dir_all(&pkg_dir).unwrap();
1542        std::fs::write(
1543            pkg_dir.join("package.json"),
1544            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1545        )
1546        .unwrap();
1547        std::fs::write(
1548            pkg_dir.join("base.json"),
1549            r#"{"rules": {"circular-dependencies": "warn"}}"#,
1550        )
1551        .unwrap();
1552
1553        std::fs::write(
1554            dir.path().join(".fallowrc.json"),
1555            r#"{"extends": "npm:fallow-config-co"}"#,
1556        )
1557        .unwrap();
1558
1559        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1560        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1561    }
1562
1563    #[test]
1564    fn extends_npm_package_json_exports_object() {
1565        let dir = test_dir("npm-exports-obj");
1566        let pkg_dir = dir.path().join("node_modules/@co/cfg");
1567        std::fs::create_dir_all(&pkg_dir).unwrap();
1568        std::fs::write(
1569            pkg_dir.join("package.json"),
1570            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1571        )
1572        .unwrap();
1573        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1574
1575        std::fs::write(
1576            dir.path().join(".fallowrc.json"),
1577            r#"{"extends": "npm:@co/cfg"}"#,
1578        )
1579        .unwrap();
1580
1581        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1582        assert_eq!(config.entry, vec!["src/app.ts"]);
1583    }
1584
1585    #[test]
1586    fn extends_npm_exports_takes_priority_over_main() {
1587        let dir = test_dir("npm-exports-prio");
1588        let pkg_dir = dir.path().join("node_modules/my-config");
1589        std::fs::create_dir_all(&pkg_dir).unwrap();
1590        std::fs::write(
1591            pkg_dir.join("package.json"),
1592            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1593        )
1594        .unwrap();
1595        std::fs::write(
1596            pkg_dir.join("old.json"),
1597            r#"{"rules": {"unused-files": "off"}}"#,
1598        )
1599        .unwrap();
1600        std::fs::write(
1601            pkg_dir.join("new.json"),
1602            r#"{"rules": {"unused-files": "warn"}}"#,
1603        )
1604        .unwrap();
1605
1606        std::fs::write(
1607            dir.path().join(".fallowrc.json"),
1608            r#"{"extends": "npm:my-config"}"#,
1609        )
1610        .unwrap();
1611
1612        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1613        // exports takes priority over main
1614        assert_eq!(config.rules.unused_files, Severity::Warn);
1615    }
1616
1617    #[test]
1618    fn extends_npm_walk_up_directories() {
1619        let dir = test_dir("npm-walkup");
1620        // node_modules at root level
1621        create_npm_package(
1622            dir.path(),
1623            "shared-config",
1624            r#"{"rules": {"unused-files": "warn"}}"#,
1625        );
1626        // Config in a nested subdirectory
1627        let sub = dir.path().join("packages/app");
1628        std::fs::create_dir_all(&sub).unwrap();
1629        std::fs::write(
1630            sub.join(".fallowrc.json"),
1631            r#"{"extends": "npm:shared-config"}"#,
1632        )
1633        .unwrap();
1634
1635        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1636        assert_eq!(config.rules.unused_files, Severity::Warn);
1637    }
1638
1639    #[test]
1640    fn extends_npm_overlay_overrides_base() {
1641        let dir = test_dir("npm-overlay");
1642        create_npm_package(
1643            dir.path(),
1644            "@company/base",
1645            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1646        );
1647        std::fs::write(
1648            dir.path().join(".fallowrc.json"),
1649            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1650        )
1651        .unwrap();
1652
1653        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1654        assert_eq!(config.rules.unused_files, Severity::Error);
1655        assert_eq!(config.rules.unused_exports, Severity::Off);
1656        assert_eq!(config.entry, vec!["src/app.ts"]);
1657    }
1658
1659    #[test]
1660    fn extends_npm_chained_with_relative() {
1661        let dir = test_dir("npm-chained");
1662        // npm package extends a relative file inside itself
1663        let pkg_dir = dir.path().join("node_modules/my-config");
1664        std::fs::create_dir_all(&pkg_dir).unwrap();
1665        std::fs::write(
1666            pkg_dir.join("base.json"),
1667            r#"{"rules": {"unused-files": "warn"}}"#,
1668        )
1669        .unwrap();
1670        std::fs::write(
1671            pkg_dir.join(".fallowrc.json"),
1672            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1673        )
1674        .unwrap();
1675
1676        std::fs::write(
1677            dir.path().join(".fallowrc.json"),
1678            r#"{"extends": "npm:my-config"}"#,
1679        )
1680        .unwrap();
1681
1682        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1683        assert_eq!(config.rules.unused_files, Severity::Warn);
1684        assert_eq!(config.rules.unused_exports, Severity::Off);
1685    }
1686
1687    #[test]
1688    fn extends_npm_mixed_with_relative_paths() {
1689        let dir = test_dir("npm-mixed");
1690        create_npm_package(
1691            dir.path(),
1692            "shared-base",
1693            r#"{"rules": {"unused-files": "off"}}"#,
1694        );
1695        std::fs::write(
1696            dir.path().join("local-overrides.json"),
1697            r#"{"rules": {"unused-files": "warn"}}"#,
1698        )
1699        .unwrap();
1700        std::fs::write(
1701            dir.path().join(".fallowrc.json"),
1702            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1703        )
1704        .unwrap();
1705
1706        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1707        // local-overrides is later in the array, so it wins
1708        assert_eq!(config.rules.unused_files, Severity::Warn);
1709    }
1710
1711    #[test]
1712    fn extends_npm_missing_package_errors() {
1713        let dir = test_dir("npm-missing");
1714        std::fs::write(
1715            dir.path().join(".fallowrc.json"),
1716            r#"{"extends": "npm:nonexistent-package"}"#,
1717        )
1718        .unwrap();
1719
1720        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1721        assert!(result.is_err());
1722        let err_msg = format!("{}", result.unwrap_err());
1723        assert!(
1724            err_msg.contains("not found"),
1725            "Expected 'not found' error, got: {err_msg}"
1726        );
1727        assert!(
1728            err_msg.contains("nonexistent-package"),
1729            "Expected package name in error, got: {err_msg}"
1730        );
1731        assert!(
1732            err_msg.contains("install it"),
1733            "Expected install hint in error, got: {err_msg}"
1734        );
1735    }
1736
1737    #[test]
1738    fn extends_npm_no_config_in_package_errors() {
1739        let dir = test_dir("npm-no-config");
1740        let pkg_dir = dir.path().join("node_modules/empty-pkg");
1741        std::fs::create_dir_all(&pkg_dir).unwrap();
1742        // Package exists but has no config files and no package.json
1743        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1744
1745        std::fs::write(
1746            dir.path().join(".fallowrc.json"),
1747            r#"{"extends": "npm:empty-pkg"}"#,
1748        )
1749        .unwrap();
1750
1751        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1752        assert!(result.is_err());
1753        let err_msg = format!("{}", result.unwrap_err());
1754        assert!(
1755            err_msg.contains("No fallow config found"),
1756            "Expected 'No fallow config found' error, got: {err_msg}"
1757        );
1758    }
1759
1760    #[test]
1761    fn extends_npm_missing_subpath_errors() {
1762        let dir = test_dir("npm-missing-sub");
1763        let pkg_dir = dir.path().join("node_modules/@co/config");
1764        std::fs::create_dir_all(&pkg_dir).unwrap();
1765
1766        std::fs::write(
1767            dir.path().join(".fallowrc.json"),
1768            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1769        )
1770        .unwrap();
1771
1772        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1773        assert!(result.is_err());
1774        let err_msg = format!("{}", result.unwrap_err());
1775        assert!(
1776            err_msg.contains("nonexistent.json"),
1777            "Expected subpath in error, got: {err_msg}"
1778        );
1779    }
1780
1781    #[test]
1782    fn extends_npm_empty_specifier_errors() {
1783        let dir = test_dir("npm-empty");
1784        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1785
1786        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1787        assert!(result.is_err());
1788        let err_msg = format!("{}", result.unwrap_err());
1789        assert!(
1790            err_msg.contains("Empty npm specifier"),
1791            "Expected 'Empty npm specifier' error, got: {err_msg}"
1792        );
1793    }
1794
1795    #[test]
1796    fn extends_npm_space_after_colon_trimmed() {
1797        let dir = test_dir("npm-space");
1798        create_npm_package(
1799            dir.path(),
1800            "fallow-config-acme",
1801            r#"{"rules": {"unused-files": "warn"}}"#,
1802        );
1803        // Space after npm: — should be trimmed and resolve correctly
1804        std::fs::write(
1805            dir.path().join(".fallowrc.json"),
1806            r#"{"extends": "npm: fallow-config-acme"}"#,
1807        )
1808        .unwrap();
1809
1810        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1811        assert_eq!(config.rules.unused_files, Severity::Warn);
1812    }
1813
1814    #[test]
1815    fn extends_npm_exports_node_condition() {
1816        let dir = test_dir("npm-node-cond");
1817        let pkg_dir = dir.path().join("node_modules/node-config");
1818        std::fs::create_dir_all(&pkg_dir).unwrap();
1819        std::fs::write(
1820            pkg_dir.join("package.json"),
1821            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1822        )
1823        .unwrap();
1824        std::fs::write(
1825            pkg_dir.join("node.json"),
1826            r#"{"rules": {"unused-files": "off"}}"#,
1827        )
1828        .unwrap();
1829
1830        std::fs::write(
1831            dir.path().join(".fallowrc.json"),
1832            r#"{"extends": "npm:node-config"}"#,
1833        )
1834        .unwrap();
1835
1836        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1837        assert_eq!(config.rules.unused_files, Severity::Off);
1838    }
1839
1840    // ── parse_npm_specifier unit tests ──────────────────────────────
1841
1842    #[test]
1843    fn parse_npm_specifier_unscoped() {
1844        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1845    }
1846
1847    #[test]
1848    fn parse_npm_specifier_unscoped_with_subpath() {
1849        assert_eq!(
1850            parse_npm_specifier("my-config/strict.json"),
1851            ("my-config", Some("strict.json"))
1852        );
1853    }
1854
1855    #[test]
1856    fn parse_npm_specifier_scoped() {
1857        assert_eq!(
1858            parse_npm_specifier("@company/fallow-config"),
1859            ("@company/fallow-config", None)
1860        );
1861    }
1862
1863    #[test]
1864    fn parse_npm_specifier_scoped_with_subpath() {
1865        assert_eq!(
1866            parse_npm_specifier("@company/fallow-config/strict.json"),
1867            ("@company/fallow-config", Some("strict.json"))
1868        );
1869    }
1870
1871    #[test]
1872    fn parse_npm_specifier_scoped_with_nested_subpath() {
1873        assert_eq!(
1874            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1875            ("@company/fallow-config", Some("presets/strict.json"))
1876        );
1877    }
1878
1879    // ── npm extends security tests ──────────────────────────────────
1880
1881    #[test]
1882    fn extends_npm_subpath_traversal_rejected() {
1883        let dir = test_dir("npm-traversal-sub");
1884        let pkg_dir = dir.path().join("node_modules/evil-pkg");
1885        std::fs::create_dir_all(&pkg_dir).unwrap();
1886        // Create a file outside the package that the traversal would reach
1887        std::fs::write(
1888            dir.path().join("secret.json"),
1889            r#"{"entry": ["stolen.ts"]}"#,
1890        )
1891        .unwrap();
1892
1893        std::fs::write(
1894            dir.path().join(".fallowrc.json"),
1895            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1896        )
1897        .unwrap();
1898
1899        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1900        assert!(result.is_err());
1901        let err_msg = format!("{}", result.unwrap_err());
1902        assert!(
1903            err_msg.contains("traversal") || err_msg.contains("not found"),
1904            "Expected traversal or not-found error, got: {err_msg}"
1905        );
1906    }
1907
1908    #[test]
1909    fn extends_npm_dotdot_package_name_rejected() {
1910        let dir = test_dir("npm-dotdot-name");
1911        std::fs::write(
1912            dir.path().join(".fallowrc.json"),
1913            r#"{"extends": "npm:../relative"}"#,
1914        )
1915        .unwrap();
1916
1917        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1918        assert!(result.is_err());
1919        let err_msg = format!("{}", result.unwrap_err());
1920        assert!(
1921            err_msg.contains("path traversal"),
1922            "Expected 'path traversal' error, got: {err_msg}"
1923        );
1924    }
1925
1926    #[test]
1927    fn extends_npm_scoped_without_name_rejected() {
1928        let dir = test_dir("npm-scope-only");
1929        std::fs::write(
1930            dir.path().join(".fallowrc.json"),
1931            r#"{"extends": "npm:@scope"}"#,
1932        )
1933        .unwrap();
1934
1935        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1936        assert!(result.is_err());
1937        let err_msg = format!("{}", result.unwrap_err());
1938        assert!(
1939            err_msg.contains("@scope/name"),
1940            "Expected scoped name format error, got: {err_msg}"
1941        );
1942    }
1943
1944    #[test]
1945    fn extends_npm_malformed_package_json_errors() {
1946        let dir = test_dir("npm-bad-pkgjson");
1947        let pkg_dir = dir.path().join("node_modules/bad-pkg");
1948        std::fs::create_dir_all(&pkg_dir).unwrap();
1949        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1950
1951        std::fs::write(
1952            dir.path().join(".fallowrc.json"),
1953            r#"{"extends": "npm:bad-pkg"}"#,
1954        )
1955        .unwrap();
1956
1957        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1958        assert!(result.is_err());
1959        let err_msg = format!("{}", result.unwrap_err());
1960        assert!(
1961            err_msg.contains("Failed to parse"),
1962            "Expected parse error, got: {err_msg}"
1963        );
1964    }
1965
1966    #[test]
1967    fn extends_npm_exports_traversal_rejected() {
1968        let dir = test_dir("npm-exports-escape");
1969        let pkg_dir = dir.path().join("node_modules/evil-exports");
1970        std::fs::create_dir_all(&pkg_dir).unwrap();
1971        std::fs::write(
1972            pkg_dir.join("package.json"),
1973            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1974        )
1975        .unwrap();
1976        // Create the target file outside the package
1977        std::fs::write(
1978            dir.path().join("secret.json"),
1979            r#"{"entry": ["stolen.ts"]}"#,
1980        )
1981        .unwrap();
1982
1983        std::fs::write(
1984            dir.path().join(".fallowrc.json"),
1985            r#"{"extends": "npm:evil-exports"}"#,
1986        )
1987        .unwrap();
1988
1989        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1990        assert!(result.is_err());
1991        let err_msg = format!("{}", result.unwrap_err());
1992        assert!(
1993            err_msg.contains("traversal"),
1994            "Expected traversal error, got: {err_msg}"
1995        );
1996    }
1997
1998    // ── deep_merge_json unit tests ───────────────────────────────────
1999
2000    #[test]
2001    fn deep_merge_scalar_overlay_replaces_base() {
2002        let mut base = serde_json::json!("hello");
2003        deep_merge_json(&mut base, serde_json::json!("world"));
2004        assert_eq!(base, serde_json::json!("world"));
2005    }
2006
2007    #[test]
2008    fn deep_merge_array_overlay_replaces_base() {
2009        let mut base = serde_json::json!(["a", "b"]);
2010        deep_merge_json(&mut base, serde_json::json!(["c"]));
2011        assert_eq!(base, serde_json::json!(["c"]));
2012    }
2013
2014    #[test]
2015    fn deep_merge_nested_object_merge() {
2016        let mut base = serde_json::json!({
2017            "level1": {
2018                "level2": {
2019                    "a": 1,
2020                    "b": 2
2021                }
2022            }
2023        });
2024        let overlay = serde_json::json!({
2025            "level1": {
2026                "level2": {
2027                    "b": 99,
2028                    "c": 3
2029                }
2030            }
2031        });
2032        deep_merge_json(&mut base, overlay);
2033        assert_eq!(base["level1"]["level2"]["a"], 1);
2034        assert_eq!(base["level1"]["level2"]["b"], 99);
2035        assert_eq!(base["level1"]["level2"]["c"], 3);
2036    }
2037
2038    #[test]
2039    fn deep_merge_overlay_adds_new_fields() {
2040        let mut base = serde_json::json!({"existing": true});
2041        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2042        deep_merge_json(&mut base, overlay);
2043        assert_eq!(base["existing"], true);
2044        assert_eq!(base["new_field"], "added");
2045        assert_eq!(base["another"], 42);
2046    }
2047
2048    #[test]
2049    fn deep_merge_null_overlay_replaces_object() {
2050        let mut base = serde_json::json!({"key": "value"});
2051        deep_merge_json(&mut base, serde_json::json!(null));
2052        assert_eq!(base, serde_json::json!(null));
2053    }
2054
2055    #[test]
2056    fn deep_merge_empty_object_overlay_preserves_base() {
2057        let mut base = serde_json::json!({"a": 1, "b": 2});
2058        deep_merge_json(&mut base, serde_json::json!({}));
2059        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2060    }
2061
2062    // ── rule severity parsing via JSON config ────────────────────────
2063
2064    #[test]
2065    fn rules_severity_error_warn_off_from_json() {
2066        let json_str = r#"{
2067            "rules": {
2068                "unused-files": "error",
2069                "unused-exports": "warn",
2070                "unused-types": "off"
2071            }
2072        }"#;
2073        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2074        assert_eq!(config.rules.unused_files, Severity::Error);
2075        assert_eq!(config.rules.unused_exports, Severity::Warn);
2076        assert_eq!(config.rules.unused_types, Severity::Off);
2077    }
2078
2079    #[test]
2080    fn rules_omitted_default_to_error() {
2081        let json_str = r#"{
2082            "rules": {
2083                "unused-files": "warn"
2084            }
2085        }"#;
2086        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2087        assert_eq!(config.rules.unused_files, Severity::Warn);
2088        // All other rules default to error
2089        assert_eq!(config.rules.unused_exports, Severity::Error);
2090        assert_eq!(config.rules.unused_types, Severity::Error);
2091        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2092        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2093        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2094        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2095        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2096        // type_only_dependencies defaults to warn, not error
2097        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2098    }
2099
2100    // ── find_and_load tests ───────────────────────────────────────
2101
2102    #[test]
2103    fn find_and_load_returns_none_when_no_config() {
2104        let dir = test_dir("find-none");
2105        // Create a .git dir so it stops searching
2106        std::fs::create_dir(dir.path().join(".git")).unwrap();
2107
2108        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2109        assert!(result.is_none());
2110    }
2111
2112    #[test]
2113    fn find_and_load_finds_fallowrc_json() {
2114        let dir = test_dir("find-json");
2115        std::fs::create_dir(dir.path().join(".git")).unwrap();
2116        std::fs::write(
2117            dir.path().join(".fallowrc.json"),
2118            r#"{"entry": ["src/main.ts"]}"#,
2119        )
2120        .unwrap();
2121
2122        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2123        assert_eq!(config.entry, vec!["src/main.ts"]);
2124        assert!(path.ends_with(".fallowrc.json"));
2125    }
2126
2127    #[test]
2128    fn find_and_load_finds_fallowrc_jsonc() {
2129        let dir = test_dir("find-jsonc");
2130        std::fs::create_dir(dir.path().join(".git")).unwrap();
2131        std::fs::write(
2132            dir.path().join(".fallowrc.jsonc"),
2133            r#"{
2134                // jsonc with comments, picked up by auto-discovery
2135                "entry": ["src/main.ts"]
2136            }"#,
2137        )
2138        .unwrap();
2139
2140        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2141        assert_eq!(config.entry, vec!["src/main.ts"]);
2142        assert!(path.ends_with(".fallowrc.jsonc"));
2143    }
2144
2145    #[test]
2146    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2147        // First-match-wins: `.fallowrc.json` ranks above `.fallowrc.jsonc`
2148        // in `CONFIG_NAMES`, mirroring tsconfig.json > tsconfig.jsonc precedence.
2149        let dir = test_dir("find-json-vs-jsonc");
2150        std::fs::create_dir(dir.path().join(".git")).unwrap();
2151        std::fs::write(
2152            dir.path().join(".fallowrc.json"),
2153            r#"{"entry": ["from-json.ts"]}"#,
2154        )
2155        .unwrap();
2156        std::fs::write(
2157            dir.path().join(".fallowrc.jsonc"),
2158            r#"{"entry": ["from-jsonc.ts"]}"#,
2159        )
2160        .unwrap();
2161
2162        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2163        assert_eq!(config.entry, vec!["from-json.ts"]);
2164        assert!(path.ends_with(".fallowrc.json"));
2165    }
2166
2167    #[test]
2168    fn find_and_load_prefers_fallowrc_json_over_toml() {
2169        let dir = test_dir("find-priority");
2170        std::fs::create_dir(dir.path().join(".git")).unwrap();
2171        std::fs::write(
2172            dir.path().join(".fallowrc.json"),
2173            r#"{"entry": ["from-json.ts"]}"#,
2174        )
2175        .unwrap();
2176        std::fs::write(
2177            dir.path().join("fallow.toml"),
2178            "entry = [\"from-toml.ts\"]\n",
2179        )
2180        .unwrap();
2181
2182        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2183        assert_eq!(config.entry, vec!["from-json.ts"]);
2184        assert!(path.ends_with(".fallowrc.json"));
2185    }
2186
2187    #[test]
2188    fn find_and_load_finds_fallow_toml() {
2189        let dir = test_dir("find-toml");
2190        std::fs::create_dir(dir.path().join(".git")).unwrap();
2191        std::fs::write(
2192            dir.path().join("fallow.toml"),
2193            "entry = [\"src/index.ts\"]\n",
2194        )
2195        .unwrap();
2196
2197        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2198        assert_eq!(config.entry, vec!["src/index.ts"]);
2199    }
2200
2201    #[test]
2202    fn find_and_load_stops_at_git_dir() {
2203        let dir = test_dir("find-git-stop");
2204        let sub = dir.path().join("sub");
2205        std::fs::create_dir(&sub).unwrap();
2206        // .git marker in root stops search
2207        std::fs::create_dir(dir.path().join(".git")).unwrap();
2208        // Config file above .git should not be found from sub
2209        // (sub has no .git or package.json, so it keeps searching up to parent)
2210        // But parent has .git, so it stops there without finding config
2211        let result = FallowConfig::find_and_load(&sub).unwrap();
2212        assert!(result.is_none());
2213    }
2214
2215    #[test]
2216    fn find_and_load_walks_past_package_json_in_monorepo() {
2217        // Simulate a pnpm/npm/yarn workspace: root has `.git` + `.fallowrc.json`,
2218        // sub-package has its own `package.json`. Config search from the
2219        // sub-package must walk past its `package.json` and find the root config.
2220        let dir = test_dir("find-monorepo");
2221        std::fs::create_dir(dir.path().join(".git")).unwrap();
2222        std::fs::write(
2223            dir.path().join(".fallowrc.json"),
2224            r#"{"entry": ["src/index.ts"]}"#,
2225        )
2226        .unwrap();
2227
2228        let sub = dir.path().join("packages").join("app");
2229        std::fs::create_dir_all(&sub).unwrap();
2230        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2231
2232        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2233        assert_eq!(config.entry, vec!["src/index.ts"]);
2234        assert_eq!(path, dir.path().join(".fallowrc.json"));
2235    }
2236
2237    #[test]
2238    fn find_and_load_sub_package_config_wins_over_root() {
2239        // Regression guard: if a monorepo sub-package has its own config,
2240        // it must be preferred over the root config (first-match-wins).
2241        let dir = test_dir("find-monorepo-override");
2242        std::fs::create_dir(dir.path().join(".git")).unwrap();
2243        std::fs::write(
2244            dir.path().join(".fallowrc.json"),
2245            r#"{"entry": ["src/root.ts"]}"#,
2246        )
2247        .unwrap();
2248
2249        let sub = dir.path().join("packages").join("app");
2250        std::fs::create_dir_all(&sub).unwrap();
2251        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2252        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2253
2254        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2255        assert_eq!(config.entry, vec!["src/sub.ts"]);
2256        assert_eq!(path, sub.join(".fallowrc.json"));
2257    }
2258
2259    #[test]
2260    fn find_and_load_stops_at_git_file_submodule() {
2261        // Git submodules / worktrees have `.git` as a file (not a directory)
2262        // pointing to the real gitdir. `.exists()` matches both, so submodule
2263        // roots correctly stop the walk — config in the parent repo should
2264        // NOT leak into a vendored submodule.
2265        let dir = test_dir("find-git-file");
2266        std::fs::create_dir(dir.path().join(".git")).unwrap();
2267        std::fs::write(
2268            dir.path().join(".fallowrc.json"),
2269            r#"{"entry": ["src/parent.ts"]}"#,
2270        )
2271        .unwrap();
2272
2273        let submodule = dir.path().join("vendor").join("lib");
2274        std::fs::create_dir_all(&submodule).unwrap();
2275        // Simulate submodule: `.git` as a file pointing to parent's .git/modules
2276        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2277
2278        let result = FallowConfig::find_and_load(&submodule).unwrap();
2279        assert!(
2280            result.is_none(),
2281            "submodule boundary should stop config walk",
2282        );
2283    }
2284
2285    #[test]
2286    fn find_and_load_stops_at_hg_dir() {
2287        let dir = test_dir("find-hg-stop");
2288        let sub = dir.path().join("sub");
2289        std::fs::create_dir(&sub).unwrap();
2290        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2291
2292        let result = FallowConfig::find_and_load(&sub).unwrap();
2293        assert!(result.is_none());
2294    }
2295
2296    #[test]
2297    fn find_and_load_returns_error_for_invalid_config() {
2298        let dir = test_dir("find-invalid");
2299        std::fs::create_dir(dir.path().join(".git")).unwrap();
2300        std::fs::write(
2301            dir.path().join(".fallowrc.json"),
2302            r"{ this is not valid json }",
2303        )
2304        .unwrap();
2305
2306        let result = FallowConfig::find_and_load(dir.path());
2307        assert!(result.is_err());
2308    }
2309
2310    // ── load TOML config file ────────────────────────────────────
2311
2312    #[test]
2313    fn load_toml_config_file() {
2314        let dir = test_dir("toml-config");
2315        let config_path = dir.path().join("fallow.toml");
2316        std::fs::write(
2317            &config_path,
2318            r#"
2319entry = ["src/index.ts"]
2320ignorePatterns = ["dist/**"]
2321
2322[rules]
2323unused-files = "warn"
2324
2325[duplicates]
2326minTokens = 100
2327"#,
2328        )
2329        .unwrap();
2330
2331        let config = FallowConfig::load(&config_path).unwrap();
2332        assert_eq!(config.entry, vec!["src/index.ts"]);
2333        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2334        assert_eq!(config.rules.unused_files, Severity::Warn);
2335        assert_eq!(config.duplicates.min_tokens, 100);
2336    }
2337
2338    // ── extends absolute path rejection ──────────────────────────
2339
2340    #[test]
2341    fn extends_absolute_path_rejected() {
2342        let dir = test_dir("extends-absolute");
2343
2344        // Use a platform-appropriate absolute path
2345        #[cfg(unix)]
2346        let abs_path = "/absolute/path/config.json";
2347        #[cfg(windows)]
2348        let abs_path = "C:\\absolute\\path\\config.json";
2349
2350        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2351        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2352
2353        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2354        assert!(result.is_err());
2355        let err_msg = format!("{}", result.unwrap_err());
2356        assert!(
2357            err_msg.contains("must be relative"),
2358            "Expected 'must be relative' error, got: {err_msg}"
2359        );
2360    }
2361
2362    // ── resolve production mode ─────────────────────────────────
2363
2364    #[test]
2365    fn resolve_production_mode_disables_dev_deps() {
2366        let config = FallowConfig {
2367            production: true.into(),
2368            ..Default::default()
2369        };
2370        let resolved = config.resolve(
2371            PathBuf::from("/tmp/test"),
2372            OutputFormat::Human,
2373            4,
2374            false,
2375            true,
2376        );
2377        assert!(resolved.production);
2378        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2379        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2380        // Other rules should remain at default (Error)
2381        assert_eq!(resolved.rules.unused_files, Severity::Error);
2382        assert_eq!(resolved.rules.unused_exports, Severity::Error);
2383    }
2384
2385    // ── config format fallback to TOML for unknown extensions ───
2386
2387    #[test]
2388    fn config_format_defaults_to_toml_for_unknown() {
2389        assert!(matches!(
2390            ConfigFormat::from_path(Path::new("config.yaml")),
2391            ConfigFormat::Toml
2392        ));
2393        assert!(matches!(
2394            ConfigFormat::from_path(Path::new("config")),
2395            ConfigFormat::Toml
2396        ));
2397    }
2398
2399    // ── deep_merge type coercion ─────────────────────────────────
2400
2401    #[test]
2402    fn deep_merge_object_over_scalar_replaces() {
2403        let mut base = serde_json::json!("just a string");
2404        let overlay = serde_json::json!({"key": "value"});
2405        deep_merge_json(&mut base, overlay);
2406        assert_eq!(base, serde_json::json!({"key": "value"}));
2407    }
2408
2409    #[test]
2410    fn deep_merge_scalar_over_object_replaces() {
2411        let mut base = serde_json::json!({"key": "value"});
2412        let overlay = serde_json::json!(42);
2413        deep_merge_json(&mut base, overlay);
2414        assert_eq!(base, serde_json::json!(42));
2415    }
2416
2417    // ── extends with non-string/array extends field ──────────────
2418
2419    #[test]
2420    fn extends_non_string_non_array_ignored() {
2421        let dir = test_dir("extends-numeric");
2422        std::fs::write(
2423            dir.path().join(".fallowrc.json"),
2424            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2425        )
2426        .unwrap();
2427
2428        // extends=42 is neither string nor array, so it's treated as no extends
2429        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2430        assert_eq!(config.entry, vec!["src/index.ts"]);
2431    }
2432
2433    // ── extends with multiple bases (later overrides earlier) ────
2434
2435    #[test]
2436    fn extends_multiple_bases_later_wins() {
2437        let dir = test_dir("extends-multi-base");
2438
2439        std::fs::write(
2440            dir.path().join("base-a.json"),
2441            r#"{"rules": {"unused-files": "warn"}}"#,
2442        )
2443        .unwrap();
2444        std::fs::write(
2445            dir.path().join("base-b.json"),
2446            r#"{"rules": {"unused-files": "off"}}"#,
2447        )
2448        .unwrap();
2449        std::fs::write(
2450            dir.path().join(".fallowrc.json"),
2451            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2452        )
2453        .unwrap();
2454
2455        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2456        // base-b is later in the array, so its value should win
2457        assert_eq!(config.rules.unused_files, Severity::Off);
2458    }
2459
2460    // ── config with production flag ──────────────────────────────
2461
2462    #[test]
2463    fn fallow_config_deserialize_production() {
2464        let json_str = r#"{"production": true}"#;
2465        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2466        assert!(config.production);
2467    }
2468
2469    #[test]
2470    fn fallow_config_production_defaults_false() {
2471        let config: FallowConfig = serde_json::from_str("{}").unwrap();
2472        assert!(!config.production);
2473    }
2474
2475    // ── optional dependency names ────────────────────────────────
2476
2477    #[test]
2478    fn package_json_optional_dependency_names() {
2479        let pkg: PackageJson = serde_json::from_str(
2480            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2481        )
2482        .unwrap();
2483        let opt = pkg.optional_dependency_names();
2484        assert_eq!(opt.len(), 2);
2485        assert!(opt.contains(&"fsevents".to_string()));
2486        assert!(opt.contains(&"chokidar".to_string()));
2487    }
2488
2489    #[test]
2490    fn package_json_optional_deps_empty_when_missing() {
2491        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2492        assert!(pkg.optional_dependency_names().is_empty());
2493    }
2494
2495    // ── find_config_path ────────────────────────────────────────────
2496
2497    #[test]
2498    fn find_config_path_returns_fallowrc_json() {
2499        let dir = test_dir("find-path-json");
2500        std::fs::create_dir(dir.path().join(".git")).unwrap();
2501        std::fs::write(
2502            dir.path().join(".fallowrc.json"),
2503            r#"{"entry": ["src/main.ts"]}"#,
2504        )
2505        .unwrap();
2506
2507        let path = FallowConfig::find_config_path(dir.path());
2508        assert!(path.is_some());
2509        assert!(path.unwrap().ends_with(".fallowrc.json"));
2510    }
2511
2512    #[test]
2513    fn find_config_path_returns_fallow_toml() {
2514        let dir = test_dir("find-path-toml");
2515        std::fs::create_dir(dir.path().join(".git")).unwrap();
2516        std::fs::write(
2517            dir.path().join("fallow.toml"),
2518            "entry = [\"src/main.ts\"]\n",
2519        )
2520        .unwrap();
2521
2522        let path = FallowConfig::find_config_path(dir.path());
2523        assert!(path.is_some());
2524        assert!(path.unwrap().ends_with("fallow.toml"));
2525    }
2526
2527    #[test]
2528    fn find_config_path_returns_dot_fallow_toml() {
2529        let dir = test_dir("find-path-dot-toml");
2530        std::fs::create_dir(dir.path().join(".git")).unwrap();
2531        std::fs::write(
2532            dir.path().join(".fallow.toml"),
2533            "entry = [\"src/main.ts\"]\n",
2534        )
2535        .unwrap();
2536
2537        let path = FallowConfig::find_config_path(dir.path());
2538        assert!(path.is_some());
2539        assert!(path.unwrap().ends_with(".fallow.toml"));
2540    }
2541
2542    #[test]
2543    fn find_config_path_prefers_json_over_toml() {
2544        let dir = test_dir("find-path-priority");
2545        std::fs::create_dir(dir.path().join(".git")).unwrap();
2546        std::fs::write(
2547            dir.path().join(".fallowrc.json"),
2548            r#"{"entry": ["json.ts"]}"#,
2549        )
2550        .unwrap();
2551        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2552
2553        let path = FallowConfig::find_config_path(dir.path());
2554        assert!(path.unwrap().ends_with(".fallowrc.json"));
2555    }
2556
2557    #[test]
2558    fn find_config_path_none_when_no_config() {
2559        let dir = test_dir("find-path-none");
2560        std::fs::create_dir(dir.path().join(".git")).unwrap();
2561
2562        let path = FallowConfig::find_config_path(dir.path());
2563        assert!(path.is_none());
2564    }
2565
2566    #[test]
2567    fn find_config_path_walks_past_package_json_in_monorepo() {
2568        let dir = test_dir("find-path-monorepo");
2569        std::fs::create_dir(dir.path().join(".git")).unwrap();
2570        std::fs::write(
2571            dir.path().join(".fallowrc.json"),
2572            r#"{"entry": ["src/index.ts"]}"#,
2573        )
2574        .unwrap();
2575
2576        let sub = dir.path().join("packages").join("app");
2577        std::fs::create_dir_all(&sub).unwrap();
2578        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2579
2580        let path = FallowConfig::find_config_path(&sub).unwrap();
2581        assert_eq!(path, dir.path().join(".fallowrc.json"));
2582    }
2583
2584    // ── TOML extends support ────────────────────────────────────────
2585
2586    #[test]
2587    fn extends_toml_base() {
2588        let dir = test_dir("extends-toml");
2589
2590        std::fs::write(
2591            dir.path().join("base.json"),
2592            r#"{"rules": {"unused-files": "warn"}}"#,
2593        )
2594        .unwrap();
2595        std::fs::write(
2596            dir.path().join("fallow.toml"),
2597            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2598        )
2599        .unwrap();
2600
2601        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2602        assert_eq!(config.rules.unused_files, Severity::Warn);
2603        assert_eq!(config.entry, vec!["src/index.ts"]);
2604    }
2605
2606    // ── deep_merge_json edge cases ──────────────────────────────────
2607
2608    #[test]
2609    fn deep_merge_boolean_overlay() {
2610        let mut base = serde_json::json!(true);
2611        deep_merge_json(&mut base, serde_json::json!(false));
2612        assert_eq!(base, serde_json::json!(false));
2613    }
2614
2615    #[test]
2616    fn deep_merge_number_overlay() {
2617        let mut base = serde_json::json!(42);
2618        deep_merge_json(&mut base, serde_json::json!(99));
2619        assert_eq!(base, serde_json::json!(99));
2620    }
2621
2622    #[test]
2623    fn deep_merge_disjoint_objects() {
2624        let mut base = serde_json::json!({"a": 1});
2625        let overlay = serde_json::json!({"b": 2});
2626        deep_merge_json(&mut base, overlay);
2627        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2628    }
2629
2630    // ── MAX_EXTENDS_DEPTH constant ──────────────────────────────────
2631
2632    #[test]
2633    fn max_extends_depth_is_reasonable() {
2634        assert_eq!(MAX_EXTENDS_DEPTH, 10);
2635    }
2636
2637    // ── Config names constant ───────────────────────────────────────
2638
2639    #[test]
2640    fn config_names_has_four_entries() {
2641        assert_eq!(CONFIG_NAMES.len(), 4);
2642        // All names should start with "." or "fallow"
2643        for name in CONFIG_NAMES {
2644            assert!(
2645                name.starts_with('.') || name.starts_with("fallow"),
2646                "unexpected config name: {name}"
2647            );
2648        }
2649    }
2650
2651    // ── package.json peer dependency names ───────────────────────────
2652
2653    #[test]
2654    fn package_json_peer_dependency_names() {
2655        let pkg: PackageJson = serde_json::from_str(
2656            r#"{
2657            "dependencies": {"react": "^18"},
2658            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2659        }"#,
2660        )
2661        .unwrap();
2662        let all = pkg.all_dependency_names();
2663        assert!(all.contains(&"react".to_string()));
2664        assert!(all.contains(&"react-dom".to_string()));
2665        assert!(all.contains(&"react-native".to_string()));
2666    }
2667
2668    // ── package.json scripts field ──────────────────────────────────
2669
2670    #[test]
2671    fn package_json_scripts_field() {
2672        let pkg: PackageJson = serde_json::from_str(
2673            r#"{
2674            "scripts": {
2675                "build": "tsc",
2676                "test": "vitest",
2677                "lint": "fallow check"
2678            }
2679        }"#,
2680        )
2681        .unwrap();
2682        let scripts = pkg.scripts.unwrap();
2683        assert_eq!(scripts.len(), 3);
2684        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2685        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2686    }
2687
2688    // ── Extends with TOML-to-TOML chain ─────────────────────────────
2689
2690    #[test]
2691    fn extends_toml_chain() {
2692        let dir = test_dir("extends-toml-chain");
2693
2694        std::fs::write(
2695            dir.path().join("base.json"),
2696            r#"{"entry": ["src/base.ts"]}"#,
2697        )
2698        .unwrap();
2699        std::fs::write(
2700            dir.path().join("middle.json"),
2701            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2702        )
2703        .unwrap();
2704        std::fs::write(
2705            dir.path().join("fallow.toml"),
2706            "extends = [\"middle.json\"]\n",
2707        )
2708        .unwrap();
2709
2710        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2711        assert_eq!(config.entry, vec!["src/base.ts"]);
2712        assert_eq!(config.rules.unused_files, Severity::Off);
2713    }
2714
2715    // ── find_and_load walks up to parent ────────────────────────────
2716
2717    #[test]
2718    fn find_and_load_walks_up_directories() {
2719        let dir = test_dir("find-walk-up");
2720        let sub = dir.path().join("src").join("deep");
2721        std::fs::create_dir_all(&sub).unwrap();
2722        std::fs::write(
2723            dir.path().join(".fallowrc.json"),
2724            r#"{"entry": ["src/main.ts"]}"#,
2725        )
2726        .unwrap();
2727        // Create .git in root to stop search there
2728        std::fs::create_dir(dir.path().join(".git")).unwrap();
2729
2730        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2731        assert_eq!(config.entry, vec!["src/main.ts"]);
2732        assert!(path.ends_with(".fallowrc.json"));
2733    }
2734
2735    // ── JSON schema generation ──────────────────────────────────────
2736
2737    #[test]
2738    fn json_schema_contains_entry_field() {
2739        let schema = FallowConfig::json_schema();
2740        let obj = schema.as_object().unwrap();
2741        let props = obj.get("properties").and_then(|v| v.as_object());
2742        assert!(props.is_some(), "schema should have properties");
2743        assert!(
2744            props.unwrap().contains_key("entry"),
2745            "schema should contain entry property"
2746        );
2747    }
2748
2749    // ── Duplicates config via JSON in FallowConfig ──────────────────
2750
2751    #[test]
2752    fn fallow_config_json_duplicates_all_fields() {
2753        let json = r#"{
2754            "duplicates": {
2755                "enabled": true,
2756                "mode": "semantic",
2757                "minTokens": 200,
2758                "minLines": 20,
2759                "threshold": 10.5,
2760                "ignore": ["**/*.test.ts"],
2761                "skipLocal": true,
2762                "crossLanguage": true,
2763                "normalization": {
2764                    "ignoreIdentifiers": true,
2765                    "ignoreStringValues": false
2766                }
2767            }
2768        }"#;
2769        let config: FallowConfig = serde_json::from_str(json).unwrap();
2770        assert!(config.duplicates.enabled);
2771        assert_eq!(
2772            config.duplicates.mode,
2773            crate::config::DetectionMode::Semantic
2774        );
2775        assert_eq!(config.duplicates.min_tokens, 200);
2776        assert_eq!(config.duplicates.min_lines, 20);
2777        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2778        assert!(config.duplicates.skip_local);
2779        assert!(config.duplicates.cross_language);
2780        assert_eq!(
2781            config.duplicates.normalization.ignore_identifiers,
2782            Some(true)
2783        );
2784        assert_eq!(
2785            config.duplicates.normalization.ignore_string_values,
2786            Some(false)
2787        );
2788    }
2789
2790    // ── URL extends tests ───────────────────────────────────────────
2791
2792    #[test]
2793    fn normalize_url_basic() {
2794        assert_eq!(
2795            normalize_url_for_dedup("https://example.com/config.json"),
2796            "https://example.com/config.json"
2797        );
2798    }
2799
2800    #[test]
2801    fn normalize_url_trailing_slash() {
2802        assert_eq!(
2803            normalize_url_for_dedup("https://example.com/config/"),
2804            "https://example.com/config"
2805        );
2806    }
2807
2808    #[test]
2809    fn normalize_url_uppercase_scheme_and_host() {
2810        assert_eq!(
2811            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2812            "https://example.com/Config.json"
2813        );
2814    }
2815
2816    #[test]
2817    fn normalize_url_root_path() {
2818        assert_eq!(
2819            normalize_url_for_dedup("https://example.com/"),
2820            "https://example.com"
2821        );
2822        assert_eq!(
2823            normalize_url_for_dedup("https://example.com"),
2824            "https://example.com"
2825        );
2826    }
2827
2828    #[test]
2829    fn normalize_url_preserves_path_case() {
2830        // Path component casing is significant (server-dependent), only scheme+host lowercase.
2831        assert_eq!(
2832            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2833            "https://github.com/Org/Repo/Fallow.json"
2834        );
2835    }
2836
2837    #[test]
2838    fn normalize_url_strips_query_string() {
2839        assert_eq!(
2840            normalize_url_for_dedup("https://example.com/config.json?v=1"),
2841            "https://example.com/config.json"
2842        );
2843    }
2844
2845    #[test]
2846    fn normalize_url_strips_fragment() {
2847        assert_eq!(
2848            normalize_url_for_dedup("https://example.com/config.json#section"),
2849            "https://example.com/config.json"
2850        );
2851    }
2852
2853    #[test]
2854    fn normalize_url_strips_query_and_fragment() {
2855        assert_eq!(
2856            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2857            "https://example.com/config.json"
2858        );
2859    }
2860
2861    #[test]
2862    fn normalize_url_default_https_port() {
2863        assert_eq!(
2864            normalize_url_for_dedup("https://example.com:443/config.json"),
2865            "https://example.com/config.json"
2866        );
2867        // Non-default port is preserved.
2868        assert_eq!(
2869            normalize_url_for_dedup("https://example.com:8443/config.json"),
2870            "https://example.com:8443/config.json"
2871        );
2872    }
2873
2874    #[test]
2875    fn extends_http_rejected() {
2876        let dir = test_dir("http-rejected");
2877        std::fs::write(
2878            dir.path().join(".fallowrc.json"),
2879            r#"{"extends": "http://example.com/config.json"}"#,
2880        )
2881        .unwrap();
2882
2883        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2884        assert!(result.is_err());
2885        let err_msg = format!("{}", result.unwrap_err());
2886        assert!(
2887            err_msg.contains("https://"),
2888            "Expected https hint in error, got: {err_msg}"
2889        );
2890        assert!(
2891            err_msg.contains("http://"),
2892            "Expected http:// mention in error, got: {err_msg}"
2893        );
2894    }
2895
2896    #[test]
2897    fn extends_url_circular_detection() {
2898        // Verify that the same URL appearing twice in the visited set is detected.
2899        let mut visited = FxHashSet::default();
2900        let url = "https://example.com/config.json";
2901        let normalized = normalize_url_for_dedup(url);
2902        visited.insert(normalized.clone());
2903
2904        // Inserting the same normalized URL should return false.
2905        assert!(
2906            !visited.insert(normalized),
2907            "Same URL should be detected as duplicate"
2908        );
2909    }
2910
2911    #[test]
2912    fn extends_url_circular_case_insensitive() {
2913        // URLs differing only in scheme/host casing should be detected as circular.
2914        let mut visited = FxHashSet::default();
2915        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2916
2917        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2918        assert!(
2919            !visited.insert(normalized),
2920            "Case-different URLs should normalize to the same key"
2921        );
2922    }
2923
2924    #[test]
2925    fn extract_extends_array() {
2926        let mut value = serde_json::json!({
2927            "extends": ["a.json", "b.json"],
2928            "entry": ["src/index.ts"]
2929        });
2930        let extends = extract_extends(&mut value);
2931        assert_eq!(extends, vec!["a.json", "b.json"]);
2932        // extends should be removed from the value.
2933        assert!(value.get("extends").is_none());
2934        assert!(value.get("entry").is_some());
2935    }
2936
2937    #[test]
2938    fn extract_extends_string_sugar() {
2939        let mut value = serde_json::json!({
2940            "extends": "base.json",
2941            "entry": ["src/index.ts"]
2942        });
2943        let extends = extract_extends(&mut value);
2944        assert_eq!(extends, vec!["base.json"]);
2945    }
2946
2947    #[test]
2948    fn extract_extends_none() {
2949        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2950        let extends = extract_extends(&mut value);
2951        assert!(extends.is_empty());
2952    }
2953
2954    #[test]
2955    fn url_timeout_default() {
2956        // Without the env var set, should return the default.
2957        let timeout = url_timeout();
2958        // We can't assert exact value since the env var might be set in the test environment,
2959        // but we can assert it's a reasonable duration.
2960        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2961    }
2962
2963    #[test]
2964    fn extends_url_mixed_with_file_and_npm() {
2965        // Test that a config with a mix of file, npm, and URL extends parses correctly
2966        // for the non-URL parts, and produces a clear error for the URL part (no server).
2967        let dir = test_dir("url-mixed");
2968        std::fs::write(
2969            dir.path().join("local.json"),
2970            r#"{"rules": {"unused-files": "warn"}}"#,
2971        )
2972        .unwrap();
2973        std::fs::write(
2974            dir.path().join(".fallowrc.json"),
2975            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2976        )
2977        .unwrap();
2978
2979        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2980        assert!(result.is_err());
2981        let err_msg = format!("{}", result.unwrap_err());
2982        assert!(
2983            err_msg.contains("unreachable.invalid"),
2984            "Expected URL in error message, got: {err_msg}"
2985        );
2986    }
2987
2988    #[test]
2989    fn extends_https_url_unreachable_errors() {
2990        let dir = test_dir("url-unreachable");
2991        std::fs::write(
2992            dir.path().join(".fallowrc.json"),
2993            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2994        )
2995        .unwrap();
2996
2997        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2998        assert!(result.is_err());
2999        let err_msg = format!("{}", result.unwrap_err());
3000        assert!(
3001            err_msg.contains("unreachable.invalid"),
3002            "Expected URL in error, got: {err_msg}"
3003        );
3004        assert!(
3005            err_msg.contains("local path or npm:"),
3006            "Expected remediation hint, got: {err_msg}"
3007        );
3008    }
3009}