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
661/// Collect every unknown key under `rules` or `overrides[].rules` in a merged
662/// config value (issue #467, phase 1).
663///
664/// Today `RulesConfig` / `PartialRulesConfig` carry serde aliases but NOT
665/// `deny_unknown_fields`, so typos like `unsued-files` are silently dropped and
666/// the user's intent is lost. This pass walks the merged value before
667/// deserialization and surfaces every unknown key, with a Levenshtein-distance
668/// suggestion when the typo is close to a known name.
669///
670/// Returns the findings so the caller can render them; tests can assert
671/// against the list without subscribing to tracing output.
672///
673/// Phase 2 (a future minor release) flips both structs to
674/// `#[serde(deny_unknown_fields)]` and the warning becomes a hard error.
675pub(super) fn collect_unknown_rule_keys(
676    merged: &serde_json::Value,
677) -> Vec<super::rules::UnknownRuleKey> {
678    use super::rules::find_unknown_rule_keys;
679
680    let mut findings = Vec::new();
681
682    if let Some(rules) = merged.get("rules") {
683        findings.extend(find_unknown_rule_keys(rules, "rules"));
684    }
685
686    if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
687        for (i, entry) in overrides.iter().enumerate() {
688            if let Some(rules) = entry.get("rules") {
689                let context = format!("overrides[{i}].rules");
690                findings.extend(find_unknown_rule_keys(rules, &context));
691            }
692        }
693    }
694
695    findings
696}
697
698thread_local! {
699    /// Per-thread capture of unknown-rule findings, for the wiring regression
700    /// test in this module. Each test installs a fresh capture via
701    /// [`capture_unknown_rule_warnings`], runs `FallowConfig::load`, and reads
702    /// back the findings. Thread-local so parallel test execution does not
703    /// race; bypassed entirely in production code (`UnknownRuleCapture::None`).
704    #[cfg(test)]
705    static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
706        const { std::cell::RefCell::new(None) };
707}
708
709/// Install a thread-local capture buffer and run `body`. Returns the findings
710/// emitted by every `warn_on_unknown_rule_keys` call within `body`'s call tree
711/// on the current thread, in order. Test-only.
712#[cfg(test)]
713pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
714    body: F,
715) -> (R, Vec<super::rules::UnknownRuleKey>) {
716    UNKNOWN_RULE_CAPTURE.with(|cell| {
717        *cell.borrow_mut() = Some(Vec::new());
718    });
719    let result = body();
720    let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
721    (result, findings)
722}
723
724/// Emit a `tracing::warn!` per finding from [`collect_unknown_rule_keys`].
725///
726/// `config_path` is the file the merged value originated from; it appears in
727/// the warning text AND in the dedupe key so two different config files with
728/// the same typo each warn once instead of the second one being silenced.
729///
730/// Deduplicates within the process: `FallowConfig::load` runs multiple times
731/// per analysis (combined mode runs check + dupes + health, each through the
732/// same config load path), so without a dedupe the same typo emits 3+ warnings
733/// per run.
734fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
735    use std::sync::{Mutex, OnceLock};
736
737    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
738    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
739
740    let path_display = config_path.display().to_string();
741
742    for finding in collect_unknown_rule_keys(merged) {
743        let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
744        // On a poisoned mutex, fall through and emit anyway: over-warning is
745        // strictly better than swallowing a typo silently.
746        if let Ok(mut set) = warned.lock()
747            && !set.insert(dedupe_key)
748        {
749            continue;
750        }
751
752        #[cfg(test)]
753        UNKNOWN_RULE_CAPTURE.with(|cell| {
754            if let Some(buf) = cell.borrow_mut().as_mut() {
755                buf.push(finding.clone());
756            }
757        });
758
759        if let Some(suggestion) = finding.suggestion {
760            tracing::warn!(
761                "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
762                 the rule will be ignored. A future release will reject unknown rule names.",
763                key = finding.key,
764                context = finding.context,
765                path = path_display,
766            );
767        } else {
768            tracing::warn!(
769                "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
770                 A future release will reject unknown rule names.",
771                key = finding.key,
772                context = finding.context,
773                path = path_display,
774            );
775        }
776    }
777}
778
779impl FallowConfig {
780    /// Load config from a fallow config file (TOML or JSON/JSONC).
781    ///
782    /// The format is detected from the file extension:
783    /// - `.toml` → TOML
784    /// - `.json` → JSON (with JSONC comment stripping)
785    ///
786    /// Supports `extends` for config inheritance. Extended configs are loaded
787    /// and deep-merged before this config's values are applied.
788    ///
789    /// User-supplied glob patterns (`entry`, `ignorePatterns`,
790    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
791    /// `boundaries.zones[].patterns`, `overrides[].files`,
792    /// `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`) are
793    /// validated against absolute paths, `..` traversal segments, and invalid
794    /// glob syntax. Loading fails loud on any rejection so silent no-match
795    /// configs surface to the user. See issue #463.
796    ///
797    /// # Errors
798    ///
799    /// Returns an error when the config file cannot be read, merged, or
800    /// deserialized, or when any user-supplied glob pattern is rejected.
801    pub fn load(path: &Path) -> Result<Self, miette::Report> {
802        let mut visited = FxHashSet::default();
803        let merged = resolve_extends(path, &mut visited, 0)?;
804
805        warn_on_unknown_rule_keys(path, &merged);
806
807        let config: Self = serde_json::from_value(merged).map_err(|e| {
808            miette::miette!(
809                "Failed to deserialize config from {}: {}",
810                path.display(),
811                e
812            )
813        })?;
814
815        // Surface validation errors as a bullet list. The outer wrapper in
816        // `find_and_load` / `runtime_support::load_config_for_analysis` is
817        // responsible for prefixing the file path so the path appears exactly
818        // once in the rendered error.
819        config.validate_user_globs().map_err(|errors| {
820            let joined = errors
821                .iter()
822                .map(ToString::to_string)
823                .collect::<Vec<_>>()
824                .join("\n  - ");
825            miette::miette!("invalid config:\n  - {}", joined)
826        })?;
827
828        Ok(config)
829    }
830
831    /// Validate all user-supplied glob patterns and directory paths in this config.
832    ///
833    /// Accumulates errors from every glob- or path-bearing field so the user
834    /// sees ALL offending values in one run rather than fixing them one at a
835    /// time.
836    ///
837    /// Covered glob fields: `entry`, `ignorePatterns`, `dynamicallyLoaded`,
838    /// `duplicates.ignore`, `health.ignore`, `overrides[].files`,
839    /// `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`,
840    /// `boundaries.zones[].patterns`, plus every glob-bearing field on
841    /// inline `framework[]` plugin definitions (entry points, always-used,
842    /// config patterns, used-exports patterns, and `fileExists` detection
843    /// patterns; the last reaches `glob::glob` on disk so a `..` segment
844    /// there is a real path traversal).
845    ///
846    /// Covered directory-path fields: `boundaries.zones[].root` and
847    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
848    /// globs), so only the absolute-path + traversal checks apply.
849    ///
850    /// # Errors
851    ///
852    /// Returns a non-empty `Vec` of
853    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
854    /// when any field contains a rejected value.
855    pub fn validate_user_globs(
856        &self,
857    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
858        use super::glob_validation::{
859            compile_user_glob, validate_user_globs, validate_user_path, validate_user_paths,
860        };
861
862        let mut errors = Vec::new();
863
864        validate_user_globs(&self.entry, "entry", &mut errors);
865        validate_user_globs(&self.ignore_patterns, "ignorePatterns", &mut errors);
866        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", &mut errors);
867        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", &mut errors);
868        validate_user_globs(&self.health.ignore, "health.ignore", &mut errors);
869
870        for override_entry in &self.overrides {
871            validate_user_globs(&override_entry.files, "overrides[].files", &mut errors);
872        }
873
874        for rule in &self.ignore_exports {
875            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
876                errors.push(e);
877            }
878        }
879
880        for rule in &self.ignore_catalog_references {
881            if let Some(consumer) = &rule.consumer
882                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
883            {
884                errors.push(e);
885            }
886        }
887
888        for zone in &self.boundaries.zones {
889            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", &mut errors);
890            if let Some(root) = &zone.root
891                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
892            {
893                errors.push(e);
894            }
895            validate_user_paths(
896                &zone.auto_discover,
897                "boundaries.zones[].autoDiscover",
898                &mut errors,
899            );
900        }
901
902        // Inline framework plugins. Shares validation logic with external
903        // plugin files loaded from `.fallow/plugins/` / `fallow-plugin-*`
904        // (see `ExternalPluginDef::validate_user_globs`), so an inline
905        // `framework[]` block and a file-loaded plugin get identical checks.
906        // The `detection.fileExists.pattern` field is the security-critical
907        // case because it reaches `glob::glob` on disk via `root.join(pattern)`
908        // in `crates/core/src/plugins/registry/helpers.rs`.
909        for plugin in &self.framework {
910            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
911                errors.append(&mut plugin_errors);
912            }
913        }
914
915        if errors.is_empty() {
916            Ok(())
917        } else {
918            Err(errors)
919        }
920    }
921
922    /// Find the config file path without loading it.
923    /// Searches the same locations as `find_and_load`.
924    #[must_use]
925    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
926        let mut dir = start;
927        loop {
928            for name in CONFIG_NAMES {
929                let candidate = dir.join(name);
930                if candidate.exists() {
931                    return Some(candidate);
932                }
933            }
934            if is_repo_root(dir) {
935                break;
936            }
937            dir = dir.parent()?;
938        }
939        None
940    }
941
942    /// Find and load config, searching from `start` up to the project root.
943    ///
944    /// # Errors
945    ///
946    /// Returns an error if a config file is found but cannot be read or parsed.
947    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
948        let mut dir = start;
949        loop {
950            for name in CONFIG_NAMES {
951                let candidate = dir.join(name);
952                if candidate.exists() {
953                    match Self::load(&candidate) {
954                        Ok(config) => return Ok(Some((config, candidate))),
955                        Err(e) => {
956                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
957                        }
958                    }
959                }
960            }
961            // Stop at project root indicators (VCS markers). We intentionally
962            // do NOT stop at `package.json` so that monorepo sub-packages
963            // inherit a root config placed alongside the workspace root.
964            if is_repo_root(dir) {
965                break;
966            }
967            dir = match dir.parent() {
968                Some(parent) => parent,
969                None => break,
970            };
971        }
972        Ok(None)
973    }
974
975    /// Generate JSON Schema for the configuration format.
976    #[must_use]
977    pub fn json_schema() -> serde_json::Value {
978        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
979    }
980
981    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
982    /// preset and auto-discover expansion.
983    ///
984    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
985    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
986    /// before invoking
987    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
988    /// and
989    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
990    /// so Bulletproof-style presets whose authored rule references logical
991    /// groups (`features`) still load cleanly.
992    ///
993    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
994    /// `core::lib::config_for_project` for LSP and programmatic embedders)
995    /// surface every collected error in a single rendered diagnostic, then
996    /// exit with code 2. Previously these failures emitted `tracing::error!`
997    /// and continued, producing a flood of false-positive boundary violations
998    /// at analysis time (#468).
999    ///
1000    /// `root` is the project root used by `expand_auto_discover` to scan for
1001    /// child directories. Caller is responsible for passing the same root it
1002    /// later hands to `resolve()`.
1003    ///
1004    /// # Errors
1005    ///
1006    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1007    /// offending zone reference and redundant-root-prefix pattern; the empty
1008    /// case becomes `Ok(())`.
1009    pub fn validate_resolved_boundaries(
1010        &self,
1011        root: &Path,
1012    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1013        use super::boundaries::ZoneValidationError;
1014
1015        // Clone the boundary section so this method stays non-consuming;
1016        // resolve() takes `self` by value and runs the same expansion in-place.
1017        let mut boundaries = self.boundaries.clone();
1018        if boundaries.preset.is_some() {
1019            // Mirror the source-root detection in `FallowConfig::resolve`:
1020            // tsconfig.json's `rootDir` wins when it points at a relative,
1021            // non-traversal subtree; otherwise default to `src`.
1022            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1023                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1024                .unwrap_or_else(|| "src".to_owned());
1025            boundaries.expand(&source_root);
1026        }
1027        let _logical_groups = boundaries.expand_auto_discover(root);
1028
1029        let mut errors: Vec<ZoneValidationError> = boundaries
1030            .validate_zone_references()
1031            .into_iter()
1032            .map(ZoneValidationError::UnknownZoneReference)
1033            .collect();
1034        errors.extend(
1035            boundaries
1036                .validate_root_prefixes()
1037                .into_iter()
1038                .map(ZoneValidationError::RedundantRootPrefix),
1039        );
1040
1041        if errors.is_empty() {
1042            Ok(())
1043        } else {
1044            Err(errors)
1045        }
1046    }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use crate::CacheConfig;
1053    use crate::PackageJson;
1054    use crate::config::format::OutputFormat;
1055    use crate::config::rules::Severity;
1056
1057    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1058    fn test_dir(_name: &str) -> tempfile::TempDir {
1059        tempfile::tempdir().expect("create temp dir")
1060    }
1061
1062    #[test]
1063    fn fallow_config_deserialize_minimal() {
1064        let toml_str = r#"
1065entry = ["src/main.ts"]
1066"#;
1067        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1068        assert_eq!(config.entry, vec!["src/main.ts"]);
1069        assert!(config.ignore_patterns.is_empty());
1070    }
1071
1072    #[test]
1073    fn fallow_config_deserialize_ignore_exports() {
1074        let toml_str = r#"
1075[[ignoreExports]]
1076file = "src/types/*.ts"
1077exports = ["*"]
1078
1079[[ignoreExports]]
1080file = "src/constants.ts"
1081exports = ["FOO", "BAR"]
1082"#;
1083        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1084        assert_eq!(config.ignore_exports.len(), 2);
1085        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1086        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1087        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1088    }
1089
1090    #[test]
1091    fn fallow_config_deserialize_ignore_dependencies() {
1092        let toml_str = r#"
1093ignoreDependencies = ["autoprefixer", "postcss"]
1094"#;
1095        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1096        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1097    }
1098
1099    #[test]
1100    fn fallow_config_resolve_default_ignores() {
1101        let config = FallowConfig::default();
1102        let resolved = config.resolve(
1103            PathBuf::from("/tmp/test"),
1104            OutputFormat::Human,
1105            4,
1106            true,
1107            true,
1108            None,
1109        );
1110
1111        // Default ignores should be compiled
1112        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1113        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1114        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1115        assert!(resolved.ignore_patterns.is_match(".git/config"));
1116        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1117        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1118        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1119    }
1120
1121    #[test]
1122    fn fallow_config_resolve_custom_ignores() {
1123        let config = FallowConfig {
1124            entry: vec!["src/**/*.ts".to_string()],
1125            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1126            ..Default::default()
1127        };
1128        let resolved = config.resolve(
1129            PathBuf::from("/tmp/test"),
1130            OutputFormat::Json,
1131            4,
1132            false,
1133            true,
1134            None,
1135        );
1136
1137        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1138        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1139        assert!(matches!(resolved.output, OutputFormat::Json));
1140        assert!(!resolved.no_cache);
1141    }
1142
1143    #[test]
1144    fn fallow_config_resolve_cache_dir() {
1145        let config = FallowConfig::default();
1146        let resolved = config.resolve(
1147            PathBuf::from("/tmp/project"),
1148            OutputFormat::Human,
1149            4,
1150            true,
1151            true,
1152            None,
1153        );
1154        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1155        assert!(resolved.no_cache);
1156    }
1157
1158    #[test]
1159    fn package_json_entry_points_main() {
1160        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1161        let entries = pkg.entry_points();
1162        assert!(entries.contains(&"dist/index.js".to_string()));
1163    }
1164
1165    #[test]
1166    fn package_json_entry_points_module() {
1167        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1168        let entries = pkg.entry_points();
1169        assert!(entries.contains(&"dist/index.mjs".to_string()));
1170    }
1171
1172    #[test]
1173    fn package_json_entry_points_types() {
1174        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1175        let entries = pkg.entry_points();
1176        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1177    }
1178
1179    #[test]
1180    fn package_json_entry_points_bin_string() {
1181        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1182        let entries = pkg.entry_points();
1183        assert!(entries.contains(&"bin/cli.js".to_string()));
1184    }
1185
1186    #[test]
1187    fn package_json_entry_points_bin_object() {
1188        let pkg: PackageJson =
1189            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1190                .unwrap();
1191        let entries = pkg.entry_points();
1192        assert!(entries.contains(&"bin/cli.js".to_string()));
1193        assert!(entries.contains(&"bin/serve.js".to_string()));
1194    }
1195
1196    #[test]
1197    fn package_json_entry_points_exports_string() {
1198        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1199        let entries = pkg.entry_points();
1200        assert!(entries.contains(&"./dist/index.js".to_string()));
1201    }
1202
1203    #[test]
1204    fn package_json_entry_points_exports_object() {
1205        let pkg: PackageJson = serde_json::from_str(
1206            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1207        )
1208        .unwrap();
1209        let entries = pkg.entry_points();
1210        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1211        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1212    }
1213
1214    #[test]
1215    fn package_json_dependency_names() {
1216        let pkg: PackageJson = serde_json::from_str(
1217            r#"{
1218            "dependencies": {"react": "^18", "lodash": "^4"},
1219            "devDependencies": {"typescript": "^5"},
1220            "peerDependencies": {"react-dom": "^18"}
1221        }"#,
1222        )
1223        .unwrap();
1224
1225        let all = pkg.all_dependency_names();
1226        assert!(all.contains(&"react".to_string()));
1227        assert!(all.contains(&"lodash".to_string()));
1228        assert!(all.contains(&"typescript".to_string()));
1229        assert!(all.contains(&"react-dom".to_string()));
1230
1231        let prod = pkg.production_dependency_names();
1232        assert!(prod.contains(&"react".to_string()));
1233        assert!(!prod.contains(&"typescript".to_string()));
1234
1235        let dev = pkg.dev_dependency_names();
1236        assert!(dev.contains(&"typescript".to_string()));
1237        assert!(!dev.contains(&"react".to_string()));
1238    }
1239
1240    #[test]
1241    fn package_json_no_dependencies() {
1242        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1243        assert!(pkg.all_dependency_names().is_empty());
1244        assert!(pkg.production_dependency_names().is_empty());
1245        assert!(pkg.dev_dependency_names().is_empty());
1246        assert!(pkg.entry_points().is_empty());
1247    }
1248
1249    #[test]
1250    fn rules_deserialize_toml_kebab_case() {
1251        let toml_str = r#"
1252[rules]
1253unused-files = "error"
1254unused-exports = "warn"
1255unused-types = "off"
1256"#;
1257        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1258        assert_eq!(config.rules.unused_files, Severity::Error);
1259        assert_eq!(config.rules.unused_exports, Severity::Warn);
1260        assert_eq!(config.rules.unused_types, Severity::Off);
1261        // Unset fields default to error
1262        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1263    }
1264
1265    #[test]
1266    fn config_without_rules_defaults_to_error() {
1267        let toml_str = r#"
1268entry = ["src/main.ts"]
1269"#;
1270        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1271        assert_eq!(config.rules.unused_files, Severity::Error);
1272        assert_eq!(config.rules.unused_exports, Severity::Error);
1273    }
1274
1275    #[test]
1276    fn fallow_config_denies_unknown_fields() {
1277        let toml_str = r"
1278unknown_field = true
1279";
1280        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1281        assert!(result.is_err());
1282    }
1283
1284    #[test]
1285    fn fallow_config_deserialize_json() {
1286        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1287        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1288        assert_eq!(config.entry, vec!["src/main.ts"]);
1289    }
1290
1291    #[test]
1292    fn fallow_config_deserialize_jsonc() {
1293        let jsonc_str = r#"{
1294            // This is a comment
1295            "entry": ["src/main.ts"],
1296            "rules": {
1297                "unused-files": "warn"
1298            }
1299        }"#;
1300        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1301        assert_eq!(config.entry, vec!["src/main.ts"]);
1302        assert_eq!(config.rules.unused_files, Severity::Warn);
1303    }
1304
1305    #[test]
1306    fn fallow_config_json_with_schema_field() {
1307        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1308        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1309        assert_eq!(config.entry, vec!["src/main.ts"]);
1310    }
1311
1312    #[test]
1313    fn fallow_config_json_schema_generation() {
1314        let schema = FallowConfig::json_schema();
1315        assert!(schema.is_object());
1316        let obj = schema.as_object().unwrap();
1317        assert!(obj.contains_key("properties"));
1318    }
1319
1320    #[test]
1321    fn config_format_detection() {
1322        assert!(matches!(
1323            ConfigFormat::from_path(Path::new("fallow.toml")),
1324            ConfigFormat::Toml
1325        ));
1326        assert!(matches!(
1327            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1328            ConfigFormat::Json
1329        ));
1330        assert!(matches!(
1331            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1332            ConfigFormat::Json
1333        ));
1334        assert!(matches!(
1335            ConfigFormat::from_path(Path::new(".fallow.toml")),
1336            ConfigFormat::Toml
1337        ));
1338    }
1339
1340    #[test]
1341    fn config_names_priority_order() {
1342        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1343        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1344        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1345        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1346    }
1347
1348    #[test]
1349    fn load_json_config_file() {
1350        let dir = test_dir("json-config");
1351        let config_path = dir.path().join(".fallowrc.json");
1352        std::fs::write(
1353            &config_path,
1354            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1355        )
1356        .unwrap();
1357
1358        let config = FallowConfig::load(&config_path).unwrap();
1359        assert_eq!(config.entry, vec!["src/index.ts"]);
1360        assert_eq!(config.rules.unused_exports, Severity::Warn);
1361    }
1362
1363    #[test]
1364    fn load_jsonc_config_file() {
1365        let dir = test_dir("jsonc-config");
1366        let config_path = dir.path().join(".fallowrc.json");
1367        std::fs::write(
1368            &config_path,
1369            r#"{
1370                // Entry points for analysis
1371                "entry": ["src/index.ts"],
1372                /* Block comment */
1373                "rules": {
1374                    "unused-exports": "warn"
1375                }
1376            }"#,
1377        )
1378        .unwrap();
1379
1380        let config = FallowConfig::load(&config_path).unwrap();
1381        assert_eq!(config.entry, vec!["src/index.ts"]);
1382        assert_eq!(config.rules.unused_exports, Severity::Warn);
1383    }
1384
1385    #[test]
1386    fn load_fallowrc_jsonc_extension() {
1387        let dir = test_dir("jsonc-extension");
1388        let config_path = dir.path().join(".fallowrc.jsonc");
1389        std::fs::write(
1390            &config_path,
1391            r#"{
1392                // editors that recognize the .jsonc extension show
1393                // proper JSON-with-comments syntax highlighting
1394                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1395                "entry": ["src/index.ts"]
1396            }"#,
1397        )
1398        .unwrap();
1399
1400        let config = FallowConfig::load(&config_path).unwrap();
1401        assert_eq!(config.entry, vec!["src/index.ts"]);
1402        assert_eq!(
1403            config.ignore_dependencies,
1404            vec!["tailwindcss-react-aria-components"]
1405        );
1406    }
1407
1408    #[test]
1409    fn json_config_ignore_dependencies_camel_case() {
1410        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1411        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1412        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1413    }
1414
1415    #[test]
1416    fn json_config_all_fields() {
1417        let json_str = r#"{
1418            "ignoreDependencies": ["lodash"],
1419            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1420            "rules": {
1421                "unused-files": "off",
1422                "unused-exports": "warn",
1423                "unused-dependencies": "error",
1424                "unused-dev-dependencies": "off",
1425                "unused-types": "warn",
1426                "unused-enum-members": "error",
1427                "unused-class-members": "off",
1428                "unresolved-imports": "warn",
1429                "unlisted-dependencies": "error",
1430                "duplicate-exports": "off"
1431            },
1432            "duplicates": {
1433                "minTokens": 100,
1434                "minLines": 10,
1435                "skipLocal": true
1436            }
1437        }"#;
1438        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1439        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1440        assert_eq!(config.rules.unused_files, Severity::Off);
1441        assert_eq!(config.rules.unused_exports, Severity::Warn);
1442        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1443        assert_eq!(config.duplicates.min_tokens, 100);
1444        assert_eq!(config.duplicates.min_lines, 10);
1445        assert!(config.duplicates.skip_local);
1446    }
1447
1448    // ── extends tests ──────────────────────────────────────────────
1449
1450    #[test]
1451    fn extends_single_base() {
1452        let dir = test_dir("extends-single");
1453
1454        std::fs::write(
1455            dir.path().join("base.json"),
1456            r#"{"rules": {"unused-files": "warn"}}"#,
1457        )
1458        .unwrap();
1459        std::fs::write(
1460            dir.path().join(".fallowrc.json"),
1461            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1462        )
1463        .unwrap();
1464
1465        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1466        assert_eq!(config.rules.unused_files, Severity::Warn);
1467        assert_eq!(config.entry, vec!["src/index.ts"]);
1468        // Unset fields from base still default
1469        assert_eq!(config.rules.unused_exports, Severity::Error);
1470    }
1471
1472    #[test]
1473    fn extends_overlay_overrides_base() {
1474        let dir = test_dir("extends-overlay");
1475
1476        std::fs::write(
1477            dir.path().join("base.json"),
1478            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1479        )
1480        .unwrap();
1481        std::fs::write(
1482            dir.path().join(".fallowrc.json"),
1483            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1484        )
1485        .unwrap();
1486
1487        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1488        // Overlay overrides base
1489        assert_eq!(config.rules.unused_files, Severity::Error);
1490        // Base value preserved when not overridden
1491        assert_eq!(config.rules.unused_exports, Severity::Off);
1492    }
1493
1494    #[test]
1495    fn extends_chained() {
1496        let dir = test_dir("extends-chained");
1497
1498        std::fs::write(
1499            dir.path().join("grandparent.json"),
1500            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1501        )
1502        .unwrap();
1503        std::fs::write(
1504            dir.path().join("parent.json"),
1505            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1506        )
1507        .unwrap();
1508        std::fs::write(
1509            dir.path().join(".fallowrc.json"),
1510            r#"{"extends": ["parent.json"]}"#,
1511        )
1512        .unwrap();
1513
1514        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1515        // grandparent: off -> parent: warn -> child: inherits warn
1516        assert_eq!(config.rules.unused_files, Severity::Warn);
1517        // grandparent: warn, not overridden
1518        assert_eq!(config.rules.unused_exports, Severity::Warn);
1519    }
1520
1521    #[test]
1522    fn extends_circular_detected() {
1523        let dir = test_dir("extends-circular");
1524
1525        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1526        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1527
1528        let result = FallowConfig::load(&dir.path().join("a.json"));
1529        assert!(result.is_err());
1530        let err_msg = format!("{}", result.unwrap_err());
1531        assert!(
1532            err_msg.contains("Circular extends"),
1533            "Expected circular error, got: {err_msg}"
1534        );
1535    }
1536
1537    #[test]
1538    fn extends_missing_file_errors() {
1539        let dir = test_dir("extends-missing");
1540
1541        std::fs::write(
1542            dir.path().join(".fallowrc.json"),
1543            r#"{"extends": ["nonexistent.json"]}"#,
1544        )
1545        .unwrap();
1546
1547        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1548        assert!(result.is_err());
1549        let err_msg = format!("{}", result.unwrap_err());
1550        assert!(
1551            err_msg.contains("not found"),
1552            "Expected not found error, got: {err_msg}"
1553        );
1554    }
1555
1556    // ── sealed: true tests ──────────────────────────────────────────
1557
1558    #[test]
1559    fn sealed_allows_in_directory_extends() {
1560        let dir = test_dir("sealed-allows-local");
1561        std::fs::write(
1562            dir.path().join("base.json"),
1563            r#"{"ignorePatterns": ["gen/**"]}"#,
1564        )
1565        .unwrap();
1566        std::fs::write(
1567            dir.path().join(".fallowrc.json"),
1568            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1569        )
1570        .unwrap();
1571
1572        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1573        assert!(config.sealed);
1574        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1575    }
1576
1577    #[test]
1578    fn sealed_rejects_extends_escaping_directory() {
1579        let dir = test_dir("sealed-rejects-escape");
1580        let sub = dir.path().join("packages").join("app");
1581        std::fs::create_dir_all(&sub).unwrap();
1582
1583        // Base config above the sealed config's directory
1584        std::fs::write(
1585            dir.path().join("base.json"),
1586            r#"{"ignorePatterns": ["dist/**"]}"#,
1587        )
1588        .unwrap();
1589        std::fs::write(
1590            sub.join(".fallowrc.json"),
1591            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1592        )
1593        .unwrap();
1594
1595        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1596        assert!(
1597            result.is_err(),
1598            "Expected sealed config to reject escaping extends"
1599        );
1600        let err_msg = format!("{}", result.unwrap_err());
1601        assert!(
1602            err_msg.contains("sealed"),
1603            "Error must mention sealed: {err_msg}"
1604        );
1605        assert!(
1606            err_msg.contains("outside the config's directory"),
1607            "Error must explain the constraint: {err_msg}"
1608        );
1609    }
1610
1611    #[test]
1612    fn sealed_rejects_https_extends() {
1613        let dir = test_dir("sealed-rejects-https");
1614        std::fs::write(
1615            dir.path().join(".fallowrc.json"),
1616            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1617        )
1618        .unwrap();
1619
1620        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1621        assert!(result.is_err());
1622        let err_msg = format!("{}", result.unwrap_err());
1623        assert!(
1624            err_msg.contains("sealed"),
1625            "Error must mention sealed: {err_msg}"
1626        );
1627        assert!(
1628            err_msg.contains("URL extends"),
1629            "Error must mention URL: {err_msg}"
1630        );
1631    }
1632
1633    #[test]
1634    fn sealed_rejects_npm_extends() {
1635        let dir = test_dir("sealed-rejects-npm");
1636        std::fs::write(
1637            dir.path().join(".fallowrc.json"),
1638            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1639        )
1640        .unwrap();
1641
1642        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1643        assert!(result.is_err());
1644        let err_msg = format!("{}", result.unwrap_err());
1645        assert!(
1646            err_msg.contains("sealed"),
1647            "Error must mention sealed: {err_msg}"
1648        );
1649        assert!(
1650            err_msg.contains("npm extends"),
1651            "Error must mention npm: {err_msg}"
1652        );
1653    }
1654
1655    #[test]
1656    fn sealed_default_is_false() {
1657        let dir = test_dir("sealed-default");
1658        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1659        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1660        assert!(!config.sealed);
1661    }
1662
1663    #[test]
1664    fn sealed_false_allows_escaping_extends() {
1665        // Without sealed (or sealed: false), escaping extends works fine
1666        let dir = test_dir("sealed-false-allows");
1667        let sub = dir.path().join("packages").join("app");
1668        std::fs::create_dir_all(&sub).unwrap();
1669
1670        std::fs::write(
1671            dir.path().join("base.json"),
1672            r#"{"ignorePatterns": ["dist/**"]}"#,
1673        )
1674        .unwrap();
1675        std::fs::write(
1676            sub.join(".fallowrc.json"),
1677            r#"{"extends": ["../../base.json"]}"#,
1678        )
1679        .unwrap();
1680
1681        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1682        assert!(!config.sealed);
1683        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1684    }
1685
1686    #[test]
1687    fn extends_string_sugar() {
1688        let dir = test_dir("extends-string");
1689
1690        std::fs::write(
1691            dir.path().join("base.json"),
1692            r#"{"ignorePatterns": ["gen/**"]}"#,
1693        )
1694        .unwrap();
1695        // String form instead of array
1696        std::fs::write(
1697            dir.path().join(".fallowrc.json"),
1698            r#"{"extends": "base.json"}"#,
1699        )
1700        .unwrap();
1701
1702        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1703        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1704    }
1705
1706    #[test]
1707    fn extends_deep_merge_preserves_arrays() {
1708        let dir = test_dir("extends-array");
1709
1710        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1711        std::fs::write(
1712            dir.path().join(".fallowrc.json"),
1713            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1714        )
1715        .unwrap();
1716
1717        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1718        // Arrays are replaced, not merged (overlay replaces base)
1719        assert_eq!(config.entry, vec!["src/b.ts"]);
1720    }
1721
1722    // ── npm extends tests ────────────────────────────────────────────
1723
1724    /// Set up a fake npm package in `node_modules/<name>` under `root`.
1725    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1726        let pkg_dir = root.join("node_modules").join(name);
1727        std::fs::create_dir_all(&pkg_dir).unwrap();
1728        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1729    }
1730
1731    /// Set up a fake npm package with `package.json` `main` field.
1732    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1733        let pkg_dir = root.join("node_modules").join(name);
1734        std::fs::create_dir_all(&pkg_dir).unwrap();
1735        std::fs::write(
1736            pkg_dir.join("package.json"),
1737            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1738        )
1739        .unwrap();
1740        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1741    }
1742
1743    #[test]
1744    fn extends_npm_basic_unscoped() {
1745        let dir = test_dir("npm-basic");
1746        create_npm_package(
1747            dir.path(),
1748            "fallow-config-acme",
1749            r#"{"rules": {"unused-files": "warn"}}"#,
1750        );
1751        std::fs::write(
1752            dir.path().join(".fallowrc.json"),
1753            r#"{"extends": "npm:fallow-config-acme"}"#,
1754        )
1755        .unwrap();
1756
1757        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1758        assert_eq!(config.rules.unused_files, Severity::Warn);
1759    }
1760
1761    #[test]
1762    fn extends_npm_scoped_package() {
1763        let dir = test_dir("npm-scoped");
1764        create_npm_package(
1765            dir.path(),
1766            "@company/fallow-config",
1767            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1768        );
1769        std::fs::write(
1770            dir.path().join(".fallowrc.json"),
1771            r#"{"extends": "npm:@company/fallow-config"}"#,
1772        )
1773        .unwrap();
1774
1775        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1776        assert_eq!(config.rules.unused_exports, Severity::Off);
1777        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1778    }
1779
1780    #[test]
1781    fn extends_npm_with_subpath() {
1782        let dir = test_dir("npm-subpath");
1783        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1784        std::fs::create_dir_all(&pkg_dir).unwrap();
1785        std::fs::write(
1786            pkg_dir.join("strict.json"),
1787            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1788        )
1789        .unwrap();
1790
1791        std::fs::write(
1792            dir.path().join(".fallowrc.json"),
1793            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1794        )
1795        .unwrap();
1796
1797        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1798        assert_eq!(config.rules.unused_files, Severity::Error);
1799        assert_eq!(config.rules.unused_exports, Severity::Error);
1800    }
1801
1802    #[test]
1803    fn extends_npm_package_json_main() {
1804        let dir = test_dir("npm-main");
1805        create_npm_package_with_main(
1806            dir.path(),
1807            "fallow-config-acme",
1808            "config.json",
1809            r#"{"rules": {"unused-types": "off"}}"#,
1810        );
1811        std::fs::write(
1812            dir.path().join(".fallowrc.json"),
1813            r#"{"extends": "npm:fallow-config-acme"}"#,
1814        )
1815        .unwrap();
1816
1817        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1818        assert_eq!(config.rules.unused_types, Severity::Off);
1819    }
1820
1821    #[test]
1822    fn extends_npm_package_json_exports_string() {
1823        let dir = test_dir("npm-exports-str");
1824        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1825        std::fs::create_dir_all(&pkg_dir).unwrap();
1826        std::fs::write(
1827            pkg_dir.join("package.json"),
1828            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1829        )
1830        .unwrap();
1831        std::fs::write(
1832            pkg_dir.join("base.json"),
1833            r#"{"rules": {"circular-dependencies": "warn"}}"#,
1834        )
1835        .unwrap();
1836
1837        std::fs::write(
1838            dir.path().join(".fallowrc.json"),
1839            r#"{"extends": "npm:fallow-config-co"}"#,
1840        )
1841        .unwrap();
1842
1843        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1844        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1845    }
1846
1847    #[test]
1848    fn extends_npm_package_json_exports_object() {
1849        let dir = test_dir("npm-exports-obj");
1850        let pkg_dir = dir.path().join("node_modules/@co/cfg");
1851        std::fs::create_dir_all(&pkg_dir).unwrap();
1852        std::fs::write(
1853            pkg_dir.join("package.json"),
1854            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1855        )
1856        .unwrap();
1857        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1858
1859        std::fs::write(
1860            dir.path().join(".fallowrc.json"),
1861            r#"{"extends": "npm:@co/cfg"}"#,
1862        )
1863        .unwrap();
1864
1865        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1866        assert_eq!(config.entry, vec!["src/app.ts"]);
1867    }
1868
1869    #[test]
1870    fn extends_npm_exports_takes_priority_over_main() {
1871        let dir = test_dir("npm-exports-prio");
1872        let pkg_dir = dir.path().join("node_modules/my-config");
1873        std::fs::create_dir_all(&pkg_dir).unwrap();
1874        std::fs::write(
1875            pkg_dir.join("package.json"),
1876            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1877        )
1878        .unwrap();
1879        std::fs::write(
1880            pkg_dir.join("old.json"),
1881            r#"{"rules": {"unused-files": "off"}}"#,
1882        )
1883        .unwrap();
1884        std::fs::write(
1885            pkg_dir.join("new.json"),
1886            r#"{"rules": {"unused-files": "warn"}}"#,
1887        )
1888        .unwrap();
1889
1890        std::fs::write(
1891            dir.path().join(".fallowrc.json"),
1892            r#"{"extends": "npm:my-config"}"#,
1893        )
1894        .unwrap();
1895
1896        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1897        // exports takes priority over main
1898        assert_eq!(config.rules.unused_files, Severity::Warn);
1899    }
1900
1901    #[test]
1902    fn extends_npm_walk_up_directories() {
1903        let dir = test_dir("npm-walkup");
1904        // node_modules at root level
1905        create_npm_package(
1906            dir.path(),
1907            "shared-config",
1908            r#"{"rules": {"unused-files": "warn"}}"#,
1909        );
1910        // Config in a nested subdirectory
1911        let sub = dir.path().join("packages/app");
1912        std::fs::create_dir_all(&sub).unwrap();
1913        std::fs::write(
1914            sub.join(".fallowrc.json"),
1915            r#"{"extends": "npm:shared-config"}"#,
1916        )
1917        .unwrap();
1918
1919        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1920        assert_eq!(config.rules.unused_files, Severity::Warn);
1921    }
1922
1923    #[test]
1924    fn extends_npm_overlay_overrides_base() {
1925        let dir = test_dir("npm-overlay");
1926        create_npm_package(
1927            dir.path(),
1928            "@company/base",
1929            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1930        );
1931        std::fs::write(
1932            dir.path().join(".fallowrc.json"),
1933            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1934        )
1935        .unwrap();
1936
1937        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1938        assert_eq!(config.rules.unused_files, Severity::Error);
1939        assert_eq!(config.rules.unused_exports, Severity::Off);
1940        assert_eq!(config.entry, vec!["src/app.ts"]);
1941    }
1942
1943    #[test]
1944    fn extends_npm_chained_with_relative() {
1945        let dir = test_dir("npm-chained");
1946        // npm package extends a relative file inside itself
1947        let pkg_dir = dir.path().join("node_modules/my-config");
1948        std::fs::create_dir_all(&pkg_dir).unwrap();
1949        std::fs::write(
1950            pkg_dir.join("base.json"),
1951            r#"{"rules": {"unused-files": "warn"}}"#,
1952        )
1953        .unwrap();
1954        std::fs::write(
1955            pkg_dir.join(".fallowrc.json"),
1956            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1957        )
1958        .unwrap();
1959
1960        std::fs::write(
1961            dir.path().join(".fallowrc.json"),
1962            r#"{"extends": "npm:my-config"}"#,
1963        )
1964        .unwrap();
1965
1966        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1967        assert_eq!(config.rules.unused_files, Severity::Warn);
1968        assert_eq!(config.rules.unused_exports, Severity::Off);
1969    }
1970
1971    #[test]
1972    fn extends_npm_mixed_with_relative_paths() {
1973        let dir = test_dir("npm-mixed");
1974        create_npm_package(
1975            dir.path(),
1976            "shared-base",
1977            r#"{"rules": {"unused-files": "off"}}"#,
1978        );
1979        std::fs::write(
1980            dir.path().join("local-overrides.json"),
1981            r#"{"rules": {"unused-files": "warn"}}"#,
1982        )
1983        .unwrap();
1984        std::fs::write(
1985            dir.path().join(".fallowrc.json"),
1986            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1987        )
1988        .unwrap();
1989
1990        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1991        // local-overrides is later in the array, so it wins
1992        assert_eq!(config.rules.unused_files, Severity::Warn);
1993    }
1994
1995    #[test]
1996    fn extends_npm_missing_package_errors() {
1997        let dir = test_dir("npm-missing");
1998        std::fs::write(
1999            dir.path().join(".fallowrc.json"),
2000            r#"{"extends": "npm:nonexistent-package"}"#,
2001        )
2002        .unwrap();
2003
2004        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2005        assert!(result.is_err());
2006        let err_msg = format!("{}", result.unwrap_err());
2007        assert!(
2008            err_msg.contains("not found"),
2009            "Expected 'not found' error, got: {err_msg}"
2010        );
2011        assert!(
2012            err_msg.contains("nonexistent-package"),
2013            "Expected package name in error, got: {err_msg}"
2014        );
2015        assert!(
2016            err_msg.contains("install it"),
2017            "Expected install hint in error, got: {err_msg}"
2018        );
2019    }
2020
2021    #[test]
2022    fn extends_npm_no_config_in_package_errors() {
2023        let dir = test_dir("npm-no-config");
2024        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2025        std::fs::create_dir_all(&pkg_dir).unwrap();
2026        // Package exists but has no config files and no package.json
2027        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2028
2029        std::fs::write(
2030            dir.path().join(".fallowrc.json"),
2031            r#"{"extends": "npm:empty-pkg"}"#,
2032        )
2033        .unwrap();
2034
2035        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2036        assert!(result.is_err());
2037        let err_msg = format!("{}", result.unwrap_err());
2038        assert!(
2039            err_msg.contains("No fallow config found"),
2040            "Expected 'No fallow config found' error, got: {err_msg}"
2041        );
2042    }
2043
2044    #[test]
2045    fn extends_npm_missing_subpath_errors() {
2046        let dir = test_dir("npm-missing-sub");
2047        let pkg_dir = dir.path().join("node_modules/@co/config");
2048        std::fs::create_dir_all(&pkg_dir).unwrap();
2049
2050        std::fs::write(
2051            dir.path().join(".fallowrc.json"),
2052            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2053        )
2054        .unwrap();
2055
2056        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2057        assert!(result.is_err());
2058        let err_msg = format!("{}", result.unwrap_err());
2059        assert!(
2060            err_msg.contains("nonexistent.json"),
2061            "Expected subpath in error, got: {err_msg}"
2062        );
2063    }
2064
2065    #[test]
2066    fn extends_npm_empty_specifier_errors() {
2067        let dir = test_dir("npm-empty");
2068        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2069
2070        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2071        assert!(result.is_err());
2072        let err_msg = format!("{}", result.unwrap_err());
2073        assert!(
2074            err_msg.contains("Empty npm specifier"),
2075            "Expected 'Empty npm specifier' error, got: {err_msg}"
2076        );
2077    }
2078
2079    #[test]
2080    fn extends_npm_space_after_colon_trimmed() {
2081        let dir = test_dir("npm-space");
2082        create_npm_package(
2083            dir.path(),
2084            "fallow-config-acme",
2085            r#"{"rules": {"unused-files": "warn"}}"#,
2086        );
2087        // Space after npm: — should be trimmed and resolve correctly
2088        std::fs::write(
2089            dir.path().join(".fallowrc.json"),
2090            r#"{"extends": "npm: fallow-config-acme"}"#,
2091        )
2092        .unwrap();
2093
2094        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2095        assert_eq!(config.rules.unused_files, Severity::Warn);
2096    }
2097
2098    #[test]
2099    fn extends_npm_exports_node_condition() {
2100        let dir = test_dir("npm-node-cond");
2101        let pkg_dir = dir.path().join("node_modules/node-config");
2102        std::fs::create_dir_all(&pkg_dir).unwrap();
2103        std::fs::write(
2104            pkg_dir.join("package.json"),
2105            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2106        )
2107        .unwrap();
2108        std::fs::write(
2109            pkg_dir.join("node.json"),
2110            r#"{"rules": {"unused-files": "off"}}"#,
2111        )
2112        .unwrap();
2113
2114        std::fs::write(
2115            dir.path().join(".fallowrc.json"),
2116            r#"{"extends": "npm:node-config"}"#,
2117        )
2118        .unwrap();
2119
2120        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2121        assert_eq!(config.rules.unused_files, Severity::Off);
2122    }
2123
2124    // ── parse_npm_specifier unit tests ──────────────────────────────
2125
2126    #[test]
2127    fn parse_npm_specifier_unscoped() {
2128        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2129    }
2130
2131    #[test]
2132    fn parse_npm_specifier_unscoped_with_subpath() {
2133        assert_eq!(
2134            parse_npm_specifier("my-config/strict.json"),
2135            ("my-config", Some("strict.json"))
2136        );
2137    }
2138
2139    #[test]
2140    fn parse_npm_specifier_scoped() {
2141        assert_eq!(
2142            parse_npm_specifier("@company/fallow-config"),
2143            ("@company/fallow-config", None)
2144        );
2145    }
2146
2147    #[test]
2148    fn parse_npm_specifier_scoped_with_subpath() {
2149        assert_eq!(
2150            parse_npm_specifier("@company/fallow-config/strict.json"),
2151            ("@company/fallow-config", Some("strict.json"))
2152        );
2153    }
2154
2155    #[test]
2156    fn parse_npm_specifier_scoped_with_nested_subpath() {
2157        assert_eq!(
2158            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2159            ("@company/fallow-config", Some("presets/strict.json"))
2160        );
2161    }
2162
2163    // ── npm extends security tests ──────────────────────────────────
2164
2165    #[test]
2166    fn extends_npm_subpath_traversal_rejected() {
2167        let dir = test_dir("npm-traversal-sub");
2168        let pkg_dir = dir.path().join("node_modules/evil-pkg");
2169        std::fs::create_dir_all(&pkg_dir).unwrap();
2170        // Create a file outside the package that the traversal would reach
2171        std::fs::write(
2172            dir.path().join("secret.json"),
2173            r#"{"entry": ["stolen.ts"]}"#,
2174        )
2175        .unwrap();
2176
2177        std::fs::write(
2178            dir.path().join(".fallowrc.json"),
2179            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2180        )
2181        .unwrap();
2182
2183        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2184        assert!(result.is_err());
2185        let err_msg = format!("{}", result.unwrap_err());
2186        assert!(
2187            err_msg.contains("traversal") || err_msg.contains("not found"),
2188            "Expected traversal or not-found error, got: {err_msg}"
2189        );
2190    }
2191
2192    #[test]
2193    fn extends_npm_dotdot_package_name_rejected() {
2194        let dir = test_dir("npm-dotdot-name");
2195        std::fs::write(
2196            dir.path().join(".fallowrc.json"),
2197            r#"{"extends": "npm:../relative"}"#,
2198        )
2199        .unwrap();
2200
2201        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2202        assert!(result.is_err());
2203        let err_msg = format!("{}", result.unwrap_err());
2204        assert!(
2205            err_msg.contains("path traversal"),
2206            "Expected 'path traversal' error, got: {err_msg}"
2207        );
2208    }
2209
2210    #[test]
2211    fn extends_npm_scoped_without_name_rejected() {
2212        let dir = test_dir("npm-scope-only");
2213        std::fs::write(
2214            dir.path().join(".fallowrc.json"),
2215            r#"{"extends": "npm:@scope"}"#,
2216        )
2217        .unwrap();
2218
2219        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2220        assert!(result.is_err());
2221        let err_msg = format!("{}", result.unwrap_err());
2222        assert!(
2223            err_msg.contains("@scope/name"),
2224            "Expected scoped name format error, got: {err_msg}"
2225        );
2226    }
2227
2228    #[test]
2229    fn extends_npm_malformed_package_json_errors() {
2230        let dir = test_dir("npm-bad-pkgjson");
2231        let pkg_dir = dir.path().join("node_modules/bad-pkg");
2232        std::fs::create_dir_all(&pkg_dir).unwrap();
2233        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2234
2235        std::fs::write(
2236            dir.path().join(".fallowrc.json"),
2237            r#"{"extends": "npm:bad-pkg"}"#,
2238        )
2239        .unwrap();
2240
2241        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2242        assert!(result.is_err());
2243        let err_msg = format!("{}", result.unwrap_err());
2244        assert!(
2245            err_msg.contains("Failed to parse"),
2246            "Expected parse error, got: {err_msg}"
2247        );
2248    }
2249
2250    #[test]
2251    fn extends_npm_exports_traversal_rejected() {
2252        let dir = test_dir("npm-exports-escape");
2253        let pkg_dir = dir.path().join("node_modules/evil-exports");
2254        std::fs::create_dir_all(&pkg_dir).unwrap();
2255        std::fs::write(
2256            pkg_dir.join("package.json"),
2257            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2258        )
2259        .unwrap();
2260        // Create the target file outside the package
2261        std::fs::write(
2262            dir.path().join("secret.json"),
2263            r#"{"entry": ["stolen.ts"]}"#,
2264        )
2265        .unwrap();
2266
2267        std::fs::write(
2268            dir.path().join(".fallowrc.json"),
2269            r#"{"extends": "npm:evil-exports"}"#,
2270        )
2271        .unwrap();
2272
2273        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2274        assert!(result.is_err());
2275        let err_msg = format!("{}", result.unwrap_err());
2276        assert!(
2277            err_msg.contains("traversal"),
2278            "Expected traversal error, got: {err_msg}"
2279        );
2280    }
2281
2282    // ── deep_merge_json unit tests ───────────────────────────────────
2283
2284    #[test]
2285    fn deep_merge_scalar_overlay_replaces_base() {
2286        let mut base = serde_json::json!("hello");
2287        deep_merge_json(&mut base, serde_json::json!("world"));
2288        assert_eq!(base, serde_json::json!("world"));
2289    }
2290
2291    #[test]
2292    fn deep_merge_array_overlay_replaces_base() {
2293        let mut base = serde_json::json!(["a", "b"]);
2294        deep_merge_json(&mut base, serde_json::json!(["c"]));
2295        assert_eq!(base, serde_json::json!(["c"]));
2296    }
2297
2298    #[test]
2299    fn deep_merge_nested_object_merge() {
2300        let mut base = serde_json::json!({
2301            "level1": {
2302                "level2": {
2303                    "a": 1,
2304                    "b": 2
2305                }
2306            }
2307        });
2308        let overlay = serde_json::json!({
2309            "level1": {
2310                "level2": {
2311                    "b": 99,
2312                    "c": 3
2313                }
2314            }
2315        });
2316        deep_merge_json(&mut base, overlay);
2317        assert_eq!(base["level1"]["level2"]["a"], 1);
2318        assert_eq!(base["level1"]["level2"]["b"], 99);
2319        assert_eq!(base["level1"]["level2"]["c"], 3);
2320    }
2321
2322    #[test]
2323    fn deep_merge_overlay_adds_new_fields() {
2324        let mut base = serde_json::json!({"existing": true});
2325        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2326        deep_merge_json(&mut base, overlay);
2327        assert_eq!(base["existing"], true);
2328        assert_eq!(base["new_field"], "added");
2329        assert_eq!(base["another"], 42);
2330    }
2331
2332    #[test]
2333    fn deep_merge_null_overlay_replaces_object() {
2334        let mut base = serde_json::json!({"key": "value"});
2335        deep_merge_json(&mut base, serde_json::json!(null));
2336        assert_eq!(base, serde_json::json!(null));
2337    }
2338
2339    #[test]
2340    fn deep_merge_empty_object_overlay_preserves_base() {
2341        let mut base = serde_json::json!({"a": 1, "b": 2});
2342        deep_merge_json(&mut base, serde_json::json!({}));
2343        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2344    }
2345
2346    // ── rule severity parsing via JSON config ────────────────────────
2347
2348    #[test]
2349    fn rules_severity_error_warn_off_from_json() {
2350        let json_str = r#"{
2351            "rules": {
2352                "unused-files": "error",
2353                "unused-exports": "warn",
2354                "unused-types": "off"
2355            }
2356        }"#;
2357        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2358        assert_eq!(config.rules.unused_files, Severity::Error);
2359        assert_eq!(config.rules.unused_exports, Severity::Warn);
2360        assert_eq!(config.rules.unused_types, Severity::Off);
2361    }
2362
2363    #[test]
2364    fn rules_omitted_default_to_error() {
2365        let json_str = r#"{
2366            "rules": {
2367                "unused-files": "warn"
2368            }
2369        }"#;
2370        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2371        assert_eq!(config.rules.unused_files, Severity::Warn);
2372        // All other rules default to error
2373        assert_eq!(config.rules.unused_exports, Severity::Error);
2374        assert_eq!(config.rules.unused_types, Severity::Error);
2375        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2376        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2377        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2378        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2379        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2380        // type_only_dependencies defaults to warn, not error
2381        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2382    }
2383
2384    // ── find_and_load tests ───────────────────────────────────────
2385
2386    #[test]
2387    fn find_and_load_returns_none_when_no_config() {
2388        let dir = test_dir("find-none");
2389        // Create a .git dir so it stops searching
2390        std::fs::create_dir(dir.path().join(".git")).unwrap();
2391
2392        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2393        assert!(result.is_none());
2394    }
2395
2396    #[test]
2397    fn find_and_load_finds_fallowrc_json() {
2398        let dir = test_dir("find-json");
2399        std::fs::create_dir(dir.path().join(".git")).unwrap();
2400        std::fs::write(
2401            dir.path().join(".fallowrc.json"),
2402            r#"{"entry": ["src/main.ts"]}"#,
2403        )
2404        .unwrap();
2405
2406        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2407        assert_eq!(config.entry, vec!["src/main.ts"]);
2408        assert!(path.ends_with(".fallowrc.json"));
2409    }
2410
2411    #[test]
2412    fn find_and_load_finds_fallowrc_jsonc() {
2413        let dir = test_dir("find-jsonc");
2414        std::fs::create_dir(dir.path().join(".git")).unwrap();
2415        std::fs::write(
2416            dir.path().join(".fallowrc.jsonc"),
2417            r#"{
2418                // jsonc with comments, picked up by auto-discovery
2419                "entry": ["src/main.ts"]
2420            }"#,
2421        )
2422        .unwrap();
2423
2424        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2425        assert_eq!(config.entry, vec!["src/main.ts"]);
2426        assert!(path.ends_with(".fallowrc.jsonc"));
2427    }
2428
2429    #[test]
2430    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2431        // First-match-wins: `.fallowrc.json` ranks above `.fallowrc.jsonc`
2432        // in `CONFIG_NAMES`, mirroring tsconfig.json > tsconfig.jsonc precedence.
2433        let dir = test_dir("find-json-vs-jsonc");
2434        std::fs::create_dir(dir.path().join(".git")).unwrap();
2435        std::fs::write(
2436            dir.path().join(".fallowrc.json"),
2437            r#"{"entry": ["from-json.ts"]}"#,
2438        )
2439        .unwrap();
2440        std::fs::write(
2441            dir.path().join(".fallowrc.jsonc"),
2442            r#"{"entry": ["from-jsonc.ts"]}"#,
2443        )
2444        .unwrap();
2445
2446        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2447        assert_eq!(config.entry, vec!["from-json.ts"]);
2448        assert!(path.ends_with(".fallowrc.json"));
2449    }
2450
2451    #[test]
2452    fn find_and_load_prefers_fallowrc_json_over_toml() {
2453        let dir = test_dir("find-priority");
2454        std::fs::create_dir(dir.path().join(".git")).unwrap();
2455        std::fs::write(
2456            dir.path().join(".fallowrc.json"),
2457            r#"{"entry": ["from-json.ts"]}"#,
2458        )
2459        .unwrap();
2460        std::fs::write(
2461            dir.path().join("fallow.toml"),
2462            "entry = [\"from-toml.ts\"]\n",
2463        )
2464        .unwrap();
2465
2466        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2467        assert_eq!(config.entry, vec!["from-json.ts"]);
2468        assert!(path.ends_with(".fallowrc.json"));
2469    }
2470
2471    #[test]
2472    fn find_and_load_finds_fallow_toml() {
2473        let dir = test_dir("find-toml");
2474        std::fs::create_dir(dir.path().join(".git")).unwrap();
2475        std::fs::write(
2476            dir.path().join("fallow.toml"),
2477            "entry = [\"src/index.ts\"]\n",
2478        )
2479        .unwrap();
2480
2481        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2482        assert_eq!(config.entry, vec!["src/index.ts"]);
2483    }
2484
2485    #[test]
2486    fn find_and_load_stops_at_git_dir() {
2487        let dir = test_dir("find-git-stop");
2488        let sub = dir.path().join("sub");
2489        std::fs::create_dir(&sub).unwrap();
2490        // .git marker in root stops search
2491        std::fs::create_dir(dir.path().join(".git")).unwrap();
2492        // Config file above .git should not be found from sub
2493        // (sub has no .git or package.json, so it keeps searching up to parent)
2494        // But parent has .git, so it stops there without finding config
2495        let result = FallowConfig::find_and_load(&sub).unwrap();
2496        assert!(result.is_none());
2497    }
2498
2499    #[test]
2500    fn find_and_load_walks_past_package_json_in_monorepo() {
2501        // Simulate a pnpm/npm/yarn workspace: root has `.git` + `.fallowrc.json`,
2502        // sub-package has its own `package.json`. Config search from the
2503        // sub-package must walk past its `package.json` and find the root config.
2504        let dir = test_dir("find-monorepo");
2505        std::fs::create_dir(dir.path().join(".git")).unwrap();
2506        std::fs::write(
2507            dir.path().join(".fallowrc.json"),
2508            r#"{"entry": ["src/index.ts"]}"#,
2509        )
2510        .unwrap();
2511
2512        let sub = dir.path().join("packages").join("app");
2513        std::fs::create_dir_all(&sub).unwrap();
2514        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2515
2516        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2517        assert_eq!(config.entry, vec!["src/index.ts"]);
2518        assert_eq!(path, dir.path().join(".fallowrc.json"));
2519    }
2520
2521    #[test]
2522    fn find_and_load_sub_package_config_wins_over_root() {
2523        // Regression guard: if a monorepo sub-package has its own config,
2524        // it must be preferred over the root config (first-match-wins).
2525        let dir = test_dir("find-monorepo-override");
2526        std::fs::create_dir(dir.path().join(".git")).unwrap();
2527        std::fs::write(
2528            dir.path().join(".fallowrc.json"),
2529            r#"{"entry": ["src/root.ts"]}"#,
2530        )
2531        .unwrap();
2532
2533        let sub = dir.path().join("packages").join("app");
2534        std::fs::create_dir_all(&sub).unwrap();
2535        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2536        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2537
2538        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2539        assert_eq!(config.entry, vec!["src/sub.ts"]);
2540        assert_eq!(path, sub.join(".fallowrc.json"));
2541    }
2542
2543    #[test]
2544    fn find_and_load_stops_at_git_file_submodule() {
2545        // Git submodules / worktrees have `.git` as a file (not a directory)
2546        // pointing to the real gitdir. `.exists()` matches both, so submodule
2547        // roots correctly stop the walk — config in the parent repo should
2548        // NOT leak into a vendored submodule.
2549        let dir = test_dir("find-git-file");
2550        std::fs::create_dir(dir.path().join(".git")).unwrap();
2551        std::fs::write(
2552            dir.path().join(".fallowrc.json"),
2553            r#"{"entry": ["src/parent.ts"]}"#,
2554        )
2555        .unwrap();
2556
2557        let submodule = dir.path().join("vendor").join("lib");
2558        std::fs::create_dir_all(&submodule).unwrap();
2559        // Simulate submodule: `.git` as a file pointing to parent's .git/modules
2560        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2561
2562        let result = FallowConfig::find_and_load(&submodule).unwrap();
2563        assert!(
2564            result.is_none(),
2565            "submodule boundary should stop config walk",
2566        );
2567    }
2568
2569    #[test]
2570    fn find_and_load_stops_at_hg_dir() {
2571        let dir = test_dir("find-hg-stop");
2572        let sub = dir.path().join("sub");
2573        std::fs::create_dir(&sub).unwrap();
2574        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2575
2576        let result = FallowConfig::find_and_load(&sub).unwrap();
2577        assert!(result.is_none());
2578    }
2579
2580    #[test]
2581    fn find_and_load_returns_error_for_invalid_config() {
2582        let dir = test_dir("find-invalid");
2583        std::fs::create_dir(dir.path().join(".git")).unwrap();
2584        std::fs::write(
2585            dir.path().join(".fallowrc.json"),
2586            r"{ this is not valid json }",
2587        )
2588        .unwrap();
2589
2590        let result = FallowConfig::find_and_load(dir.path());
2591        assert!(result.is_err());
2592    }
2593
2594    // ── load TOML config file ────────────────────────────────────
2595
2596    #[test]
2597    fn load_toml_config_file() {
2598        let dir = test_dir("toml-config");
2599        let config_path = dir.path().join("fallow.toml");
2600        std::fs::write(
2601            &config_path,
2602            r#"
2603entry = ["src/index.ts"]
2604ignorePatterns = ["dist/**"]
2605
2606[rules]
2607unused-files = "warn"
2608
2609[duplicates]
2610minTokens = 100
2611"#,
2612        )
2613        .unwrap();
2614
2615        let config = FallowConfig::load(&config_path).unwrap();
2616        assert_eq!(config.entry, vec!["src/index.ts"]);
2617        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2618        assert_eq!(config.rules.unused_files, Severity::Warn);
2619        assert_eq!(config.duplicates.min_tokens, 100);
2620    }
2621
2622    // ── extends absolute path rejection ──────────────────────────
2623
2624    #[test]
2625    fn extends_absolute_path_rejected() {
2626        let dir = test_dir("extends-absolute");
2627
2628        // Use a platform-appropriate absolute path
2629        #[cfg(unix)]
2630        let abs_path = "/absolute/path/config.json";
2631        #[cfg(windows)]
2632        let abs_path = "C:\\absolute\\path\\config.json";
2633
2634        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2635        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2636
2637        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2638        assert!(result.is_err());
2639        let err_msg = format!("{}", result.unwrap_err());
2640        assert!(
2641            err_msg.contains("must be relative"),
2642            "Expected 'must be relative' error, got: {err_msg}"
2643        );
2644    }
2645
2646    // ── resolve production mode ─────────────────────────────────
2647
2648    #[test]
2649    fn resolve_production_mode_disables_dev_deps() {
2650        let config = FallowConfig {
2651            production: true.into(),
2652            ..Default::default()
2653        };
2654        let resolved = config.resolve(
2655            PathBuf::from("/tmp/test"),
2656            OutputFormat::Human,
2657            4,
2658            false,
2659            true,
2660            None,
2661        );
2662        assert!(resolved.production);
2663        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2664        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2665        // Other rules should remain at default (Error)
2666        assert_eq!(resolved.rules.unused_files, Severity::Error);
2667        assert_eq!(resolved.rules.unused_exports, Severity::Error);
2668    }
2669
2670    // ── include-entry-exports config support (issue #249) ──────
2671
2672    #[test]
2673    fn include_entry_exports_deserializes_from_camelcase_json() {
2674        let json = r#"{ "includeEntryExports": true }"#;
2675        let config: FallowConfig = serde_json::from_str(json).unwrap();
2676        assert!(config.include_entry_exports);
2677    }
2678
2679    #[test]
2680    fn include_entry_exports_deserializes_from_camelcase_toml() {
2681        let toml_str = "includeEntryExports = true\n";
2682        let config: FallowConfig = toml::from_str(toml_str).unwrap();
2683        assert!(config.include_entry_exports);
2684    }
2685
2686    #[test]
2687    fn include_entry_exports_default_is_false() {
2688        let config: FallowConfig = serde_json::from_str("{}").unwrap();
2689        assert!(!config.include_entry_exports);
2690    }
2691
2692    #[test]
2693    fn include_entry_exports_propagates_through_resolve() {
2694        let config = FallowConfig {
2695            include_entry_exports: true,
2696            cache: CacheConfig::default(),
2697            ..Default::default()
2698        };
2699        let resolved = config.resolve(
2700            PathBuf::from("/tmp/test"),
2701            OutputFormat::Human,
2702            1,
2703            true,
2704            true,
2705            None,
2706        );
2707        assert!(resolved.include_entry_exports);
2708    }
2709
2710    // ── config format fallback to TOML for unknown extensions ───
2711
2712    #[test]
2713    fn config_format_defaults_to_toml_for_unknown() {
2714        assert!(matches!(
2715            ConfigFormat::from_path(Path::new("config.yaml")),
2716            ConfigFormat::Toml
2717        ));
2718        assert!(matches!(
2719            ConfigFormat::from_path(Path::new("config")),
2720            ConfigFormat::Toml
2721        ));
2722    }
2723
2724    // ── deep_merge type coercion ─────────────────────────────────
2725
2726    #[test]
2727    fn deep_merge_object_over_scalar_replaces() {
2728        let mut base = serde_json::json!("just a string");
2729        let overlay = serde_json::json!({"key": "value"});
2730        deep_merge_json(&mut base, overlay);
2731        assert_eq!(base, serde_json::json!({"key": "value"}));
2732    }
2733
2734    #[test]
2735    fn deep_merge_scalar_over_object_replaces() {
2736        let mut base = serde_json::json!({"key": "value"});
2737        let overlay = serde_json::json!(42);
2738        deep_merge_json(&mut base, overlay);
2739        assert_eq!(base, serde_json::json!(42));
2740    }
2741
2742    // ── extends with non-string/array extends field ──────────────
2743
2744    #[test]
2745    fn extends_non_string_non_array_ignored() {
2746        let dir = test_dir("extends-numeric");
2747        std::fs::write(
2748            dir.path().join(".fallowrc.json"),
2749            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2750        )
2751        .unwrap();
2752
2753        // extends=42 is neither string nor array, so it's treated as no extends
2754        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2755        assert_eq!(config.entry, vec!["src/index.ts"]);
2756    }
2757
2758    // ── extends with multiple bases (later overrides earlier) ────
2759
2760    #[test]
2761    fn extends_multiple_bases_later_wins() {
2762        let dir = test_dir("extends-multi-base");
2763
2764        std::fs::write(
2765            dir.path().join("base-a.json"),
2766            r#"{"rules": {"unused-files": "warn"}}"#,
2767        )
2768        .unwrap();
2769        std::fs::write(
2770            dir.path().join("base-b.json"),
2771            r#"{"rules": {"unused-files": "off"}}"#,
2772        )
2773        .unwrap();
2774        std::fs::write(
2775            dir.path().join(".fallowrc.json"),
2776            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2777        )
2778        .unwrap();
2779
2780        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2781        // base-b is later in the array, so its value should win
2782        assert_eq!(config.rules.unused_files, Severity::Off);
2783    }
2784
2785    // ── config with production flag ──────────────────────────────
2786
2787    #[test]
2788    fn fallow_config_deserialize_production() {
2789        let json_str = r#"{"production": true}"#;
2790        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2791        assert!(config.production);
2792    }
2793
2794    #[test]
2795    fn fallow_config_production_defaults_false() {
2796        let config: FallowConfig = serde_json::from_str("{}").unwrap();
2797        assert!(!config.production);
2798    }
2799
2800    // ── optional dependency names ────────────────────────────────
2801
2802    #[test]
2803    fn package_json_optional_dependency_names() {
2804        let pkg: PackageJson = serde_json::from_str(
2805            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2806        )
2807        .unwrap();
2808        let opt = pkg.optional_dependency_names();
2809        assert_eq!(opt.len(), 2);
2810        assert!(opt.contains(&"fsevents".to_string()));
2811        assert!(opt.contains(&"chokidar".to_string()));
2812    }
2813
2814    #[test]
2815    fn package_json_optional_deps_empty_when_missing() {
2816        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2817        assert!(pkg.optional_dependency_names().is_empty());
2818    }
2819
2820    // ── find_config_path ────────────────────────────────────────────
2821
2822    #[test]
2823    fn find_config_path_returns_fallowrc_json() {
2824        let dir = test_dir("find-path-json");
2825        std::fs::create_dir(dir.path().join(".git")).unwrap();
2826        std::fs::write(
2827            dir.path().join(".fallowrc.json"),
2828            r#"{"entry": ["src/main.ts"]}"#,
2829        )
2830        .unwrap();
2831
2832        let path = FallowConfig::find_config_path(dir.path());
2833        assert!(path.is_some());
2834        assert!(path.unwrap().ends_with(".fallowrc.json"));
2835    }
2836
2837    #[test]
2838    fn find_config_path_returns_fallow_toml() {
2839        let dir = test_dir("find-path-toml");
2840        std::fs::create_dir(dir.path().join(".git")).unwrap();
2841        std::fs::write(
2842            dir.path().join("fallow.toml"),
2843            "entry = [\"src/main.ts\"]\n",
2844        )
2845        .unwrap();
2846
2847        let path = FallowConfig::find_config_path(dir.path());
2848        assert!(path.is_some());
2849        assert!(path.unwrap().ends_with("fallow.toml"));
2850    }
2851
2852    #[test]
2853    fn find_config_path_returns_dot_fallow_toml() {
2854        let dir = test_dir("find-path-dot-toml");
2855        std::fs::create_dir(dir.path().join(".git")).unwrap();
2856        std::fs::write(
2857            dir.path().join(".fallow.toml"),
2858            "entry = [\"src/main.ts\"]\n",
2859        )
2860        .unwrap();
2861
2862        let path = FallowConfig::find_config_path(dir.path());
2863        assert!(path.is_some());
2864        assert!(path.unwrap().ends_with(".fallow.toml"));
2865    }
2866
2867    #[test]
2868    fn find_config_path_prefers_json_over_toml() {
2869        let dir = test_dir("find-path-priority");
2870        std::fs::create_dir(dir.path().join(".git")).unwrap();
2871        std::fs::write(
2872            dir.path().join(".fallowrc.json"),
2873            r#"{"entry": ["json.ts"]}"#,
2874        )
2875        .unwrap();
2876        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2877
2878        let path = FallowConfig::find_config_path(dir.path());
2879        assert!(path.unwrap().ends_with(".fallowrc.json"));
2880    }
2881
2882    #[test]
2883    fn find_config_path_none_when_no_config() {
2884        let dir = test_dir("find-path-none");
2885        std::fs::create_dir(dir.path().join(".git")).unwrap();
2886
2887        let path = FallowConfig::find_config_path(dir.path());
2888        assert!(path.is_none());
2889    }
2890
2891    #[test]
2892    fn find_config_path_walks_past_package_json_in_monorepo() {
2893        let dir = test_dir("find-path-monorepo");
2894        std::fs::create_dir(dir.path().join(".git")).unwrap();
2895        std::fs::write(
2896            dir.path().join(".fallowrc.json"),
2897            r#"{"entry": ["src/index.ts"]}"#,
2898        )
2899        .unwrap();
2900
2901        let sub = dir.path().join("packages").join("app");
2902        std::fs::create_dir_all(&sub).unwrap();
2903        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2904
2905        let path = FallowConfig::find_config_path(&sub).unwrap();
2906        assert_eq!(path, dir.path().join(".fallowrc.json"));
2907    }
2908
2909    // ── TOML extends support ────────────────────────────────────────
2910
2911    #[test]
2912    fn extends_toml_base() {
2913        let dir = test_dir("extends-toml");
2914
2915        std::fs::write(
2916            dir.path().join("base.json"),
2917            r#"{"rules": {"unused-files": "warn"}}"#,
2918        )
2919        .unwrap();
2920        std::fs::write(
2921            dir.path().join("fallow.toml"),
2922            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2923        )
2924        .unwrap();
2925
2926        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2927        assert_eq!(config.rules.unused_files, Severity::Warn);
2928        assert_eq!(config.entry, vec!["src/index.ts"]);
2929    }
2930
2931    // ── deep_merge_json edge cases ──────────────────────────────────
2932
2933    #[test]
2934    fn deep_merge_boolean_overlay() {
2935        let mut base = serde_json::json!(true);
2936        deep_merge_json(&mut base, serde_json::json!(false));
2937        assert_eq!(base, serde_json::json!(false));
2938    }
2939
2940    #[test]
2941    fn deep_merge_number_overlay() {
2942        let mut base = serde_json::json!(42);
2943        deep_merge_json(&mut base, serde_json::json!(99));
2944        assert_eq!(base, serde_json::json!(99));
2945    }
2946
2947    #[test]
2948    fn deep_merge_disjoint_objects() {
2949        let mut base = serde_json::json!({"a": 1});
2950        let overlay = serde_json::json!({"b": 2});
2951        deep_merge_json(&mut base, overlay);
2952        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2953    }
2954
2955    // ── MAX_EXTENDS_DEPTH constant ──────────────────────────────────
2956
2957    #[test]
2958    fn max_extends_depth_is_reasonable() {
2959        assert_eq!(MAX_EXTENDS_DEPTH, 10);
2960    }
2961
2962    // ── Config names constant ───────────────────────────────────────
2963
2964    #[test]
2965    fn config_names_has_four_entries() {
2966        assert_eq!(CONFIG_NAMES.len(), 4);
2967        // All names should start with "." or "fallow"
2968        for name in CONFIG_NAMES {
2969            assert!(
2970                name.starts_with('.') || name.starts_with("fallow"),
2971                "unexpected config name: {name}"
2972            );
2973        }
2974    }
2975
2976    // ── package.json peer dependency names ───────────────────────────
2977
2978    #[test]
2979    fn package_json_peer_dependency_names() {
2980        let pkg: PackageJson = serde_json::from_str(
2981            r#"{
2982            "dependencies": {"react": "^18"},
2983            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2984        }"#,
2985        )
2986        .unwrap();
2987        let all = pkg.all_dependency_names();
2988        assert!(all.contains(&"react".to_string()));
2989        assert!(all.contains(&"react-dom".to_string()));
2990        assert!(all.contains(&"react-native".to_string()));
2991    }
2992
2993    // ── package.json scripts field ──────────────────────────────────
2994
2995    #[test]
2996    fn package_json_scripts_field() {
2997        let pkg: PackageJson = serde_json::from_str(
2998            r#"{
2999            "scripts": {
3000                "build": "tsc",
3001                "test": "vitest",
3002                "lint": "fallow check"
3003            }
3004        }"#,
3005        )
3006        .unwrap();
3007        let scripts = pkg.scripts.unwrap();
3008        assert_eq!(scripts.len(), 3);
3009        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3010        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3011    }
3012
3013    // ── Extends with TOML-to-TOML chain ─────────────────────────────
3014
3015    #[test]
3016    fn extends_toml_chain() {
3017        let dir = test_dir("extends-toml-chain");
3018
3019        std::fs::write(
3020            dir.path().join("base.json"),
3021            r#"{"entry": ["src/base.ts"]}"#,
3022        )
3023        .unwrap();
3024        std::fs::write(
3025            dir.path().join("middle.json"),
3026            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3027        )
3028        .unwrap();
3029        std::fs::write(
3030            dir.path().join("fallow.toml"),
3031            "extends = [\"middle.json\"]\n",
3032        )
3033        .unwrap();
3034
3035        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3036        assert_eq!(config.entry, vec!["src/base.ts"]);
3037        assert_eq!(config.rules.unused_files, Severity::Off);
3038    }
3039
3040    // ── find_and_load walks up to parent ────────────────────────────
3041
3042    #[test]
3043    fn find_and_load_walks_up_directories() {
3044        let dir = test_dir("find-walk-up");
3045        let sub = dir.path().join("src").join("deep");
3046        std::fs::create_dir_all(&sub).unwrap();
3047        std::fs::write(
3048            dir.path().join(".fallowrc.json"),
3049            r#"{"entry": ["src/main.ts"]}"#,
3050        )
3051        .unwrap();
3052        // Create .git in root to stop search there
3053        std::fs::create_dir(dir.path().join(".git")).unwrap();
3054
3055        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3056        assert_eq!(config.entry, vec!["src/main.ts"]);
3057        assert!(path.ends_with(".fallowrc.json"));
3058    }
3059
3060    // ── JSON schema generation ──────────────────────────────────────
3061
3062    #[test]
3063    fn json_schema_contains_entry_field() {
3064        let schema = FallowConfig::json_schema();
3065        let obj = schema.as_object().unwrap();
3066        let props = obj.get("properties").and_then(|v| v.as_object());
3067        assert!(props.is_some(), "schema should have properties");
3068        assert!(
3069            props.unwrap().contains_key("entry"),
3070            "schema should contain entry property"
3071        );
3072    }
3073
3074    // ── Duplicates config via JSON in FallowConfig ──────────────────
3075
3076    #[test]
3077    fn fallow_config_json_duplicates_all_fields() {
3078        let json = r#"{
3079            "duplicates": {
3080                "enabled": true,
3081                "mode": "semantic",
3082                "minTokens": 200,
3083                "minLines": 20,
3084                "threshold": 10.5,
3085                "ignore": ["**/*.test.ts"],
3086                "skipLocal": true,
3087                "crossLanguage": true,
3088                "normalization": {
3089                    "ignoreIdentifiers": true,
3090                    "ignoreStringValues": false
3091                }
3092            }
3093        }"#;
3094        let config: FallowConfig = serde_json::from_str(json).unwrap();
3095        assert!(config.duplicates.enabled);
3096        assert_eq!(
3097            config.duplicates.mode,
3098            crate::config::DetectionMode::Semantic
3099        );
3100        assert_eq!(config.duplicates.min_tokens, 200);
3101        assert_eq!(config.duplicates.min_lines, 20);
3102        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3103        assert!(config.duplicates.skip_local);
3104        assert!(config.duplicates.cross_language);
3105        assert_eq!(
3106            config.duplicates.normalization.ignore_identifiers,
3107            Some(true)
3108        );
3109        assert_eq!(
3110            config.duplicates.normalization.ignore_string_values,
3111            Some(false)
3112        );
3113    }
3114
3115    // ── URL extends tests ───────────────────────────────────────────
3116
3117    #[test]
3118    fn normalize_url_basic() {
3119        assert_eq!(
3120            normalize_url_for_dedup("https://example.com/config.json"),
3121            "https://example.com/config.json"
3122        );
3123    }
3124
3125    #[test]
3126    fn normalize_url_trailing_slash() {
3127        assert_eq!(
3128            normalize_url_for_dedup("https://example.com/config/"),
3129            "https://example.com/config"
3130        );
3131    }
3132
3133    #[test]
3134    fn normalize_url_uppercase_scheme_and_host() {
3135        assert_eq!(
3136            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3137            "https://example.com/Config.json"
3138        );
3139    }
3140
3141    #[test]
3142    fn normalize_url_root_path() {
3143        assert_eq!(
3144            normalize_url_for_dedup("https://example.com/"),
3145            "https://example.com"
3146        );
3147        assert_eq!(
3148            normalize_url_for_dedup("https://example.com"),
3149            "https://example.com"
3150        );
3151    }
3152
3153    #[test]
3154    fn normalize_url_preserves_path_case() {
3155        // Path component casing is significant (server-dependent), only scheme+host lowercase.
3156        assert_eq!(
3157            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3158            "https://github.com/Org/Repo/Fallow.json"
3159        );
3160    }
3161
3162    #[test]
3163    fn normalize_url_strips_query_string() {
3164        assert_eq!(
3165            normalize_url_for_dedup("https://example.com/config.json?v=1"),
3166            "https://example.com/config.json"
3167        );
3168    }
3169
3170    #[test]
3171    fn normalize_url_strips_fragment() {
3172        assert_eq!(
3173            normalize_url_for_dedup("https://example.com/config.json#section"),
3174            "https://example.com/config.json"
3175        );
3176    }
3177
3178    #[test]
3179    fn normalize_url_strips_query_and_fragment() {
3180        assert_eq!(
3181            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3182            "https://example.com/config.json"
3183        );
3184    }
3185
3186    #[test]
3187    fn normalize_url_default_https_port() {
3188        assert_eq!(
3189            normalize_url_for_dedup("https://example.com:443/config.json"),
3190            "https://example.com/config.json"
3191        );
3192        // Non-default port is preserved.
3193        assert_eq!(
3194            normalize_url_for_dedup("https://example.com:8443/config.json"),
3195            "https://example.com:8443/config.json"
3196        );
3197    }
3198
3199    #[test]
3200    fn extends_http_rejected() {
3201        let dir = test_dir("http-rejected");
3202        std::fs::write(
3203            dir.path().join(".fallowrc.json"),
3204            r#"{"extends": "http://example.com/config.json"}"#,
3205        )
3206        .unwrap();
3207
3208        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3209        assert!(result.is_err());
3210        let err_msg = format!("{}", result.unwrap_err());
3211        assert!(
3212            err_msg.contains("https://"),
3213            "Expected https hint in error, got: {err_msg}"
3214        );
3215        assert!(
3216            err_msg.contains("http://"),
3217            "Expected http:// mention in error, got: {err_msg}"
3218        );
3219    }
3220
3221    #[test]
3222    fn extends_url_circular_detection() {
3223        // Verify that the same URL appearing twice in the visited set is detected.
3224        let mut visited = FxHashSet::default();
3225        let url = "https://example.com/config.json";
3226        let normalized = normalize_url_for_dedup(url);
3227        visited.insert(normalized.clone());
3228
3229        // Inserting the same normalized URL should return false.
3230        assert!(
3231            !visited.insert(normalized),
3232            "Same URL should be detected as duplicate"
3233        );
3234    }
3235
3236    #[test]
3237    fn extends_url_circular_case_insensitive() {
3238        // URLs differing only in scheme/host casing should be detected as circular.
3239        let mut visited = FxHashSet::default();
3240        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3241
3242        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3243        assert!(
3244            !visited.insert(normalized),
3245            "Case-different URLs should normalize to the same key"
3246        );
3247    }
3248
3249    #[test]
3250    fn extract_extends_array() {
3251        let mut value = serde_json::json!({
3252            "extends": ["a.json", "b.json"],
3253            "entry": ["src/index.ts"]
3254        });
3255        let extends = extract_extends(&mut value);
3256        assert_eq!(extends, vec!["a.json", "b.json"]);
3257        // extends should be removed from the value.
3258        assert!(value.get("extends").is_none());
3259        assert!(value.get("entry").is_some());
3260    }
3261
3262    #[test]
3263    fn extract_extends_string_sugar() {
3264        let mut value = serde_json::json!({
3265            "extends": "base.json",
3266            "entry": ["src/index.ts"]
3267        });
3268        let extends = extract_extends(&mut value);
3269        assert_eq!(extends, vec!["base.json"]);
3270    }
3271
3272    #[test]
3273    fn extract_extends_none() {
3274        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3275        let extends = extract_extends(&mut value);
3276        assert!(extends.is_empty());
3277    }
3278
3279    #[test]
3280    fn url_timeout_default() {
3281        // Without the env var set, should return the default.
3282        let timeout = url_timeout();
3283        // We can't assert exact value since the env var might be set in the test environment,
3284        // but we can assert it's a reasonable duration.
3285        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3286    }
3287
3288    #[test]
3289    fn extends_url_mixed_with_file_and_npm() {
3290        // Test that a config with a mix of file, npm, and URL extends parses correctly
3291        // for the non-URL parts, and produces a clear error for the URL part (no server).
3292        let dir = test_dir("url-mixed");
3293        std::fs::write(
3294            dir.path().join("local.json"),
3295            r#"{"rules": {"unused-files": "warn"}}"#,
3296        )
3297        .unwrap();
3298        std::fs::write(
3299            dir.path().join(".fallowrc.json"),
3300            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3301        )
3302        .unwrap();
3303
3304        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3305        assert!(result.is_err());
3306        let err_msg = format!("{}", result.unwrap_err());
3307        assert!(
3308            err_msg.contains("unreachable.invalid"),
3309            "Expected URL in error message, got: {err_msg}"
3310        );
3311    }
3312
3313    #[test]
3314    fn extends_https_url_unreachable_errors() {
3315        let dir = test_dir("url-unreachable");
3316        std::fs::write(
3317            dir.path().join(".fallowrc.json"),
3318            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3319        )
3320        .unwrap();
3321
3322        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3323        assert!(result.is_err());
3324        let err_msg = format!("{}", result.unwrap_err());
3325        assert!(
3326            err_msg.contains("unreachable.invalid"),
3327            "Expected URL in error, got: {err_msg}"
3328        );
3329        assert!(
3330            err_msg.contains("local path or npm:"),
3331            "Expected remediation hint, got: {err_msg}"
3332        );
3333    }
3334
3335    // ── Unknown-rule-name detection wiring (issue #467 phase 1) ──────
3336
3337    #[test]
3338    fn collect_unknown_rule_keys_flags_top_level_typo() {
3339        let merged = serde_json::json!({
3340            "rules": {
3341                "unsued-files": "warn",
3342                "unused-exports": "off"
3343            }
3344        });
3345        let findings = collect_unknown_rule_keys(&merged);
3346        assert_eq!(findings.len(), 1);
3347        assert_eq!(findings[0].context, "rules");
3348        assert_eq!(findings[0].key, "unsued-files");
3349        assert_eq!(findings[0].suggestion, Some("unused-files"));
3350    }
3351
3352    #[test]
3353    fn collect_unknown_rule_keys_flags_overrides_typo() {
3354        let merged = serde_json::json!({
3355            "overrides": [
3356                {
3357                    "files": ["src/**/*.ts"],
3358                    "rules": {
3359                        "unsued-files": "warn"
3360                    }
3361                },
3362                {
3363                    "files": ["tests/**/*.ts"],
3364                    "rules": {
3365                        "circular-dependnecy": "off"
3366                    }
3367                }
3368            ]
3369        });
3370        let findings = collect_unknown_rule_keys(&merged);
3371        assert_eq!(findings.len(), 2);
3372        assert_eq!(findings[0].context, "overrides[0].rules");
3373        assert_eq!(findings[1].context, "overrides[1].rules");
3374        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3375    }
3376
3377    #[test]
3378    fn collect_unknown_rule_keys_empty_for_valid_config() {
3379        let merged = serde_json::json!({
3380            "rules": {
3381                "unused-files": "warn",
3382                "unused-file": "off",
3383                "circular-dependency": "off",
3384                "boundary-violations": "warn"
3385            },
3386            "overrides": [
3387                {
3388                    "files": ["src/**"],
3389                    "rules": {
3390                        "unused-exports": "warn"
3391                    }
3392                }
3393            ]
3394        });
3395        let findings = collect_unknown_rule_keys(&merged);
3396        assert!(
3397            findings.is_empty(),
3398            "valid rule names and aliases must not be flagged: {findings:?}"
3399        );
3400    }
3401
3402    #[test]
3403    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3404        let merged = serde_json::json!({
3405            "entry": ["src/main.ts"]
3406        });
3407        let findings = collect_unknown_rule_keys(&merged);
3408        assert!(findings.is_empty());
3409    }
3410
3411    #[test]
3412    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3413        // Wiring regression test: asserts FallowConfig::load actually invokes
3414        // the warn pass on the merged value. If a future refactor removes the
3415        // `warn_on_unknown_rule_keys` line from `load`, the helper tests still
3416        // pass but this capture-based assertion fails because no finding is
3417        // pushed onto the thread-local buffer.
3418        //
3419        // Uses a thread-local capture (not a process-global counter) so that
3420        // parallel test execution does not race; each test thread has its own
3421        // capture buffer. Uses a unique typo per test invocation so the
3422        // process-wide dedupe set does not suppress the finding if another
3423        // test happens to load a config with the same typo earlier.
3424        let dir = test_dir("wiring");
3425        let path = dir.path().join(".fallowrc.json");
3426        let typo = format!(
3427            "wiring-probe-{}-{}",
3428            std::process::id(),
3429            std::time::SystemTime::now()
3430                .duration_since(std::time::UNIX_EPOCH)
3431                .map_or(0, |d| d.as_nanos())
3432        );
3433        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3434
3435        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3436
3437        assert!(
3438            config_res.is_ok(),
3439            "load should succeed in phase 1: {:?}",
3440            config_res.err()
3441        );
3442        assert_eq!(
3443            captured.len(),
3444            1,
3445            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3446        );
3447        assert_eq!(captured[0].key, typo);
3448        assert_eq!(captured[0].context, "rules");
3449    }
3450
3451    #[test]
3452    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3453        // Phase 1 contract: load succeeds, typo'd rule is silently dropped
3454        // (falls back to default severity). Phase 2 will turn this into a
3455        // hard error.
3456        let dir = test_dir("misspelled-rule");
3457        std::fs::write(
3458            dir.path().join(".fallowrc.json"),
3459            r#"{"rules": {"unsued-files": "warn"}}"#,
3460        )
3461        .unwrap();
3462
3463        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3464            .expect("load should succeed in phase 1");
3465
3466        // Typo'd rule had no effect; unused_files stays at its default (Error).
3467        assert_eq!(config.rules.unused_files, Severity::Error);
3468    }
3469
3470    // ── validate_resolved_boundaries (issue #468) ──────────────────────
3471
3472    #[test]
3473    fn validate_resolved_boundaries_passes_on_valid_config() {
3474        let dir = test_dir("boundaries-valid");
3475        let config = FallowConfig {
3476            boundaries: crate::BoundaryConfig {
3477                preset: None,
3478                zones: vec![
3479                    crate::BoundaryZone {
3480                        name: "ui".to_string(),
3481                        patterns: vec!["src/components/**".to_string()],
3482                        auto_discover: vec![],
3483                        root: None,
3484                    },
3485                    crate::BoundaryZone {
3486                        name: "db".to_string(),
3487                        patterns: vec!["src/db/**".to_string()],
3488                        auto_discover: vec![],
3489                        root: None,
3490                    },
3491                ],
3492                rules: vec![crate::BoundaryRule {
3493                    from: "ui".to_string(),
3494                    allow: vec!["db".to_string()],
3495                    allow_type_only: vec![],
3496                }],
3497            },
3498            ..FallowConfig::default()
3499        };
3500        config
3501            .validate_resolved_boundaries(dir.path())
3502            .expect("valid config should pass");
3503    }
3504
3505    #[test]
3506    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3507        let dir = test_dir("boundaries-unknown-zones");
3508        let config = FallowConfig {
3509            boundaries: crate::BoundaryConfig {
3510                preset: None,
3511                zones: vec![crate::BoundaryZone {
3512                    name: "ui".to_string(),
3513                    patterns: vec!["src/ui/**".to_string()],
3514                    auto_discover: vec![],
3515                    root: None,
3516                }],
3517                rules: vec![
3518                    crate::BoundaryRule {
3519                        from: "typo-from".to_string(),
3520                        allow: vec!["typo-allow".to_string()],
3521                        allow_type_only: vec!["typo-type-only".to_string()],
3522                    },
3523                    crate::BoundaryRule {
3524                        from: "ui".to_string(),
3525                        allow: vec!["another-typo".to_string()],
3526                        allow_type_only: vec![],
3527                    },
3528                ],
3529            },
3530            ..FallowConfig::default()
3531        };
3532
3533        let errors = config
3534            .validate_resolved_boundaries(dir.path())
3535            .expect_err("invalid zone refs should fail");
3536
3537        assert_eq!(errors.len(), 4, "got: {errors:?}");
3538
3539        // Every rendered diagnostic carries the offending zone name AND the
3540        // rule index so users editing a multi-rule config know which entry to
3541        // edit. Verify by rendering and substring-checking each.
3542        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3543        assert!(
3544            rendered
3545                .iter()
3546                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3547        );
3548        assert!(
3549            rendered
3550                .iter()
3551                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3552        );
3553        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3554            && m.contains("rules[0]")
3555            && m.contains("allowTypeOnly")));
3556        assert!(
3557            rendered.iter().any(|m| m.contains("another-typo")
3558                && m.contains("rules[1]")
3559                && m.contains("allow"))
3560        );
3561    }
3562
3563    #[test]
3564    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3565        let dir = test_dir("boundaries-redundant-prefix");
3566        let config = FallowConfig {
3567            boundaries: crate::BoundaryConfig {
3568                preset: None,
3569                zones: vec![crate::BoundaryZone {
3570                    name: "ui".to_string(),
3571                    patterns: vec!["packages/app/src/**".to_string()],
3572                    auto_discover: vec![],
3573                    root: Some("packages/app/".to_string()),
3574                }],
3575                rules: vec![],
3576            },
3577            ..FallowConfig::default()
3578        };
3579
3580        let errors = config
3581            .validate_resolved_boundaries(dir.path())
3582            .expect_err("redundant root prefix should fail");
3583        assert_eq!(errors.len(), 1, "got: {errors:?}");
3584        // Display preserves the legacy FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX tag.
3585        let rendered = errors[0].to_string();
3586        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3587        assert!(rendered.contains("zone 'ui'"));
3588    }
3589
3590    #[test]
3591    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3592        // One config, two distinct failure classes; the user should see both
3593        // in a single diagnostic run instead of fixing one and re-running.
3594        let dir = test_dir("boundaries-mixed-errors");
3595        let config = FallowConfig {
3596            boundaries: crate::BoundaryConfig {
3597                preset: None,
3598                zones: vec![crate::BoundaryZone {
3599                    name: "ui".to_string(),
3600                    patterns: vec!["packages/app/src/**".to_string()],
3601                    auto_discover: vec![],
3602                    root: Some("packages/app/".to_string()),
3603                }],
3604                rules: vec![crate::BoundaryRule {
3605                    from: "ui".to_string(),
3606                    allow: vec!["typo-zone".to_string()],
3607                    allow_type_only: vec![],
3608                }],
3609            },
3610            ..FallowConfig::default()
3611        };
3612        let errors = config
3613            .validate_resolved_boundaries(dir.path())
3614            .expect_err("mixed errors should fail");
3615        assert_eq!(errors.len(), 2, "got: {errors:?}");
3616        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3617        assert!(
3618            rendered
3619                .iter()
3620                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3621        );
3622        assert!(
3623            rendered
3624                .iter()
3625                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3626        );
3627    }
3628
3629    #[test]
3630    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3631        // Bulletproof's authored rule references the logical `features`
3632        // group, which is replaced by concrete children only AFTER
3633        // `expand_auto_discover` runs. Validation must execute the expansion
3634        // first, otherwise the preset always looks like it references an
3635        // undefined zone.
3636        let dir = test_dir("boundaries-bulletproof");
3637        // Create a stub `src/features/auth` child so auto-discover finds it.
3638        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3639        let config = FallowConfig {
3640            boundaries: crate::BoundaryConfig {
3641                preset: Some(crate::BoundaryPreset::Bulletproof),
3642                zones: vec![],
3643                rules: vec![],
3644            },
3645            ..FallowConfig::default()
3646        };
3647        config
3648            .validate_resolved_boundaries(dir.path())
3649            .expect("Bulletproof with discoverable features should pass");
3650    }
3651}