Skip to main content

fallow_config/config/
parsing.rs

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