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