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