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