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