Skip to main content

fallow_config/config/
parsing.rs

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