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