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