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.
10pub(super) const CONFIG_NAMES: &[&str] = &[
11    ".fallowrc.json",
12    ".fallowrc.jsonc",
13    "fallow.toml",
14    ".fallow.toml",
15];
16
17pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
18
19/// Prefix for npm package specifiers in the `extends` field.
20const NPM_PREFIX: &str = "npm:";
21
22/// Prefix for HTTPS URL specifiers in the `extends` field.
23const HTTPS_PREFIX: &str = "https://";
24
25/// Prefix for HTTP URL specifiers (rejected with a clear error).
26const HTTP_PREFIX: &str = "http://";
27
28/// Default timeout for fetching remote configs via URL extends.
29const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
30
31/// Detect config format from file extension.
32pub(super) enum ConfigFormat {
33    Toml,
34    Json,
35}
36
37impl ConfigFormat {
38    pub(super) fn from_path(path: &Path) -> Self {
39        match path.extension().and_then(|e| e.to_str()) {
40            Some("json" | "jsonc") => Self::Json,
41            _ => Self::Toml,
42        }
43    }
44}
45
46/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
47/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
48pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
49    match (base, overlay) {
50        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
51            for (key, value) in overlay_map {
52                if let Some(base_value) = base_map.get_mut(&key) {
53                    deep_merge_json(base_value, value);
54                } else {
55                    base_map.insert(key, value);
56                }
57            }
58        }
59        (base, overlay) => {
60            *base = overlay;
61        }
62    }
63}
64
65pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
66    let content = std::fs::read_to_string(path)
67        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
68    let content = content.trim_start_matches('\u{FEFF}');
69
70    match ConfigFormat::from_path(path) {
71        ConfigFormat::Toml => {
72            let toml_value: toml::Value = toml::from_str(content).map_err(|e| {
73                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
74            })?;
75            serde_json::to_value(toml_value).map_err(|e| {
76                miette::miette!(
77                    "Failed to convert TOML to JSON for {}: {}",
78                    path.display(),
79                    e
80                )
81            })
82        }
83        ConfigFormat::Json => crate::jsonc::parse_to_value(content)
84            .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e)),
85    }
86}
87
88fn is_repo_root(dir: &Path) -> bool {
89    dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
90}
91
92fn resolve_confined(
93    base_dir: &Path,
94    resolved: &Path,
95    context: &str,
96    source_config: &Path,
97) -> Result<PathBuf, miette::Report> {
98    let canonical_base = dunce::canonicalize(base_dir)
99        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
100    let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
101        miette::miette!(
102            "Config file not found: {} ({}, referenced from {}): {}",
103            resolved.display(),
104            context,
105            source_config.display(),
106            e
107        )
108    })?;
109    if !canonical_file.starts_with(&canonical_base) {
110        return Err(miette::miette!(
111            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
112            resolved.display(),
113            base_dir.display(),
114            context,
115            source_config.display()
116        ));
117    }
118    Ok(canonical_file)
119}
120
121fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
122    if name.starts_with('@') && !name.contains('/') {
123        return Err(miette::miette!(
124            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
125            name,
126            source_config.display()
127        ));
128    }
129    if name.split('/').any(|c| c == ".." || c == ".") {
130        return Err(miette::miette!(
131            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
132            name,
133            source_config.display()
134        ));
135    }
136    Ok(())
137}
138
139fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
140    if specifier.starts_with('@') {
141        let mut slashes = 0;
142        for (i, ch) in specifier.char_indices() {
143            if ch == '/' {
144                slashes += 1;
145                if slashes == 2 {
146                    return (&specifier[..i], Some(&specifier[i + 1..]));
147                }
148            }
149        }
150        (specifier, None)
151    } else if let Some(slash) = specifier.find('/') {
152        (&specifier[..slash], Some(&specifier[slash + 1..]))
153    } else {
154        (specifier, None)
155    }
156}
157
158fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
159    let exports = pkg.get("exports")?;
160    match exports {
161        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
162        serde_json::Value::Object(map) => {
163            let dot_export = map.get(".")?;
164            match dot_export {
165                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
166                serde_json::Value::Object(conditions) => {
167                    for key in ["default", "node", "import", "require"] {
168                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
169                            return Some(package_dir.join(s.as_str()));
170                        }
171                    }
172                    None
173                }
174                _ => None,
175            }
176        }
177        _ => None,
178    }
179}
180
181fn find_config_in_npm_package(
182    package_dir: &Path,
183    source_config: &Path,
184) -> Result<PathBuf, miette::Report> {
185    let pkg_json_path = package_dir.join("package.json");
186    if pkg_json_path.exists() {
187        let content = std::fs::read_to_string(&pkg_json_path)
188            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
189        let pkg: serde_json::Value = serde_json::from_str(&content)
190            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
191        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
192            && config_path.exists()
193        {
194            return resolve_confined(
195                package_dir,
196                &config_path,
197                "package.json exports",
198                source_config,
199            );
200        }
201        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
202            let main_path = package_dir.join(main);
203            if main_path.exists() {
204                return resolve_confined(
205                    package_dir,
206                    &main_path,
207                    "package.json main",
208                    source_config,
209                );
210            }
211        }
212    }
213
214    for config_name in CONFIG_NAMES {
215        let config_path = package_dir.join(config_name);
216        if config_path.exists() {
217            return resolve_confined(
218                package_dir,
219                &config_path,
220                "config name fallback",
221                source_config,
222            );
223        }
224    }
225
226    Err(miette::miette!(
227        "No fallow config found in npm package at {}. \
228         Expected package.json with main/exports pointing to a config file, \
229         or one of: {}",
230        package_dir.display(),
231        CONFIG_NAMES.join(", ")
232    ))
233}
234
235fn resolve_npm_package(
236    config_dir: &Path,
237    specifier: &str,
238    source_config: &Path,
239) -> Result<PathBuf, miette::Report> {
240    let specifier = specifier.trim();
241    if specifier.is_empty() {
242        return Err(miette::miette!(
243            "Empty npm specifier in extends (in {})",
244            source_config.display()
245        ));
246    }
247
248    let (package_name, subpath) = parse_npm_specifier(specifier);
249    validate_npm_package_name(package_name, source_config)?;
250
251    let mut dir = Some(config_dir);
252    while let Some(d) = dir {
253        let candidate = d.join("node_modules").join(package_name);
254        if candidate.is_dir() {
255            return if let Some(sub) = subpath {
256                let file = candidate.join(sub);
257                if file.exists() {
258                    resolve_confined(
259                        &candidate,
260                        &file,
261                        &format!("subpath '{sub}'"),
262                        source_config,
263                    )
264                } else {
265                    Err(miette::miette!(
266                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
267                        file.display(),
268                        sub,
269                        candidate.display(),
270                        source_config.display()
271                    ))
272                }
273            } else {
274                find_config_in_npm_package(&candidate, source_config)
275            };
276        }
277        dir = d.parent();
278    }
279
280    Err(miette::miette!(
281        "npm package '{}' not found. \
282         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
283         If this package should be available, install it and ensure it is listed in your project's dependencies",
284        package_name,
285        package_name,
286        config_dir.display(),
287        source_config.display()
288    ))
289}
290
291/// Normalize a URL for deduplication.
292fn normalize_url_for_dedup(url: &str) -> String {
293    let Some((scheme, rest)) = url.split_once("://") else {
294        return url.to_string();
295    };
296    let scheme = scheme.to_ascii_lowercase();
297
298    let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
299    let authority = authority.to_ascii_lowercase();
300
301    let authority = authority.strip_suffix(":443").unwrap_or(&authority);
302
303    let path = path.split_once('#').map_or(path, |(p, _)| p);
304    let path = path.split_once('?').map_or(path, |(p, _)| p);
305    let path = path.strip_suffix('/').unwrap_or(path);
306
307    if path.is_empty() {
308        format!("{scheme}://{authority}")
309    } else {
310        format!("{scheme}://{authority}/{path}")
311    }
312}
313
314/// Read the `FALLOW_EXTENDS_TIMEOUT_SECS` env var, falling back to [`DEFAULT_URL_TIMEOUT_SECS`].
315fn url_timeout() -> Duration {
316    url_timeout_from(std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS").ok().as_deref())
317}
318
319/// Parse a raw `FALLOW_EXTENDS_TIMEOUT_SECS` value into a timeout, falling back
320/// to [`DEFAULT_URL_TIMEOUT_SECS`] for absent, zero, or non-numeric input. Pure
321/// so the parsing branches stay testable without mutating the process env.
322fn url_timeout_from(raw: Option<&str>) -> Duration {
323    raw.and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
324        .map_or(
325            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
326            Duration::from_secs,
327        )
328}
329
330/// Maximum response body size for fetched config files.
331const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
332
333/// Fetch a remote JSON config from an HTTPS URL.
334fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
335    let timeout = url_timeout();
336    let agent = ureq::Agent::config_builder()
337        .timeout_global(Some(timeout))
338        .https_only(true)
339        .build()
340        .new_agent();
341
342    let mut response = agent.get(url).call().map_err(|e| {
343        miette::miette!(
344            "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
345             If this URL is unavailable, use a local path or npm: specifier instead"
346        )
347    })?;
348
349    let body = response
350        .body_mut()
351        .with_config()
352        .limit(MAX_URL_CONFIG_BYTES)
353        .read_to_string()
354        .map_err(|e| {
355            miette::miette!(
356                "Failed to read response body from {url} (referenced from {source}): {e}"
357            )
358        })?;
359
360    crate::jsonc::parse_to_value(&body).map_err(|e| {
361        miette::miette!(
362            "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
363             Only JSON/JSONC is supported for URL-sourced configs"
364        )
365    })
366}
367
368/// Extract the `extends` array from a parsed JSON config value.
369fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
370    value
371        .as_object_mut()
372        .and_then(|obj| obj.remove("extends"))
373        .and_then(|v| match v {
374            serde_json::Value::Array(arr) => Some(
375                arr.into_iter()
376                    .filter_map(|v| v.as_str().map(String::from))
377                    .collect::<Vec<_>>(),
378            ),
379            serde_json::Value::String(s) => Some(vec![s]),
380            _ => None,
381        })
382        .unwrap_or_default()
383}
384
385/// Resolve extends entries from a URL-sourced config.
386fn resolve_url_extends(
387    url: &str,
388    visited: &mut FxHashSet<String>,
389    depth: usize,
390) -> Result<serde_json::Value, miette::Report> {
391    if depth >= MAX_EXTENDS_DEPTH {
392        return Err(miette::miette!(
393            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
394        ));
395    }
396
397    let normalized = normalize_url_for_dedup(url);
398    if !visited.insert(normalized) {
399        return Err(miette::miette!(
400            "Circular extends detected: {url} was already visited in the extends chain"
401        ));
402    }
403
404    let mut value = fetch_url_config(url, url)?;
405    let extends = extract_extends(&mut value);
406
407    if extends.is_empty() {
408        return Ok(value);
409    }
410
411    let mut merged = serde_json::Value::Object(serde_json::Map::new());
412
413    for entry in &extends {
414        let base = if entry.starts_with(HTTPS_PREFIX) {
415            resolve_url_extends(entry, visited, depth + 1)?
416        } else if entry.starts_with(HTTP_PREFIX) {
417            return Err(miette::miette!(
418                "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
419                 Change the URL to use https:// instead",
420                entry,
421                url
422            ));
423        } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
424            let cwd = std::env::current_dir().map_err(|e| {
425                miette::miette!(
426                    "Cannot resolve npm: specifier from URL-sourced config: \
427                     failed to determine current directory: {e}"
428                )
429            })?;
430            let path_placeholder = PathBuf::from(url);
431            let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
432            resolve_extends_file(&npm_path, visited, depth + 1)?
433        } else {
434            return Err(miette::miette!(
435                "Relative paths in 'extends' are not supported when the base config was \
436                 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
437                 instead. Got: '{entry}'"
438            ));
439        };
440        deep_merge_json(&mut merged, base);
441    }
442
443    deep_merge_json(&mut merged, value);
444    Ok(merged)
445}
446
447/// Resolve extends from a local config file.
448fn resolve_extends_file(
449    path: &Path,
450    visited: &mut FxHashSet<String>,
451    depth: usize,
452) -> Result<serde_json::Value, miette::Report> {
453    if depth >= MAX_EXTENDS_DEPTH {
454        return Err(miette::miette!(
455            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
456            path.display()
457        ));
458    }
459
460    record_extends_visit(path, visited)?;
461
462    let mut value = parse_config_to_value(path)?;
463    let extends = extract_extends(&mut value);
464
465    if extends.is_empty() {
466        return Ok(value);
467    }
468
469    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
470    let sealed = value
471        .get("sealed")
472        .and_then(serde_json::Value::as_bool)
473        .unwrap_or(false);
474    let sealed_dir_canonical = sealed_config_dir(config_dir, sealed)?;
475    let mut merged = serde_json::Value::Object(serde_json::Map::new());
476
477    for extend_path_str in &extends {
478        let base = resolve_extends_file_entry(&mut ExtendsFileEntryInput {
479            path,
480            config_dir,
481            entry: extend_path_str,
482            sealed,
483            sealed_dir_canonical: sealed_dir_canonical.as_deref(),
484            visited,
485            depth,
486        })?;
487        deep_merge_json(&mut merged, base);
488    }
489
490    deep_merge_json(&mut merged, value);
491    Ok(merged)
492}
493
494fn record_extends_visit(
495    path: &Path,
496    visited: &mut FxHashSet<String>,
497) -> Result<(), miette::Report> {
498    let canonical = dunce::canonicalize(path).map_err(|e| {
499        miette::miette!(
500            "Config file not found or unresolvable: {}: {}",
501            path.display(),
502            e
503        )
504    })?;
505
506    if visited.insert(canonical.to_string_lossy().into_owned()) {
507        Ok(())
508    } else {
509        Err(miette::miette!(
510            "Circular extends detected: {} was already visited in the extends chain",
511            path.display()
512        ))
513    }
514}
515
516fn sealed_config_dir(config_dir: &Path, sealed: bool) -> Result<Option<PathBuf>, miette::Report> {
517    if !sealed {
518        return Ok(None);
519    }
520    dunce::canonicalize(config_dir).map(Some).map_err(|e| {
521        miette::miette!(
522            "Sealed config directory '{}' could not be canonicalized: {e}",
523            config_dir.display()
524        )
525    })
526}
527
528struct ExtendsFileEntryInput<'a> {
529    path: &'a Path,
530    config_dir: &'a Path,
531    entry: &'a str,
532    sealed: bool,
533    sealed_dir_canonical: Option<&'a Path>,
534    visited: &'a mut FxHashSet<String>,
535    depth: usize,
536}
537
538fn resolve_extends_file_entry(
539    input: &mut ExtendsFileEntryInput<'_>,
540) -> Result<serde_json::Value, miette::Report> {
541    if input.entry.starts_with(HTTPS_PREFIX) {
542        reject_sealed_remote_extends(input.path, input.entry, input.sealed, "URL")?;
543        return resolve_url_extends(input.entry, input.visited, input.depth + 1);
544    }
545    if input.entry.starts_with(HTTP_PREFIX) {
546        return Err(miette::miette!(
547            "URL extends must use https://, got http:// URL '{}' (in {}). \
548             Change the URL to use https:// instead",
549            input.entry,
550            input.path.display()
551        ));
552    }
553    if let Some(npm_specifier) = input.entry.strip_prefix(NPM_PREFIX) {
554        reject_sealed_remote_extends(input.path, input.entry, input.sealed, "npm")?;
555        let npm_path = resolve_npm_package(input.config_dir, npm_specifier, input.path)?;
556        return resolve_extends_file(&npm_path, input.visited, input.depth + 1);
557    }
558    resolve_relative_extends_file(
559        input.path,
560        input.config_dir,
561        input.entry,
562        input.sealed_dir_canonical,
563        input.visited,
564        input.depth,
565    )
566}
567
568fn reject_sealed_remote_extends(
569    path: &Path,
570    entry: &str,
571    sealed: bool,
572    kind: &str,
573) -> Result<(), miette::Report> {
574    if sealed {
575        Err(miette::miette!(
576            "'sealed: true' config at {} rejects {} extends '{}'. \
577             Sealed configs only allow file-relative extends within \
578             the config's directory",
579            path.display(),
580            kind,
581            entry
582        ))
583    } else {
584        Ok(())
585    }
586}
587
588fn resolve_relative_extends_file(
589    path: &Path,
590    config_dir: &Path,
591    entry: &str,
592    sealed_dir_canonical: Option<&Path>,
593    visited: &mut FxHashSet<String>,
594    depth: usize,
595) -> Result<serde_json::Value, miette::Report> {
596    if is_absolute_path_any_platform(Path::new(entry)) {
597        return Err(miette::miette!(
598            "extends paths must be relative, got absolute path: {} (in {})",
599            entry,
600            path.display()
601        ));
602    }
603    let p = config_dir.join(entry);
604    if !p.exists() {
605        return Err(miette::miette!(
606            "Extended config file not found: {} (referenced from {})",
607            p.display(),
608            path.display()
609        ));
610    }
611    validate_sealed_relative_extends(path, entry, &p, sealed_dir_canonical)?;
612    resolve_extends_file(&p, visited, depth + 1)
613}
614
615fn validate_sealed_relative_extends(
616    path: &Path,
617    entry: &str,
618    resolved_path: &Path,
619    sealed_dir_canonical: Option<&Path>,
620) -> Result<(), miette::Report> {
621    let Some(dir_canonical) = sealed_dir_canonical else {
622        return Ok(());
623    };
624    let p_canonical = dunce::canonicalize(resolved_path).map_err(|e| {
625        miette::miette!(
626            "Sealed config extends path '{}' could not be canonicalized: {e}",
627            resolved_path.display()
628        )
629    })?;
630    if p_canonical.starts_with(dir_canonical) {
631        Ok(())
632    } else {
633        Err(miette::miette!(
634            "'sealed: true' config at {} rejects extends '{}' which resolves \
635             outside the config's directory ({}). Sealed configs only allow \
636             extends within the config's directory",
637            path.display(),
638            entry,
639            p_canonical.display()
640        ))
641    }
642}
643
644/// Public entry point: resolve a config file with all its extends chain.
645///
646/// Delegates to [`resolve_extends_file`] with a fresh visited set.
647pub(super) fn resolve_extends(
648    path: &Path,
649    visited: &mut FxHashSet<String>,
650    depth: usize,
651) -> Result<serde_json::Value, miette::Report> {
652    resolve_extends_file(path, visited, depth)
653}
654
655/// Collect every unknown key under `rules` or `overrides[].rules` in a merged
656/// config value (issue #467, phase 1).
657///
658/// Today `RulesConfig` / `PartialRulesConfig` carry serde aliases but NOT
659/// `deny_unknown_fields`, so typos like `unsued-files` are silently dropped and
660/// the user's intent is lost. This pass walks the merged value before
661/// deserialization and surfaces every unknown key, with a Levenshtein-distance
662/// suggestion when the typo is close to a known name.
663///
664/// Returns the findings so the caller can render them; tests can assert
665/// against the list without subscribing to tracing output.
666///
667/// Phase 2 (a future minor release) flips both structs to
668/// `#[serde(deny_unknown_fields)]` and the warning becomes a hard error.
669pub(super) fn collect_unknown_rule_keys(
670    merged: &serde_json::Value,
671) -> Vec<super::rules::UnknownRuleKey> {
672    use super::rules::find_unknown_rule_keys;
673
674    let mut findings = Vec::new();
675
676    if let Some(rules) = merged.get("rules") {
677        findings.extend(find_unknown_rule_keys(rules, "rules"));
678    }
679
680    if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
681        for (i, entry) in overrides.iter().enumerate() {
682            if let Some(rules) = entry.get("rules") {
683                let context = format!("overrides[{i}].rules");
684                findings.extend(find_unknown_rule_keys(rules, &context));
685            }
686        }
687    }
688
689    findings
690}
691
692thread_local! {
693    /// Per-thread capture of unknown-rule findings, for the wiring regression
694    /// test in this module. Each test installs a fresh capture via
695    /// [`capture_unknown_rule_warnings`], runs `FallowConfig::load`, and reads
696    /// back the findings. Thread-local so parallel test execution does not
697    /// race; bypassed entirely in production code (`UnknownRuleCapture::None`).
698    #[cfg(test)]
699    static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
700        const { std::cell::RefCell::new(None) };
701}
702
703/// Install a thread-local capture buffer and run `body`. Returns the findings
704/// emitted by every `warn_on_unknown_rule_keys` call within `body`'s call tree
705/// on the current thread, in order. Test-only.
706#[cfg(test)]
707pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
708    body: F,
709) -> (R, Vec<super::rules::UnknownRuleKey>) {
710    UNKNOWN_RULE_CAPTURE.with(|cell| {
711        *cell.borrow_mut() = Some(Vec::new());
712    });
713    let result = body();
714    let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
715    (result, findings)
716}
717
718/// Emit a `tracing::warn!` per finding from [`collect_unknown_rule_keys`].
719///
720/// `config_path` is the file the merged value originated from; it appears in
721/// the warning text AND in the dedupe key so two different config files with
722/// the same typo each warn once instead of the second one being silenced.
723///
724/// Deduplicates within the process: `FallowConfig::load` runs multiple times
725/// per analysis (combined mode runs check + dupes + health, each through the
726/// same config load path), so without a dedupe the same typo emits 3+ warnings
727/// per run.
728fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
729    use std::sync::{Mutex, OnceLock};
730
731    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
732    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
733
734    let path_display = config_path.display().to_string();
735
736    for finding in collect_unknown_rule_keys(merged) {
737        let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
738        if let Ok(mut set) = warned.lock()
739            && !set.insert(dedupe_key)
740        {
741            continue;
742        }
743
744        #[cfg(test)]
745        UNKNOWN_RULE_CAPTURE.with(|cell| {
746            if let Some(buf) = cell.borrow_mut().as_mut() {
747                buf.push(finding.clone());
748            }
749        });
750
751        if let Some(suggestion) = finding.suggestion {
752            tracing::warn!(
753                "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
754                 the rule will be ignored. A future release will reject unknown rule names.",
755                key = finding.key,
756                context = finding.context,
757                path = path_display,
758            );
759        } else {
760            tracing::warn!(
761                "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
762                 A future release will reject unknown rule names.",
763                key = finding.key,
764                context = finding.context,
765                path = path_display,
766            );
767        }
768    }
769}
770
771/// Return the lower-precedence config names from [`CONFIG_NAMES`] that ALSO
772/// exist in `dir`, given that `chosen_index` is the index of the first-match
773/// (winning) name.
774///
775/// Only indices after `chosen_index` are scanned: a higher-precedence name
776/// cannot coexist undetected, because it would have been the first match.
777fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
778    CONFIG_NAMES
779        .iter()
780        .skip(chosen_index + 1)
781        .filter(|name| dir.join(name).exists())
782        .copied()
783        .collect()
784}
785
786/// A captured coexistence warning: `(chosen file name, shadowed file names)`.
787/// Test-only; populated by `warn_on_coexisting_configs` under capture.
788#[cfg(test)]
789type CoexistWarning = (String, Vec<String>);
790
791thread_local! {
792    /// Per-thread capture of coexisting-config warnings, for the wiring
793    /// regression test in this module. Mirrors [`UNKNOWN_RULE_CAPTURE`]: each
794    /// test installs a fresh capture via
795    /// [`capture_coexisting_config_warnings`], runs `find_and_load`, and reads
796    /// back the `(chosen, shadowed)` pairs. Thread-local so parallel test
797    /// execution does not race; bypassed entirely in production code.
798    #[cfg(test)]
799    static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
800        const { std::cell::RefCell::new(None) };
801}
802
803/// Install a thread-local capture buffer and run `body`. Returns every
804/// `(chosen, shadowed)` pair emitted by `warn_on_coexisting_configs` within
805/// `body`'s call tree on the current thread, in order. Test-only.
806#[cfg(test)]
807pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
808    body: F,
809) -> (R, Vec<CoexistWarning>) {
810    COEXIST_CAPTURE.with(|cell| {
811        *cell.borrow_mut() = Some(Vec::new());
812    });
813    let result = body();
814    let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
815    (result, findings)
816}
817
818/// Emit a `tracing::warn!` when `find_and_load` picked `chosen_path` while one
819/// or more lower-precedence config files (`shadowed`) coexist in the same
820/// directory. Silent precedence is the worst class of config bug: the user
821/// sees correct-looking output produced from the wrong source (#458).
822///
823/// `chosen_path` is the absolute candidate path of the winning config;
824/// `shadowed` are the bare names of the lower-precedence files that also exist.
825///
826/// Deduplicates within the process keyed on the canonical directory, because
827/// `find_and_load` runs multiple times per analysis (combined mode loads config
828/// for check + dupes + health); without the dedupe the same directory would
829/// warn 3+ times per run. Two different directories with coexisting configs
830/// warn independently.
831fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
832    use std::sync::{Mutex, OnceLock};
833
834    if shadowed.is_empty() {
835        return;
836    }
837
838    let chosen_name = chosen_path.file_name().map_or_else(
839        || chosen_path.display().to_string(),
840        |n| n.to_string_lossy().into_owned(),
841    );
842    let dir = chosen_path.parent().unwrap_or(chosen_path);
843
844    #[cfg(test)]
845    COEXIST_CAPTURE.with(|cell| {
846        if let Some(buf) = cell.borrow_mut().as_mut() {
847            buf.push((
848                chosen_name.clone(),
849                shadowed.iter().map(|s| (*s).to_owned()).collect(),
850            ));
851        }
852    });
853
854    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
855    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
856    let dedupe_key = std::fs::canonicalize(dir)
857        .unwrap_or_else(|_| dir.to_path_buf())
858        .display()
859        .to_string();
860    if let Ok(mut set) = warned.lock()
861        && !set.insert(dedupe_key)
862    {
863        return;
864    }
865
866    tracing::warn!(
867        "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
868         fallow uses the first match in precedence order \
869         (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
870         remove the unused file(s) to silence this warning.",
871        dir = dir.display(),
872        chosen = chosen_name,
873        shadowed = shadowed.join(", "),
874    );
875}
876
877impl FallowConfig {
878    /// Load config from a fallow config file (TOML or JSON/JSONC).
879    ///
880    /// The format is detected from the file extension:
881    /// - `.toml` → TOML
882    /// - `.json` → JSON (with JSONC comment stripping)
883    ///
884    /// Supports `extends` for config inheritance. Extended configs are loaded
885    /// and deep-merged before this config's values are applied.
886    ///
887    /// User-supplied glob patterns (`entry`, `ignorePatterns`,
888    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
889    /// `health.thresholdOverrides[].files`, `boundaries.zones[].patterns`, `overrides[].files`,
890    /// `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`) are
891    /// validated against absolute paths, `..` traversal segments, and invalid
892    /// glob syntax. Loading fails loud on any rejection so silent no-match
893    /// configs surface to the user. See issue #463.
894    ///
895    /// # Errors
896    ///
897    /// Returns an error when the config file cannot be read, merged, or
898    /// deserialized, or when any user-supplied glob pattern is rejected.
899    pub fn load(path: &Path) -> Result<Self, miette::Report> {
900        let mut visited = FxHashSet::default();
901        let merged = resolve_extends(path, &mut visited, 0)?;
902
903        warn_on_unknown_rule_keys(path, &merged);
904
905        let config: Self = serde_json::from_value(merged).map_err(|e| {
906            miette::miette!(
907                "Failed to deserialize config from {}: {}",
908                path.display(),
909                e
910            )
911        })?;
912
913        config.validate_user_globs().map_err(|errors| {
914            let joined = errors
915                .iter()
916                .map(ToString::to_string)
917                .collect::<Vec<_>>()
918                .join("\n  - ");
919            miette::miette!("invalid config:\n  - {}", joined)
920        })?;
921        if !config.security.request_receivers_are_valid() {
922            return Err(miette::miette!(
923                "invalid config:\n  - security.requestReceivers entries must be non-empty strings"
924            ));
925        }
926        let threshold_override_errors = config.health.threshold_override_errors();
927        if !threshold_override_errors.is_empty() {
928            return Err(miette::miette!(
929                "invalid config:\n  - {}",
930                threshold_override_errors.join("\n  - ")
931            ));
932        }
933
934        Ok(config)
935    }
936
937    /// Validate all user-supplied glob patterns and directory paths in this config.
938    ///
939    /// Accumulates errors from every glob- or path-bearing field so the user
940    /// sees ALL offending values in one run rather than fixing them one at a
941    /// time.
942    ///
943    /// Covered filesystem glob fields: `entry`, `ignorePatterns`,
944    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
945    /// `health.thresholdOverrides[].files`, `overrides[].files`, `ignoreExports[].file`,
946    /// `ignoreCatalogReferences[].consumer`, `boundaries.zones[].patterns`,
947    /// `boundaries.coverage.allowUnmatched`,
948    /// plus every glob-bearing field on inline `framework[]` plugin
949    /// definitions (entry points, always-used, config patterns, used-exports
950    /// patterns, and `fileExists` detection patterns; the last reaches
951    /// `glob::glob` on disk so a `..` segment there is a real path traversal).
952    ///
953    /// Covered specifier glob fields: `ignoreUnresolvedImports`. These match
954    /// raw import strings, so parent-relative specifiers like `../generated/**`
955    /// are valid and only glob syntax is checked.
956    ///
957    /// Covered directory-path fields: `boundaries.zones[].root` and
958    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
959    /// globs), so only the absolute-path + traversal checks apply.
960    ///
961    /// # Errors
962    ///
963    /// Returns a non-empty `Vec` of
964    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
965    /// when any field contains a rejected value.
966    pub fn validate_user_globs(
967        &self,
968    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
969        let mut errors = Vec::new();
970
971        self.validate_top_level_globs(&mut errors);
972        self.validate_ignore_rule_globs(&mut errors);
973        self.validate_boundary_globs(&mut errors);
974
975        for plugin in &self.framework {
976            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
977                errors.append(&mut plugin_errors);
978            }
979        }
980
981        if errors.is_empty() {
982            Ok(())
983        } else {
984            Err(errors)
985        }
986    }
987
988    /// Validate the top-level filesystem and specifier glob fields plus the
989    /// per-override and threshold-override file globs.
990    fn validate_top_level_globs(
991        &self,
992        errors: &mut Vec<super::glob_validation::GlobValidationError>,
993    ) {
994        use super::glob_validation::{validate_user_globs, validate_user_specifier_globs};
995
996        validate_user_globs(&self.entry, "entry", errors);
997        validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
998        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
999        validate_user_specifier_globs(
1000            &self.ignore_unresolved_imports,
1001            "ignoreUnresolvedImports",
1002            errors,
1003        );
1004        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1005        validate_user_globs(&self.health.ignore, "health.ignore", errors);
1006        for override_entry in &self.health.threshold_overrides {
1007            validate_user_globs(
1008                &override_entry.files,
1009                "health.thresholdOverrides[].files",
1010                errors,
1011            );
1012        }
1013        for override_entry in &self.overrides {
1014            validate_user_globs(&override_entry.files, "overrides[].files", errors);
1015        }
1016    }
1017
1018    /// Validate the `ignoreExports` and `ignoreCatalogReferences` rule globs.
1019    fn validate_ignore_rule_globs(
1020        &self,
1021        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1022    ) {
1023        use super::glob_validation::compile_user_glob;
1024
1025        for rule in &self.ignore_exports {
1026            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1027                errors.push(e);
1028            }
1029        }
1030
1031        for rule in &self.ignore_catalog_references {
1032            if let Some(consumer) = &rule.consumer
1033                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1034            {
1035                errors.push(e);
1036            }
1037        }
1038    }
1039
1040    /// Validate the `boundaries.zones[]` patterns/roots/autoDiscover and the
1041    /// coverage `allowUnmatched` globs.
1042    fn validate_boundary_globs(
1043        &self,
1044        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1045    ) {
1046        use super::glob_validation::{
1047            validate_user_globs, validate_user_path, validate_user_paths,
1048        };
1049
1050        for zone in &self.boundaries.zones {
1051            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1052            if let Some(root) = &zone.root
1053                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1054            {
1055                errors.push(e);
1056            }
1057            validate_user_paths(
1058                &zone.auto_discover,
1059                "boundaries.zones[].autoDiscover",
1060                errors,
1061            );
1062        }
1063        validate_user_globs(
1064            &self.boundaries.coverage.allow_unmatched,
1065            "boundaries.coverage.allowUnmatched",
1066            errors,
1067        );
1068    }
1069
1070    /// Find the config file path without loading it.
1071    /// Searches the same locations as `find_and_load`.
1072    #[must_use]
1073    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1074        let mut dir = start;
1075        loop {
1076            for name in CONFIG_NAMES {
1077                let candidate = dir.join(name);
1078                if candidate.exists() {
1079                    return Some(candidate);
1080                }
1081            }
1082            if is_repo_root(dir) {
1083                break;
1084            }
1085            dir = dir.parent()?;
1086        }
1087        None
1088    }
1089
1090    /// Find and load config, searching from `start` up to the project root.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error if a config file is found but cannot be read or parsed.
1095    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1096        let mut dir = start;
1097        loop {
1098            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1099                let candidate = dir.join(name);
1100                if candidate.exists() {
1101                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1102                    match Self::load(&candidate) {
1103                        Ok(config) => return Ok(Some((config, candidate))),
1104                        Err(e) => {
1105                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
1106                        }
1107                    }
1108                }
1109            }
1110            if is_repo_root(dir) {
1111                break;
1112            }
1113            dir = match dir.parent() {
1114                Some(parent) => parent,
1115                None => break,
1116            };
1117        }
1118        Ok(None)
1119    }
1120
1121    /// Generate JSON Schema for the configuration format.
1122    #[must_use]
1123    pub fn json_schema() -> serde_json::Value {
1124        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1125    }
1126
1127    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1128    /// preset and auto-discover expansion.
1129    ///
1130    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1131    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1132    /// before invoking
1133    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1134    /// and
1135    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1136    /// so Bulletproof-style presets whose authored rule references logical
1137    /// groups (`features`) still load cleanly.
1138    ///
1139    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1140    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1141    /// surface every collected error in a single rendered diagnostic, then
1142    /// exit with code 2. Previously these failures emitted `tracing::error!`
1143    /// and continued, producing a flood of false-positive boundary violations
1144    /// at analysis time (#468).
1145    ///
1146    /// `root` is the project root used by `expand_auto_discover` to scan for
1147    /// child directories. Caller is responsible for passing the same root it
1148    /// later hands to `resolve()`.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1153    /// offending zone reference and redundant-root-prefix pattern; the empty
1154    /// case becomes `Ok(())`.
1155    pub fn validate_resolved_boundaries(
1156        &self,
1157        root: &Path,
1158    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1159        use super::boundaries::ZoneValidationError;
1160
1161        let mut boundaries = self.boundaries.clone();
1162        if boundaries.preset.is_some() {
1163            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1164                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1165                .unwrap_or_else(|| "src".to_owned());
1166            boundaries.expand(&source_root);
1167        }
1168        let _logical_groups = boundaries.expand_auto_discover(root);
1169
1170        let mut errors: Vec<ZoneValidationError> = boundaries
1171            .validate_zone_references()
1172            .into_iter()
1173            .map(ZoneValidationError::UnknownZoneReference)
1174            .collect();
1175        errors.extend(
1176            boundaries
1177                .validate_root_prefixes()
1178                .into_iter()
1179                .map(ZoneValidationError::RedundantRootPrefix),
1180        );
1181        errors.extend(
1182            boundaries
1183                .validate_call_rules()
1184                .into_iter()
1185                .map(ZoneValidationError::InvalidForbiddenCallee),
1186        );
1187
1188        if errors.is_empty() {
1189            Ok(())
1190        } else {
1191            Err(errors)
1192        }
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::CacheConfig;
1200    use crate::PackageJson;
1201    use crate::config::format::OutputFormat;
1202    use crate::config::rules::Severity;
1203
1204    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1205    fn test_dir(_name: &str) -> tempfile::TempDir {
1206        tempfile::tempdir().expect("create temp dir")
1207    }
1208
1209    #[test]
1210    fn fallow_config_deserialize_minimal() {
1211        let toml_str = r#"
1212entry = ["src/main.ts"]
1213"#;
1214        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1215        assert_eq!(config.entry, vec!["src/main.ts"]);
1216        assert!(config.ignore_patterns.is_empty());
1217    }
1218
1219    #[test]
1220    fn fallow_config_deserialize_ignore_exports() {
1221        let toml_str = r#"
1222[[ignoreExports]]
1223file = "src/types/*.ts"
1224exports = ["*"]
1225
1226[[ignoreExports]]
1227file = "src/constants.ts"
1228exports = ["FOO", "BAR"]
1229"#;
1230        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1231        assert_eq!(config.ignore_exports.len(), 2);
1232        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1233        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1234        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1235    }
1236
1237    #[test]
1238    fn fallow_config_deserialize_ignore_dependencies() {
1239        let toml_str = r#"
1240ignoreDependencies = ["autoprefixer", "postcss"]
1241"#;
1242        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1243        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1244    }
1245
1246    #[test]
1247    fn fallow_config_deserialize_ignore_unresolved_imports() {
1248        let toml_str = r#"
1249ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1250"#;
1251        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1252        assert_eq!(
1253            config.ignore_unresolved_imports,
1254            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1255        );
1256    }
1257
1258    #[test]
1259    fn fallow_config_resolve_default_ignores() {
1260        let config = FallowConfig::default();
1261        let resolved = config.resolve(
1262            PathBuf::from("/tmp/test"),
1263            OutputFormat::Human,
1264            4,
1265            true,
1266            true,
1267            None,
1268        );
1269
1270        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1271        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1272        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1273        assert!(resolved.ignore_patterns.is_match(".git/config"));
1274        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1275        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1276        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1277    }
1278
1279    #[test]
1280    fn fallow_config_resolve_custom_ignores() {
1281        let config = FallowConfig {
1282            entry: vec!["src/**/*.ts".to_string()],
1283            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1284            ..Default::default()
1285        };
1286        let resolved = config.resolve(
1287            PathBuf::from("/tmp/test"),
1288            OutputFormat::Json,
1289            4,
1290            false,
1291            true,
1292            None,
1293        );
1294
1295        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1296        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1297        assert!(matches!(resolved.output, OutputFormat::Json));
1298        assert!(!resolved.no_cache);
1299    }
1300
1301    #[test]
1302    fn fallow_config_resolve_cache_dir() {
1303        let config = FallowConfig::default();
1304        let resolved = config.resolve(
1305            PathBuf::from("/tmp/project"),
1306            OutputFormat::Human,
1307            4,
1308            true,
1309            true,
1310            None,
1311        );
1312        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1313        assert!(resolved.no_cache);
1314    }
1315
1316    #[test]
1317    fn package_json_entry_points_main() {
1318        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1319        let entries = pkg.entry_points();
1320        assert!(entries.contains(&"dist/index.js".to_string()));
1321    }
1322
1323    #[test]
1324    fn package_json_entry_points_module() {
1325        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1326        let entries = pkg.entry_points();
1327        assert!(entries.contains(&"dist/index.mjs".to_string()));
1328    }
1329
1330    #[test]
1331    fn package_json_entry_points_types() {
1332        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1333        let entries = pkg.entry_points();
1334        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1335    }
1336
1337    #[test]
1338    fn package_json_entry_points_bin_string() {
1339        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1340        let entries = pkg.entry_points();
1341        assert!(entries.contains(&"bin/cli.js".to_string()));
1342    }
1343
1344    #[test]
1345    fn package_json_entry_points_bin_object() {
1346        let pkg: PackageJson =
1347            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1348                .unwrap();
1349        let entries = pkg.entry_points();
1350        assert!(entries.contains(&"bin/cli.js".to_string()));
1351        assert!(entries.contains(&"bin/serve.js".to_string()));
1352    }
1353
1354    #[test]
1355    fn package_json_entry_points_exports_string() {
1356        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1357        let entries = pkg.entry_points();
1358        assert!(entries.contains(&"./dist/index.js".to_string()));
1359    }
1360
1361    #[test]
1362    fn package_json_entry_points_exports_object() {
1363        let pkg: PackageJson = serde_json::from_str(
1364            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1365        )
1366        .unwrap();
1367        let entries = pkg.entry_points();
1368        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1369        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1370    }
1371
1372    #[test]
1373    fn package_json_dependency_names() {
1374        let pkg: PackageJson = serde_json::from_str(
1375            r#"{
1376            "dependencies": {"react": "^18", "lodash": "^4"},
1377            "devDependencies": {"typescript": "^5"},
1378            "peerDependencies": {"react-dom": "^18"}
1379        }"#,
1380        )
1381        .unwrap();
1382
1383        let all = pkg.all_dependency_names();
1384        assert!(all.contains(&"react".to_string()));
1385        assert!(all.contains(&"lodash".to_string()));
1386        assert!(all.contains(&"typescript".to_string()));
1387        assert!(all.contains(&"react-dom".to_string()));
1388
1389        let prod = pkg.production_dependency_names();
1390        assert!(prod.contains(&"react".to_string()));
1391        assert!(!prod.contains(&"typescript".to_string()));
1392
1393        let dev = pkg.dev_dependency_names();
1394        assert!(dev.contains(&"typescript".to_string()));
1395        assert!(!dev.contains(&"react".to_string()));
1396    }
1397
1398    #[test]
1399    fn package_json_no_dependencies() {
1400        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1401        assert!(pkg.all_dependency_names().is_empty());
1402        assert!(pkg.production_dependency_names().is_empty());
1403        assert!(pkg.dev_dependency_names().is_empty());
1404        assert!(pkg.entry_points().is_empty());
1405    }
1406
1407    #[test]
1408    fn rules_deserialize_toml_kebab_case() {
1409        let toml_str = r#"
1410[rules]
1411unused-files = "error"
1412unused-exports = "warn"
1413unused-types = "off"
1414"#;
1415        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1416        assert_eq!(config.rules.unused_files, Severity::Error);
1417        assert_eq!(config.rules.unused_exports, Severity::Warn);
1418        assert_eq!(config.rules.unused_types, Severity::Off);
1419        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1420    }
1421
1422    #[test]
1423    fn config_without_rules_defaults_to_error() {
1424        let toml_str = r#"
1425entry = ["src/main.ts"]
1426"#;
1427        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1428        assert_eq!(config.rules.unused_files, Severity::Error);
1429        assert_eq!(config.rules.unused_exports, Severity::Error);
1430    }
1431
1432    #[test]
1433    fn fallow_config_denies_unknown_fields() {
1434        let toml_str = r"
1435unknown_field = true
1436";
1437        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1438        assert!(result.is_err());
1439    }
1440
1441    #[test]
1442    fn fallow_config_deserialize_json() {
1443        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1444        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1445        assert_eq!(config.entry, vec!["src/main.ts"]);
1446    }
1447
1448    #[test]
1449    fn fallow_config_deserialize_jsonc() {
1450        let jsonc_str = r#"{
1451            "entry": ["src/main.ts"],
1452            "rules": {
1453                "unused-files": "warn"
1454            }
1455        }"#;
1456        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1457        assert_eq!(config.entry, vec!["src/main.ts"]);
1458        assert_eq!(config.rules.unused_files, Severity::Warn);
1459    }
1460
1461    #[test]
1462    fn fallow_config_json_with_schema_field() {
1463        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1464        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1465        assert_eq!(config.entry, vec!["src/main.ts"]);
1466    }
1467
1468    #[test]
1469    fn fallow_config_json_schema_generation() {
1470        let schema = FallowConfig::json_schema();
1471        assert!(schema.is_object());
1472        let obj = schema.as_object().unwrap();
1473        assert!(obj.contains_key("properties"));
1474    }
1475
1476    #[test]
1477    fn config_format_detection() {
1478        assert!(matches!(
1479            ConfigFormat::from_path(Path::new("fallow.toml")),
1480            ConfigFormat::Toml
1481        ));
1482        assert!(matches!(
1483            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1484            ConfigFormat::Json
1485        ));
1486        assert!(matches!(
1487            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1488            ConfigFormat::Json
1489        ));
1490        assert!(matches!(
1491            ConfigFormat::from_path(Path::new(".fallow.toml")),
1492            ConfigFormat::Toml
1493        ));
1494    }
1495
1496    #[test]
1497    fn config_names_priority_order() {
1498        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1499        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1500        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1501        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1502    }
1503
1504    #[test]
1505    fn load_json_config_file() {
1506        let dir = test_dir("json-config");
1507        let config_path = dir.path().join(".fallowrc.json");
1508        std::fs::write(
1509            &config_path,
1510            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
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_json_config_file_with_health_threshold_override() {
1521        let dir = test_dir("json-health-threshold-override");
1522        let config_path = dir.path().join(".fallowrc.json");
1523        std::fs::write(
1524            &config_path,
1525            r#"{
1526                "health": {
1527                    "thresholdOverrides": [
1528                        {
1529                            "files": ["src/legacy.ts"],
1530                            "functions": ["legacyFlow"],
1531                            "maxCyclomatic": 30,
1532                            "maxCognitive": 25,
1533                            "maxCrap": 80.5,
1534                            "reason": "legacy migration"
1535                        }
1536                    ]
1537                }
1538            }"#,
1539        )
1540        .unwrap();
1541
1542        let config = FallowConfig::load(&config_path).unwrap();
1543        let override_config = &config.health.threshold_overrides[0];
1544        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1545        assert_eq!(override_config.functions, vec!["legacyFlow"]);
1546        assert_eq!(override_config.max_cyclomatic, Some(30));
1547        assert_eq!(override_config.max_cognitive, Some(25));
1548        assert_eq!(override_config.max_crap, Some(80.5));
1549        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1550    }
1551
1552    #[test]
1553    fn load_jsonc_config_file() {
1554        let dir = test_dir("jsonc-config");
1555        let config_path = dir.path().join(".fallowrc.json");
1556        std::fs::write(
1557            &config_path,
1558            r#"{
1559                "entry": ["src/index.ts"],
1560                /* Block comment */
1561                "rules": {
1562                    "unused-exports": "warn"
1563                }
1564            }"#,
1565        )
1566        .unwrap();
1567
1568        let config = FallowConfig::load(&config_path).unwrap();
1569        assert_eq!(config.entry, vec!["src/index.ts"]);
1570        assert_eq!(config.rules.unused_exports, Severity::Warn);
1571    }
1572
1573    #[test]
1574    fn load_jsonc_config_file_with_health_threshold_override() {
1575        let dir = test_dir("jsonc-health-threshold-override");
1576        let config_path = dir.path().join(".fallowrc.jsonc");
1577        std::fs::write(
1578            &config_path,
1579            r#"{
1580                "health": {
1581                    // Empty functions means every function in matching files.
1582                    "thresholdOverrides": [
1583                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1584                    ]
1585                }
1586            }"#,
1587        )
1588        .unwrap();
1589
1590        let config = FallowConfig::load(&config_path).unwrap();
1591        let override_config = &config.health.threshold_overrides[0];
1592        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1593        assert!(override_config.functions.is_empty());
1594        assert_eq!(override_config.max_cognitive, Some(25));
1595    }
1596
1597    #[test]
1598    fn load_fallowrc_jsonc_extension() {
1599        let dir = test_dir("jsonc-extension");
1600        let config_path = dir.path().join(".fallowrc.jsonc");
1601        std::fs::write(
1602            &config_path,
1603            r#"{
1604                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1605                "entry": ["src/index.ts"]
1606            }"#,
1607        )
1608        .unwrap();
1609
1610        let config = FallowConfig::load(&config_path).unwrap();
1611        assert_eq!(config.entry, vec!["src/index.ts"]);
1612        assert_eq!(
1613            config.ignore_dependencies,
1614            vec!["tailwindcss-react-aria-components"]
1615        );
1616    }
1617
1618    #[test]
1619    fn json_config_ignore_dependencies_camel_case() {
1620        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1621        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1622        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1623    }
1624
1625    #[test]
1626    fn json_config_ignore_unresolved_imports_camel_case() {
1627        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1628        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1629        assert_eq!(
1630            config.ignore_unresolved_imports,
1631            vec!["@example/icons", "@example/icons/**"]
1632        );
1633    }
1634
1635    #[test]
1636    fn json_config_all_fields() {
1637        let json_str = r#"{
1638            "ignoreDependencies": ["lodash"],
1639            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1640            "rules": {
1641                "unused-files": "off",
1642                "unused-exports": "warn",
1643                "unused-dependencies": "error",
1644                "unused-dev-dependencies": "off",
1645                "unused-types": "warn",
1646                "unused-enum-members": "error",
1647                "unused-class-members": "off",
1648                "unresolved-imports": "warn",
1649                "unlisted-dependencies": "error",
1650                "duplicate-exports": "off"
1651            },
1652            "duplicates": {
1653                "minTokens": 100,
1654                "minLines": 10,
1655                "skipLocal": true
1656            }
1657        }"#;
1658        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1659        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1660        assert_eq!(config.rules.unused_files, Severity::Off);
1661        assert_eq!(config.rules.unused_exports, Severity::Warn);
1662        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1663        assert_eq!(config.duplicates.min_tokens, 100);
1664        assert_eq!(config.duplicates.min_lines, 10);
1665        assert!(config.duplicates.skip_local);
1666    }
1667
1668    #[test]
1669    fn extends_single_base() {
1670        let dir = test_dir("extends-single");
1671
1672        std::fs::write(
1673            dir.path().join("base.json"),
1674            r#"{"rules": {"unused-files": "warn"}}"#,
1675        )
1676        .unwrap();
1677        std::fs::write(
1678            dir.path().join(".fallowrc.json"),
1679            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1680        )
1681        .unwrap();
1682
1683        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1684        assert_eq!(config.rules.unused_files, Severity::Warn);
1685        assert_eq!(config.entry, vec!["src/index.ts"]);
1686        assert_eq!(config.rules.unused_exports, Severity::Error);
1687    }
1688
1689    #[test]
1690    fn extends_overlay_overrides_base() {
1691        let dir = test_dir("extends-overlay");
1692
1693        std::fs::write(
1694            dir.path().join("base.json"),
1695            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1696        )
1697        .unwrap();
1698        std::fs::write(
1699            dir.path().join(".fallowrc.json"),
1700            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1701        )
1702        .unwrap();
1703
1704        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1705        assert_eq!(config.rules.unused_files, Severity::Error);
1706        assert_eq!(config.rules.unused_exports, Severity::Off);
1707    }
1708
1709    #[test]
1710    fn extends_chained() {
1711        let dir = test_dir("extends-chained");
1712
1713        std::fs::write(
1714            dir.path().join("grandparent.json"),
1715            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1716        )
1717        .unwrap();
1718        std::fs::write(
1719            dir.path().join("parent.json"),
1720            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1721        )
1722        .unwrap();
1723        std::fs::write(
1724            dir.path().join(".fallowrc.json"),
1725            r#"{"extends": ["parent.json"]}"#,
1726        )
1727        .unwrap();
1728
1729        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1730        assert_eq!(config.rules.unused_files, Severity::Warn);
1731        assert_eq!(config.rules.unused_exports, Severity::Warn);
1732    }
1733
1734    #[test]
1735    fn extends_circular_detected() {
1736        let dir = test_dir("extends-circular");
1737
1738        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1739        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1740
1741        let result = FallowConfig::load(&dir.path().join("a.json"));
1742        assert!(result.is_err());
1743        let err_msg = format!("{}", result.unwrap_err());
1744        assert!(
1745            err_msg.contains("Circular extends"),
1746            "Expected circular error, got: {err_msg}"
1747        );
1748    }
1749
1750    #[test]
1751    fn extends_missing_file_errors() {
1752        let dir = test_dir("extends-missing");
1753
1754        std::fs::write(
1755            dir.path().join(".fallowrc.json"),
1756            r#"{"extends": ["nonexistent.json"]}"#,
1757        )
1758        .unwrap();
1759
1760        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1761        assert!(result.is_err());
1762        let err_msg = format!("{}", result.unwrap_err());
1763        assert!(
1764            err_msg.contains("not found"),
1765            "Expected not found error, got: {err_msg}"
1766        );
1767    }
1768
1769    #[test]
1770    fn sealed_allows_in_directory_extends() {
1771        let dir = test_dir("sealed-allows-local");
1772        std::fs::write(
1773            dir.path().join("base.json"),
1774            r#"{"ignorePatterns": ["gen/**"]}"#,
1775        )
1776        .unwrap();
1777        std::fs::write(
1778            dir.path().join(".fallowrc.json"),
1779            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1780        )
1781        .unwrap();
1782
1783        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1784        assert!(config.sealed);
1785        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1786    }
1787
1788    #[test]
1789    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
1790        let dir = test_dir("boundary-coverage-invalid-glob");
1791        std::fs::write(
1792            dir.path().join(".fallowrc.json"),
1793            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
1794        )
1795        .unwrap();
1796
1797        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1798        assert!(result.is_err());
1799        let err_msg = format!("{}", result.unwrap_err());
1800        assert!(
1801            err_msg.contains("boundaries.coverage.allowUnmatched"),
1802            "expected coverage field in error, got: {err_msg}"
1803        );
1804    }
1805
1806    #[test]
1807    fn sealed_rejects_extends_escaping_directory() {
1808        let dir = test_dir("sealed-rejects-escape");
1809        let sub = dir.path().join("packages").join("app");
1810        std::fs::create_dir_all(&sub).unwrap();
1811
1812        std::fs::write(
1813            dir.path().join("base.json"),
1814            r#"{"ignorePatterns": ["dist/**"]}"#,
1815        )
1816        .unwrap();
1817        std::fs::write(
1818            sub.join(".fallowrc.json"),
1819            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1820        )
1821        .unwrap();
1822
1823        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1824        assert!(
1825            result.is_err(),
1826            "Expected sealed config to reject escaping extends"
1827        );
1828        let err_msg = format!("{}", result.unwrap_err());
1829        assert!(
1830            err_msg.contains("sealed"),
1831            "Error must mention sealed: {err_msg}"
1832        );
1833        assert!(
1834            err_msg.contains("outside the config's directory"),
1835            "Error must explain the constraint: {err_msg}"
1836        );
1837    }
1838
1839    #[test]
1840    fn sealed_rejects_https_extends() {
1841        let dir = test_dir("sealed-rejects-https");
1842        std::fs::write(
1843            dir.path().join(".fallowrc.json"),
1844            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1845        )
1846        .unwrap();
1847
1848        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1849        assert!(result.is_err());
1850        let err_msg = format!("{}", result.unwrap_err());
1851        assert!(
1852            err_msg.contains("sealed"),
1853            "Error must mention sealed: {err_msg}"
1854        );
1855        assert!(
1856            err_msg.contains("URL extends"),
1857            "Error must mention URL: {err_msg}"
1858        );
1859    }
1860
1861    #[test]
1862    fn sealed_rejects_npm_extends() {
1863        let dir = test_dir("sealed-rejects-npm");
1864        std::fs::write(
1865            dir.path().join(".fallowrc.json"),
1866            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1867        )
1868        .unwrap();
1869
1870        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1871        assert!(result.is_err());
1872        let err_msg = format!("{}", result.unwrap_err());
1873        assert!(
1874            err_msg.contains("sealed"),
1875            "Error must mention sealed: {err_msg}"
1876        );
1877        assert!(
1878            err_msg.contains("npm extends"),
1879            "Error must mention npm: {err_msg}"
1880        );
1881    }
1882
1883    #[test]
1884    fn sealed_default_is_false() {
1885        let dir = test_dir("sealed-default");
1886        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1887        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1888        assert!(!config.sealed);
1889    }
1890
1891    #[test]
1892    fn sealed_false_allows_escaping_extends() {
1893        let dir = test_dir("sealed-false-allows");
1894        let sub = dir.path().join("packages").join("app");
1895        std::fs::create_dir_all(&sub).unwrap();
1896
1897        std::fs::write(
1898            dir.path().join("base.json"),
1899            r#"{"ignorePatterns": ["dist/**"]}"#,
1900        )
1901        .unwrap();
1902        std::fs::write(
1903            sub.join(".fallowrc.json"),
1904            r#"{"extends": ["../../base.json"]}"#,
1905        )
1906        .unwrap();
1907
1908        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1909        assert!(!config.sealed);
1910        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1911    }
1912
1913    #[test]
1914    fn extends_string_sugar() {
1915        let dir = test_dir("extends-string");
1916
1917        std::fs::write(
1918            dir.path().join("base.json"),
1919            r#"{"ignorePatterns": ["gen/**"]}"#,
1920        )
1921        .unwrap();
1922        std::fs::write(
1923            dir.path().join(".fallowrc.json"),
1924            r#"{"extends": "base.json"}"#,
1925        )
1926        .unwrap();
1927
1928        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1929        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1930    }
1931
1932    #[test]
1933    fn extends_deep_merge_preserves_arrays() {
1934        let dir = test_dir("extends-array");
1935
1936        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1937        std::fs::write(
1938            dir.path().join(".fallowrc.json"),
1939            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1940        )
1941        .unwrap();
1942
1943        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1944        assert_eq!(config.entry, vec!["src/b.ts"]);
1945    }
1946
1947    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1948        let pkg_dir = root.join("node_modules").join(name);
1949        std::fs::create_dir_all(&pkg_dir).unwrap();
1950        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1951    }
1952
1953    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1954        let pkg_dir = root.join("node_modules").join(name);
1955        std::fs::create_dir_all(&pkg_dir).unwrap();
1956        std::fs::write(
1957            pkg_dir.join("package.json"),
1958            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1959        )
1960        .unwrap();
1961        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1962    }
1963
1964    #[test]
1965    fn extends_npm_basic_unscoped() {
1966        let dir = test_dir("npm-basic");
1967        create_npm_package(
1968            dir.path(),
1969            "fallow-config-acme",
1970            r#"{"rules": {"unused-files": "warn"}}"#,
1971        );
1972        std::fs::write(
1973            dir.path().join(".fallowrc.json"),
1974            r#"{"extends": "npm:fallow-config-acme"}"#,
1975        )
1976        .unwrap();
1977
1978        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1979        assert_eq!(config.rules.unused_files, Severity::Warn);
1980    }
1981
1982    #[test]
1983    fn extends_npm_scoped_package() {
1984        let dir = test_dir("npm-scoped");
1985        create_npm_package(
1986            dir.path(),
1987            "@company/fallow-config",
1988            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1989        );
1990        std::fs::write(
1991            dir.path().join(".fallowrc.json"),
1992            r#"{"extends": "npm:@company/fallow-config"}"#,
1993        )
1994        .unwrap();
1995
1996        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1997        assert_eq!(config.rules.unused_exports, Severity::Off);
1998        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1999    }
2000
2001    #[test]
2002    fn extends_npm_with_subpath() {
2003        let dir = test_dir("npm-subpath");
2004        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2005        std::fs::create_dir_all(&pkg_dir).unwrap();
2006        std::fs::write(
2007            pkg_dir.join("strict.json"),
2008            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2009        )
2010        .unwrap();
2011
2012        std::fs::write(
2013            dir.path().join(".fallowrc.json"),
2014            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2015        )
2016        .unwrap();
2017
2018        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2019        assert_eq!(config.rules.unused_files, Severity::Error);
2020        assert_eq!(config.rules.unused_exports, Severity::Error);
2021    }
2022
2023    #[test]
2024    fn extends_npm_package_json_main() {
2025        let dir = test_dir("npm-main");
2026        create_npm_package_with_main(
2027            dir.path(),
2028            "fallow-config-acme",
2029            "config.json",
2030            r#"{"rules": {"unused-types": "off"}}"#,
2031        );
2032        std::fs::write(
2033            dir.path().join(".fallowrc.json"),
2034            r#"{"extends": "npm:fallow-config-acme"}"#,
2035        )
2036        .unwrap();
2037
2038        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2039        assert_eq!(config.rules.unused_types, Severity::Off);
2040    }
2041
2042    #[test]
2043    fn extends_npm_package_json_exports_string() {
2044        let dir = test_dir("npm-exports-str");
2045        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2046        std::fs::create_dir_all(&pkg_dir).unwrap();
2047        std::fs::write(
2048            pkg_dir.join("package.json"),
2049            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2050        )
2051        .unwrap();
2052        std::fs::write(
2053            pkg_dir.join("base.json"),
2054            r#"{"rules": {"circular-dependencies": "warn"}}"#,
2055        )
2056        .unwrap();
2057
2058        std::fs::write(
2059            dir.path().join(".fallowrc.json"),
2060            r#"{"extends": "npm:fallow-config-co"}"#,
2061        )
2062        .unwrap();
2063
2064        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2065        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2066    }
2067
2068    #[test]
2069    fn extends_npm_package_json_exports_object() {
2070        let dir = test_dir("npm-exports-obj");
2071        let pkg_dir = dir.path().join("node_modules/@co/cfg");
2072        std::fs::create_dir_all(&pkg_dir).unwrap();
2073        std::fs::write(
2074            pkg_dir.join("package.json"),
2075            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2076        )
2077        .unwrap();
2078        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2079
2080        std::fs::write(
2081            dir.path().join(".fallowrc.json"),
2082            r#"{"extends": "npm:@co/cfg"}"#,
2083        )
2084        .unwrap();
2085
2086        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2087        assert_eq!(config.entry, vec!["src/app.ts"]);
2088    }
2089
2090    #[test]
2091    fn extends_npm_exports_takes_priority_over_main() {
2092        let dir = test_dir("npm-exports-prio");
2093        let pkg_dir = dir.path().join("node_modules/my-config");
2094        std::fs::create_dir_all(&pkg_dir).unwrap();
2095        std::fs::write(
2096            pkg_dir.join("package.json"),
2097            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2098        )
2099        .unwrap();
2100        std::fs::write(
2101            pkg_dir.join("old.json"),
2102            r#"{"rules": {"unused-files": "off"}}"#,
2103        )
2104        .unwrap();
2105        std::fs::write(
2106            pkg_dir.join("new.json"),
2107            r#"{"rules": {"unused-files": "warn"}}"#,
2108        )
2109        .unwrap();
2110
2111        std::fs::write(
2112            dir.path().join(".fallowrc.json"),
2113            r#"{"extends": "npm:my-config"}"#,
2114        )
2115        .unwrap();
2116
2117        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2118        assert_eq!(config.rules.unused_files, Severity::Warn);
2119    }
2120
2121    #[test]
2122    fn extends_npm_walk_up_directories() {
2123        let dir = test_dir("npm-walkup");
2124        create_npm_package(
2125            dir.path(),
2126            "shared-config",
2127            r#"{"rules": {"unused-files": "warn"}}"#,
2128        );
2129        let sub = dir.path().join("packages/app");
2130        std::fs::create_dir_all(&sub).unwrap();
2131        std::fs::write(
2132            sub.join(".fallowrc.json"),
2133            r#"{"extends": "npm:shared-config"}"#,
2134        )
2135        .unwrap();
2136
2137        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2138        assert_eq!(config.rules.unused_files, Severity::Warn);
2139    }
2140
2141    #[test]
2142    fn extends_npm_overlay_overrides_base() {
2143        let dir = test_dir("npm-overlay");
2144        create_npm_package(
2145            dir.path(),
2146            "@company/base",
2147            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2148        );
2149        std::fs::write(
2150            dir.path().join(".fallowrc.json"),
2151            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2152        )
2153        .unwrap();
2154
2155        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2156        assert_eq!(config.rules.unused_files, Severity::Error);
2157        assert_eq!(config.rules.unused_exports, Severity::Off);
2158        assert_eq!(config.entry, vec!["src/app.ts"]);
2159    }
2160
2161    #[test]
2162    fn extends_npm_chained_with_relative() {
2163        let dir = test_dir("npm-chained");
2164        let pkg_dir = dir.path().join("node_modules/my-config");
2165        std::fs::create_dir_all(&pkg_dir).unwrap();
2166        std::fs::write(
2167            pkg_dir.join("base.json"),
2168            r#"{"rules": {"unused-files": "warn"}}"#,
2169        )
2170        .unwrap();
2171        std::fs::write(
2172            pkg_dir.join(".fallowrc.json"),
2173            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2174        )
2175        .unwrap();
2176
2177        std::fs::write(
2178            dir.path().join(".fallowrc.json"),
2179            r#"{"extends": "npm:my-config"}"#,
2180        )
2181        .unwrap();
2182
2183        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2184        assert_eq!(config.rules.unused_files, Severity::Warn);
2185        assert_eq!(config.rules.unused_exports, Severity::Off);
2186    }
2187
2188    #[test]
2189    fn extends_npm_mixed_with_relative_paths() {
2190        let dir = test_dir("npm-mixed");
2191        create_npm_package(
2192            dir.path(),
2193            "shared-base",
2194            r#"{"rules": {"unused-files": "off"}}"#,
2195        );
2196        std::fs::write(
2197            dir.path().join("local-overrides.json"),
2198            r#"{"rules": {"unused-files": "warn"}}"#,
2199        )
2200        .unwrap();
2201        std::fs::write(
2202            dir.path().join(".fallowrc.json"),
2203            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2204        )
2205        .unwrap();
2206
2207        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2208        assert_eq!(config.rules.unused_files, Severity::Warn);
2209    }
2210
2211    #[test]
2212    fn extends_npm_missing_package_errors() {
2213        let dir = test_dir("npm-missing");
2214        std::fs::write(
2215            dir.path().join(".fallowrc.json"),
2216            r#"{"extends": "npm:nonexistent-package"}"#,
2217        )
2218        .unwrap();
2219
2220        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2221        assert!(result.is_err());
2222        let err_msg = format!("{}", result.unwrap_err());
2223        assert!(
2224            err_msg.contains("not found"),
2225            "Expected 'not found' error, got: {err_msg}"
2226        );
2227        assert!(
2228            err_msg.contains("nonexistent-package"),
2229            "Expected package name in error, got: {err_msg}"
2230        );
2231        assert!(
2232            err_msg.contains("install it"),
2233            "Expected install hint in error, got: {err_msg}"
2234        );
2235    }
2236
2237    #[test]
2238    fn extends_npm_no_config_in_package_errors() {
2239        let dir = test_dir("npm-no-config");
2240        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2241        std::fs::create_dir_all(&pkg_dir).unwrap();
2242        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2243
2244        std::fs::write(
2245            dir.path().join(".fallowrc.json"),
2246            r#"{"extends": "npm:empty-pkg"}"#,
2247        )
2248        .unwrap();
2249
2250        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2251        assert!(result.is_err());
2252        let err_msg = format!("{}", result.unwrap_err());
2253        assert!(
2254            err_msg.contains("No fallow config found"),
2255            "Expected 'No fallow config found' error, got: {err_msg}"
2256        );
2257    }
2258
2259    #[test]
2260    fn extends_npm_missing_subpath_errors() {
2261        let dir = test_dir("npm-missing-sub");
2262        let pkg_dir = dir.path().join("node_modules/@co/config");
2263        std::fs::create_dir_all(&pkg_dir).unwrap();
2264
2265        std::fs::write(
2266            dir.path().join(".fallowrc.json"),
2267            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2268        )
2269        .unwrap();
2270
2271        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2272        assert!(result.is_err());
2273        let err_msg = format!("{}", result.unwrap_err());
2274        assert!(
2275            err_msg.contains("nonexistent.json"),
2276            "Expected subpath in error, got: {err_msg}"
2277        );
2278    }
2279
2280    #[test]
2281    fn extends_npm_empty_specifier_errors() {
2282        let dir = test_dir("npm-empty");
2283        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2284
2285        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2286        assert!(result.is_err());
2287        let err_msg = format!("{}", result.unwrap_err());
2288        assert!(
2289            err_msg.contains("Empty npm specifier"),
2290            "Expected 'Empty npm specifier' error, got: {err_msg}"
2291        );
2292    }
2293
2294    #[test]
2295    fn extends_npm_space_after_colon_trimmed() {
2296        let dir = test_dir("npm-space");
2297        create_npm_package(
2298            dir.path(),
2299            "fallow-config-acme",
2300            r#"{"rules": {"unused-files": "warn"}}"#,
2301        );
2302        std::fs::write(
2303            dir.path().join(".fallowrc.json"),
2304            r#"{"extends": "npm: fallow-config-acme"}"#,
2305        )
2306        .unwrap();
2307
2308        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2309        assert_eq!(config.rules.unused_files, Severity::Warn);
2310    }
2311
2312    #[test]
2313    fn extends_npm_exports_node_condition() {
2314        let dir = test_dir("npm-node-cond");
2315        let pkg_dir = dir.path().join("node_modules/node-config");
2316        std::fs::create_dir_all(&pkg_dir).unwrap();
2317        std::fs::write(
2318            pkg_dir.join("package.json"),
2319            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2320        )
2321        .unwrap();
2322        std::fs::write(
2323            pkg_dir.join("node.json"),
2324            r#"{"rules": {"unused-files": "off"}}"#,
2325        )
2326        .unwrap();
2327
2328        std::fs::write(
2329            dir.path().join(".fallowrc.json"),
2330            r#"{"extends": "npm:node-config"}"#,
2331        )
2332        .unwrap();
2333
2334        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2335        assert_eq!(config.rules.unused_files, Severity::Off);
2336    }
2337
2338    #[test]
2339    fn parse_npm_specifier_unscoped() {
2340        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2341    }
2342
2343    #[test]
2344    fn parse_npm_specifier_unscoped_with_subpath() {
2345        assert_eq!(
2346            parse_npm_specifier("my-config/strict.json"),
2347            ("my-config", Some("strict.json"))
2348        );
2349    }
2350
2351    #[test]
2352    fn parse_npm_specifier_scoped() {
2353        assert_eq!(
2354            parse_npm_specifier("@company/fallow-config"),
2355            ("@company/fallow-config", None)
2356        );
2357    }
2358
2359    #[test]
2360    fn parse_npm_specifier_scoped_with_subpath() {
2361        assert_eq!(
2362            parse_npm_specifier("@company/fallow-config/strict.json"),
2363            ("@company/fallow-config", Some("strict.json"))
2364        );
2365    }
2366
2367    #[test]
2368    fn parse_npm_specifier_scoped_with_nested_subpath() {
2369        assert_eq!(
2370            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2371            ("@company/fallow-config", Some("presets/strict.json"))
2372        );
2373    }
2374
2375    #[test]
2376    fn extends_npm_subpath_traversal_rejected() {
2377        let dir = test_dir("npm-traversal-sub");
2378        let pkg_dir = dir.path().join("node_modules/evil-pkg");
2379        std::fs::create_dir_all(&pkg_dir).unwrap();
2380        std::fs::write(
2381            dir.path().join("secret.json"),
2382            r#"{"entry": ["stolen.ts"]}"#,
2383        )
2384        .unwrap();
2385
2386        std::fs::write(
2387            dir.path().join(".fallowrc.json"),
2388            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2389        )
2390        .unwrap();
2391
2392        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2393        assert!(result.is_err());
2394        let err_msg = format!("{}", result.unwrap_err());
2395        assert!(
2396            err_msg.contains("traversal") || err_msg.contains("not found"),
2397            "Expected traversal or not-found error, got: {err_msg}"
2398        );
2399    }
2400
2401    #[test]
2402    fn extends_npm_dotdot_package_name_rejected() {
2403        let dir = test_dir("npm-dotdot-name");
2404        std::fs::write(
2405            dir.path().join(".fallowrc.json"),
2406            r#"{"extends": "npm:../relative"}"#,
2407        )
2408        .unwrap();
2409
2410        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2411        assert!(result.is_err());
2412        let err_msg = format!("{}", result.unwrap_err());
2413        assert!(
2414            err_msg.contains("path traversal"),
2415            "Expected 'path traversal' error, got: {err_msg}"
2416        );
2417    }
2418
2419    #[test]
2420    fn extends_npm_scoped_without_name_rejected() {
2421        let dir = test_dir("npm-scope-only");
2422        std::fs::write(
2423            dir.path().join(".fallowrc.json"),
2424            r#"{"extends": "npm:@scope"}"#,
2425        )
2426        .unwrap();
2427
2428        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2429        assert!(result.is_err());
2430        let err_msg = format!("{}", result.unwrap_err());
2431        assert!(
2432            err_msg.contains("@scope/name"),
2433            "Expected scoped name format error, got: {err_msg}"
2434        );
2435    }
2436
2437    #[test]
2438    fn extends_npm_malformed_package_json_errors() {
2439        let dir = test_dir("npm-bad-pkgjson");
2440        let pkg_dir = dir.path().join("node_modules/bad-pkg");
2441        std::fs::create_dir_all(&pkg_dir).unwrap();
2442        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2443
2444        std::fs::write(
2445            dir.path().join(".fallowrc.json"),
2446            r#"{"extends": "npm:bad-pkg"}"#,
2447        )
2448        .unwrap();
2449
2450        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2451        assert!(result.is_err());
2452        let err_msg = format!("{}", result.unwrap_err());
2453        assert!(
2454            err_msg.contains("Failed to parse"),
2455            "Expected parse error, got: {err_msg}"
2456        );
2457    }
2458
2459    #[test]
2460    fn extends_npm_exports_traversal_rejected() {
2461        let dir = test_dir("npm-exports-escape");
2462        let pkg_dir = dir.path().join("node_modules/evil-exports");
2463        std::fs::create_dir_all(&pkg_dir).unwrap();
2464        std::fs::write(
2465            pkg_dir.join("package.json"),
2466            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2467        )
2468        .unwrap();
2469        std::fs::write(
2470            dir.path().join("secret.json"),
2471            r#"{"entry": ["stolen.ts"]}"#,
2472        )
2473        .unwrap();
2474
2475        std::fs::write(
2476            dir.path().join(".fallowrc.json"),
2477            r#"{"extends": "npm:evil-exports"}"#,
2478        )
2479        .unwrap();
2480
2481        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2482        assert!(result.is_err());
2483        let err_msg = format!("{}", result.unwrap_err());
2484        assert!(
2485            err_msg.contains("traversal"),
2486            "Expected traversal error, got: {err_msg}"
2487        );
2488    }
2489
2490    #[test]
2491    fn deep_merge_scalar_overlay_replaces_base() {
2492        let mut base = serde_json::json!("hello");
2493        deep_merge_json(&mut base, serde_json::json!("world"));
2494        assert_eq!(base, serde_json::json!("world"));
2495    }
2496
2497    #[test]
2498    fn deep_merge_array_overlay_replaces_base() {
2499        let mut base = serde_json::json!(["a", "b"]);
2500        deep_merge_json(&mut base, serde_json::json!(["c"]));
2501        assert_eq!(base, serde_json::json!(["c"]));
2502    }
2503
2504    #[test]
2505    fn deep_merge_nested_object_merge() {
2506        let mut base = serde_json::json!({
2507            "level1": {
2508                "level2": {
2509                    "a": 1,
2510                    "b": 2
2511                }
2512            }
2513        });
2514        let overlay = serde_json::json!({
2515            "level1": {
2516                "level2": {
2517                    "b": 99,
2518                    "c": 3
2519                }
2520            }
2521        });
2522        deep_merge_json(&mut base, overlay);
2523        assert_eq!(base["level1"]["level2"]["a"], 1);
2524        assert_eq!(base["level1"]["level2"]["b"], 99);
2525        assert_eq!(base["level1"]["level2"]["c"], 3);
2526    }
2527
2528    #[test]
2529    fn deep_merge_overlay_adds_new_fields() {
2530        let mut base = serde_json::json!({"existing": true});
2531        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2532        deep_merge_json(&mut base, overlay);
2533        assert_eq!(base["existing"], true);
2534        assert_eq!(base["new_field"], "added");
2535        assert_eq!(base["another"], 42);
2536    }
2537
2538    #[test]
2539    fn deep_merge_null_overlay_replaces_object() {
2540        let mut base = serde_json::json!({"key": "value"});
2541        deep_merge_json(&mut base, serde_json::json!(null));
2542        assert_eq!(base, serde_json::json!(null));
2543    }
2544
2545    #[test]
2546    fn deep_merge_empty_object_overlay_preserves_base() {
2547        let mut base = serde_json::json!({"a": 1, "b": 2});
2548        deep_merge_json(&mut base, serde_json::json!({}));
2549        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2550    }
2551
2552    #[test]
2553    fn rules_severity_error_warn_off_from_json() {
2554        let json_str = r#"{
2555            "rules": {
2556                "unused-files": "error",
2557                "unused-exports": "warn",
2558                "unused-types": "off"
2559            }
2560        }"#;
2561        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2562        assert_eq!(config.rules.unused_files, Severity::Error);
2563        assert_eq!(config.rules.unused_exports, Severity::Warn);
2564        assert_eq!(config.rules.unused_types, Severity::Off);
2565    }
2566
2567    #[test]
2568    fn rules_omitted_default_to_error() {
2569        let json_str = r#"{
2570            "rules": {
2571                "unused-files": "warn"
2572            }
2573        }"#;
2574        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2575        assert_eq!(config.rules.unused_files, Severity::Warn);
2576        assert_eq!(config.rules.unused_exports, Severity::Error);
2577        assert_eq!(config.rules.unused_types, Severity::Error);
2578        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2579        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2580        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2581        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2582        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2583        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2584    }
2585
2586    #[test]
2587    fn find_and_load_returns_none_when_no_config() {
2588        let dir = test_dir("find-none");
2589        std::fs::create_dir(dir.path().join(".git")).unwrap();
2590
2591        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2592        assert!(result.is_none());
2593    }
2594
2595    #[test]
2596    fn find_and_load_finds_fallowrc_json() {
2597        let dir = test_dir("find-json");
2598        std::fs::create_dir(dir.path().join(".git")).unwrap();
2599        std::fs::write(
2600            dir.path().join(".fallowrc.json"),
2601            r#"{"entry": ["src/main.ts"]}"#,
2602        )
2603        .unwrap();
2604
2605        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2606        assert_eq!(config.entry, vec!["src/main.ts"]);
2607        assert!(path.ends_with(".fallowrc.json"));
2608    }
2609
2610    #[test]
2611    fn find_and_load_finds_fallowrc_jsonc() {
2612        let dir = test_dir("find-jsonc");
2613        std::fs::create_dir(dir.path().join(".git")).unwrap();
2614        std::fs::write(
2615            dir.path().join(".fallowrc.jsonc"),
2616            r#"{
2617                "entry": ["src/main.ts"]
2618            }"#,
2619        )
2620        .unwrap();
2621
2622        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2623        assert_eq!(config.entry, vec!["src/main.ts"]);
2624        assert!(path.ends_with(".fallowrc.jsonc"));
2625    }
2626
2627    #[test]
2628    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2629        let dir = test_dir("find-json-vs-jsonc");
2630        std::fs::create_dir(dir.path().join(".git")).unwrap();
2631        std::fs::write(
2632            dir.path().join(".fallowrc.json"),
2633            r#"{"entry": ["from-json.ts"]}"#,
2634        )
2635        .unwrap();
2636        std::fs::write(
2637            dir.path().join(".fallowrc.jsonc"),
2638            r#"{"entry": ["from-jsonc.ts"]}"#,
2639        )
2640        .unwrap();
2641
2642        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2643        assert_eq!(config.entry, vec!["from-json.ts"]);
2644        assert!(path.ends_with(".fallowrc.json"));
2645    }
2646
2647    #[test]
2648    fn find_and_load_prefers_fallowrc_json_over_toml() {
2649        let dir = test_dir("find-priority");
2650        std::fs::create_dir(dir.path().join(".git")).unwrap();
2651        std::fs::write(
2652            dir.path().join(".fallowrc.json"),
2653            r#"{"entry": ["from-json.ts"]}"#,
2654        )
2655        .unwrap();
2656        std::fs::write(
2657            dir.path().join("fallow.toml"),
2658            "entry = [\"from-toml.ts\"]\n",
2659        )
2660        .unwrap();
2661
2662        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2663        assert_eq!(config.entry, vec!["from-json.ts"]);
2664        assert!(path.ends_with(".fallowrc.json"));
2665    }
2666
2667    #[test]
2668    fn shadowed_config_names_empty_when_single_config() {
2669        let dir = test_dir("shadow-single");
2670        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2671        assert!(shadowed_config_names(dir.path(), 0).is_empty());
2672    }
2673
2674    #[test]
2675    fn shadowed_config_names_reports_lower_precedence_toml() {
2676        let dir = test_dir("shadow-json-toml");
2677        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2678        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2679        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2680    }
2681
2682    #[test]
2683    fn shadowed_config_names_reports_jsonc_sibling() {
2684        let dir = test_dir("shadow-json-jsonc");
2685        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2686        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2687        assert_eq!(
2688            shadowed_config_names(dir.path(), 0),
2689            vec![".fallowrc.jsonc"]
2690        );
2691    }
2692
2693    #[test]
2694    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2695        let dir = test_dir("shadow-all-four");
2696        for name in CONFIG_NAMES {
2697            std::fs::write(dir.path().join(name), "").unwrap();
2698        }
2699        assert_eq!(
2700            shadowed_config_names(dir.path(), 0),
2701            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2702        );
2703    }
2704
2705    #[test]
2706    fn shadowed_config_names_scoped_to_indices_after_winner() {
2707        let dir = test_dir("shadow-toml-dottoml");
2708        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2709        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2710        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2711    }
2712
2713    #[test]
2714    fn find_and_load_warns_when_configs_coexist() {
2715        let dir = test_dir("coexist-warn");
2716        std::fs::create_dir(dir.path().join(".git")).unwrap();
2717        std::fs::write(
2718            dir.path().join(".fallowrc.json"),
2719            r#"{"entry": ["from-json.ts"]}"#,
2720        )
2721        .unwrap();
2722        std::fs::write(
2723            dir.path().join("fallow.toml"),
2724            "entry = [\"from-toml.ts\"]\n",
2725        )
2726        .unwrap();
2727
2728        let (result, captured) =
2729            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2730
2731        let (config, path) = result.unwrap().unwrap();
2732        assert_eq!(config.entry, vec!["from-json.ts"]);
2733        assert!(path.ends_with(".fallowrc.json"));
2734
2735        assert_eq!(captured.len(), 1);
2736        let (chosen, shadowed) = &captured[0];
2737        assert_eq!(chosen, ".fallowrc.json");
2738        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2739    }
2740
2741    #[test]
2742    fn find_and_load_does_not_warn_for_single_config() {
2743        let dir = test_dir("coexist-none");
2744        std::fs::create_dir(dir.path().join(".git")).unwrap();
2745        std::fs::write(
2746            dir.path().join(".fallowrc.json"),
2747            r#"{"entry": ["only.ts"]}"#,
2748        )
2749        .unwrap();
2750
2751        let (result, captured) =
2752            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2753        assert!(result.unwrap().is_some());
2754        assert!(captured.is_empty());
2755    }
2756
2757    #[test]
2758    fn find_and_load_warns_per_directory_independently() {
2759        let make = |name: &str| {
2760            let dir = test_dir(name);
2761            std::fs::create_dir(dir.path().join(".git")).unwrap();
2762            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2763            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2764            dir
2765        };
2766        let first = make("coexist-dir-a");
2767        let second = make("coexist-dir-b");
2768
2769        let ((), captured) = capture_coexisting_config_warnings(|| {
2770            FallowConfig::find_and_load(first.path()).unwrap();
2771            FallowConfig::find_and_load(second.path()).unwrap();
2772        });
2773
2774        assert_eq!(captured.len(), 2);
2775        assert!(captured.iter().all(|(chosen, shadowed)| {
2776            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2777        }));
2778    }
2779
2780    #[test]
2781    fn explicit_load_does_not_warn_about_coexisting_configs() {
2782        let dir = test_dir("coexist-explicit");
2783        std::fs::write(
2784            dir.path().join(".fallowrc.json"),
2785            r#"{"entry": ["chosen.ts"]}"#,
2786        )
2787        .unwrap();
2788        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2789
2790        let chosen = dir.path().join("fallow.toml");
2791        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2792        assert!(result.is_ok());
2793        assert!(captured.is_empty());
2794    }
2795
2796    #[test]
2797    fn find_and_load_finds_fallow_toml() {
2798        let dir = test_dir("find-toml");
2799        std::fs::create_dir(dir.path().join(".git")).unwrap();
2800        std::fs::write(
2801            dir.path().join("fallow.toml"),
2802            "entry = [\"src/index.ts\"]\n",
2803        )
2804        .unwrap();
2805
2806        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2807        assert_eq!(config.entry, vec!["src/index.ts"]);
2808    }
2809
2810    #[test]
2811    fn find_and_load_stops_at_git_dir() {
2812        let dir = test_dir("find-git-stop");
2813        let sub = dir.path().join("sub");
2814        std::fs::create_dir(&sub).unwrap();
2815        std::fs::create_dir(dir.path().join(".git")).unwrap();
2816        let result = FallowConfig::find_and_load(&sub).unwrap();
2817        assert!(result.is_none());
2818    }
2819
2820    #[test]
2821    fn find_and_load_walks_past_package_json_in_monorepo() {
2822        let dir = test_dir("find-monorepo");
2823        std::fs::create_dir(dir.path().join(".git")).unwrap();
2824        std::fs::write(
2825            dir.path().join(".fallowrc.json"),
2826            r#"{"entry": ["src/index.ts"]}"#,
2827        )
2828        .unwrap();
2829
2830        let sub = dir.path().join("packages").join("app");
2831        std::fs::create_dir_all(&sub).unwrap();
2832        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2833
2834        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2835        assert_eq!(config.entry, vec!["src/index.ts"]);
2836        assert_eq!(path, dir.path().join(".fallowrc.json"));
2837    }
2838
2839    #[test]
2840    fn find_and_load_sub_package_config_wins_over_root() {
2841        let dir = test_dir("find-monorepo-override");
2842        std::fs::create_dir(dir.path().join(".git")).unwrap();
2843        std::fs::write(
2844            dir.path().join(".fallowrc.json"),
2845            r#"{"entry": ["src/root.ts"]}"#,
2846        )
2847        .unwrap();
2848
2849        let sub = dir.path().join("packages").join("app");
2850        std::fs::create_dir_all(&sub).unwrap();
2851        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2852        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2853
2854        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2855        assert_eq!(config.entry, vec!["src/sub.ts"]);
2856        assert_eq!(path, sub.join(".fallowrc.json"));
2857    }
2858
2859    #[test]
2860    fn find_and_load_stops_at_git_file_submodule() {
2861        let dir = test_dir("find-git-file");
2862        std::fs::create_dir(dir.path().join(".git")).unwrap();
2863        std::fs::write(
2864            dir.path().join(".fallowrc.json"),
2865            r#"{"entry": ["src/parent.ts"]}"#,
2866        )
2867        .unwrap();
2868
2869        let submodule = dir.path().join("vendor").join("lib");
2870        std::fs::create_dir_all(&submodule).unwrap();
2871        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2872
2873        let result = FallowConfig::find_and_load(&submodule).unwrap();
2874        assert!(
2875            result.is_none(),
2876            "submodule boundary should stop config walk",
2877        );
2878    }
2879
2880    #[test]
2881    fn find_and_load_stops_at_hg_dir() {
2882        let dir = test_dir("find-hg-stop");
2883        let sub = dir.path().join("sub");
2884        std::fs::create_dir(&sub).unwrap();
2885        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2886
2887        let result = FallowConfig::find_and_load(&sub).unwrap();
2888        assert!(result.is_none());
2889    }
2890
2891    #[test]
2892    fn find_and_load_returns_error_for_invalid_config() {
2893        let dir = test_dir("find-invalid");
2894        std::fs::create_dir(dir.path().join(".git")).unwrap();
2895        std::fs::write(
2896            dir.path().join(".fallowrc.json"),
2897            r"{ this is not valid json }",
2898        )
2899        .unwrap();
2900
2901        let result = FallowConfig::find_and_load(dir.path());
2902        assert!(result.is_err());
2903    }
2904
2905    #[test]
2906    fn load_toml_config_file() {
2907        let dir = test_dir("toml-config");
2908        let config_path = dir.path().join("fallow.toml");
2909        std::fs::write(
2910            &config_path,
2911            r#"
2912entry = ["src/index.ts"]
2913ignorePatterns = ["dist/**"]
2914
2915[rules]
2916unused-files = "warn"
2917
2918[duplicates]
2919minTokens = 100
2920"#,
2921        )
2922        .unwrap();
2923
2924        let config = FallowConfig::load(&config_path).unwrap();
2925        assert_eq!(config.entry, vec!["src/index.ts"]);
2926        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2927        assert_eq!(config.rules.unused_files, Severity::Warn);
2928        assert_eq!(config.duplicates.min_tokens, 100);
2929    }
2930
2931    #[test]
2932    fn load_toml_config_file_with_health_threshold_override() {
2933        let dir = test_dir("toml-health-threshold-override");
2934        let config_path = dir.path().join("fallow.toml");
2935        std::fs::write(
2936            &config_path,
2937            r#"
2938[health]
2939thresholdOverrides = [
2940  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
2941]
2942"#,
2943        )
2944        .unwrap();
2945
2946        let config = FallowConfig::load(&config_path).unwrap();
2947        let override_config = &config.health.threshold_overrides[0];
2948        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2949        assert_eq!(override_config.functions, vec!["legacyFlow"]);
2950        assert_eq!(override_config.max_cyclomatic, Some(30));
2951        assert_eq!(override_config.max_cognitive, Some(25));
2952        assert_eq!(override_config.max_crap, Some(80.5));
2953        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
2954    }
2955
2956    #[test]
2957    fn extends_absolute_path_rejected() {
2958        let dir = test_dir("extends-absolute");
2959
2960        #[cfg(unix)]
2961        let abs_path = "/absolute/path/config.json";
2962        #[cfg(windows)]
2963        let abs_path = "C:\\absolute\\path\\config.json";
2964
2965        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2966        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2967
2968        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2969        assert!(result.is_err());
2970        let err_msg = format!("{}", result.unwrap_err());
2971        assert!(
2972            err_msg.contains("must be relative"),
2973            "Expected 'must be relative' error, got: {err_msg}"
2974        );
2975    }
2976
2977    #[test]
2978    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2979        let dir = test_dir("extends-windows-absolute");
2980
2981        std::fs::write(
2982            dir.path().join(".fallowrc.json"),
2983            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2984        )
2985        .unwrap();
2986
2987        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2988        assert!(result.is_err());
2989        let err_msg = format!("{}", result.unwrap_err());
2990        assert!(
2991            err_msg.contains("must be relative"),
2992            "Expected 'must be relative' error, got: {err_msg}"
2993        );
2994    }
2995
2996    #[cfg(windows)]
2997    #[test]
2998    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
2999        let dir = test_dir("extends-posix-rooted-absolute");
3000
3001        std::fs::write(
3002            dir.path().join(".fallowrc.json"),
3003            r#"{"extends": ["/absolute/path/config.json"]}"#,
3004        )
3005        .unwrap();
3006
3007        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3008        assert!(result.is_err());
3009        let err_msg = format!("{}", result.unwrap_err());
3010        assert!(
3011            err_msg.contains("must be relative"),
3012            "Expected 'must be relative' error, got: {err_msg}"
3013        );
3014    }
3015
3016    #[test]
3017    fn resolve_production_mode_disables_dev_deps() {
3018        let config = FallowConfig {
3019            production: true.into(),
3020            ..Default::default()
3021        };
3022        let resolved = config.resolve(
3023            PathBuf::from("/tmp/test"),
3024            OutputFormat::Human,
3025            4,
3026            false,
3027            true,
3028            None,
3029        );
3030        assert!(resolved.production);
3031        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3032        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3033        assert_eq!(resolved.rules.unused_files, Severity::Error);
3034        assert_eq!(resolved.rules.unused_exports, Severity::Error);
3035    }
3036
3037    #[test]
3038    fn include_entry_exports_deserializes_from_camelcase_json() {
3039        let json = r#"{ "includeEntryExports": true }"#;
3040        let config: FallowConfig = serde_json::from_str(json).unwrap();
3041        assert!(config.include_entry_exports);
3042    }
3043
3044    #[test]
3045    fn include_entry_exports_deserializes_from_camelcase_toml() {
3046        let toml_str = "includeEntryExports = true\n";
3047        let config: FallowConfig = toml::from_str(toml_str).unwrap();
3048        assert!(config.include_entry_exports);
3049    }
3050
3051    #[test]
3052    fn include_entry_exports_default_is_false() {
3053        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3054        assert!(!config.include_entry_exports);
3055    }
3056
3057    #[test]
3058    fn include_entry_exports_propagates_through_resolve() {
3059        let config = FallowConfig {
3060            include_entry_exports: true,
3061            auto_imports: false,
3062            cache: CacheConfig::default(),
3063            ..Default::default()
3064        };
3065        let resolved = config.resolve(
3066            PathBuf::from("/tmp/test"),
3067            OutputFormat::Human,
3068            1,
3069            true,
3070            true,
3071            None,
3072        );
3073        assert!(resolved.include_entry_exports);
3074    }
3075
3076    #[test]
3077    fn config_format_defaults_to_toml_for_unknown() {
3078        assert!(matches!(
3079            ConfigFormat::from_path(Path::new("config.yaml")),
3080            ConfigFormat::Toml
3081        ));
3082        assert!(matches!(
3083            ConfigFormat::from_path(Path::new("config")),
3084            ConfigFormat::Toml
3085        ));
3086    }
3087
3088    #[test]
3089    fn deep_merge_object_over_scalar_replaces() {
3090        let mut base = serde_json::json!("just a string");
3091        let overlay = serde_json::json!({"key": "value"});
3092        deep_merge_json(&mut base, overlay);
3093        assert_eq!(base, serde_json::json!({"key": "value"}));
3094    }
3095
3096    #[test]
3097    fn deep_merge_scalar_over_object_replaces() {
3098        let mut base = serde_json::json!({"key": "value"});
3099        let overlay = serde_json::json!(42);
3100        deep_merge_json(&mut base, overlay);
3101        assert_eq!(base, serde_json::json!(42));
3102    }
3103
3104    #[test]
3105    fn extends_non_string_non_array_ignored() {
3106        let dir = test_dir("extends-numeric");
3107        std::fs::write(
3108            dir.path().join(".fallowrc.json"),
3109            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3110        )
3111        .unwrap();
3112
3113        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3114        assert_eq!(config.entry, vec!["src/index.ts"]);
3115    }
3116
3117    #[test]
3118    fn extends_multiple_bases_later_wins() {
3119        let dir = test_dir("extends-multi-base");
3120
3121        std::fs::write(
3122            dir.path().join("base-a.json"),
3123            r#"{"rules": {"unused-files": "warn"}}"#,
3124        )
3125        .unwrap();
3126        std::fs::write(
3127            dir.path().join("base-b.json"),
3128            r#"{"rules": {"unused-files": "off"}}"#,
3129        )
3130        .unwrap();
3131        std::fs::write(
3132            dir.path().join(".fallowrc.json"),
3133            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3134        )
3135        .unwrap();
3136
3137        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3138        assert_eq!(config.rules.unused_files, Severity::Off);
3139    }
3140
3141    #[test]
3142    fn load_rejects_empty_security_request_receivers() {
3143        let dir = test_dir("empty-security-request-receivers");
3144        std::fs::write(
3145            dir.path().join(".fallowrc.json"),
3146            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
3147        )
3148        .unwrap();
3149
3150        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3151        let err = result.expect_err("empty receiver should be rejected");
3152        assert!(
3153            err.to_string().contains("security.requestReceivers"),
3154            "error should name security.requestReceivers: {err}"
3155        );
3156    }
3157
3158    #[test]
3159    fn resolve_normalizes_security_request_receivers() {
3160        let dir = test_dir("normalize-security-request-receivers");
3161        std::fs::write(
3162            dir.path().join(".fallowrc.json"),
3163            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3164        )
3165        .unwrap();
3166
3167        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3168            .unwrap()
3169            .resolve(
3170                dir.path().to_path_buf(),
3171                OutputFormat::Human,
3172                1,
3173                true,
3174                true,
3175                None,
3176            );
3177        assert_eq!(
3178            config.security.request_receivers,
3179            vec!["httpreq".to_string(), "r".to_string()]
3180        );
3181    }
3182
3183    #[test]
3184    fn fallow_config_deserialize_production() {
3185        let json_str = r#"{"production": true}"#;
3186        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3187        assert!(config.production);
3188    }
3189
3190    #[test]
3191    fn fallow_config_production_defaults_false() {
3192        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3193        assert!(!config.production);
3194    }
3195
3196    #[test]
3197    fn package_json_optional_dependency_names() {
3198        let pkg: PackageJson = serde_json::from_str(
3199            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3200        )
3201        .unwrap();
3202        let opt = pkg.optional_dependency_names();
3203        assert_eq!(opt.len(), 2);
3204        assert!(opt.contains(&"fsevents".to_string()));
3205        assert!(opt.contains(&"chokidar".to_string()));
3206    }
3207
3208    #[test]
3209    fn package_json_optional_deps_empty_when_missing() {
3210        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3211        assert!(pkg.optional_dependency_names().is_empty());
3212    }
3213
3214    #[test]
3215    fn find_config_path_returns_fallowrc_json() {
3216        let dir = test_dir("find-path-json");
3217        std::fs::create_dir(dir.path().join(".git")).unwrap();
3218        std::fs::write(
3219            dir.path().join(".fallowrc.json"),
3220            r#"{"entry": ["src/main.ts"]}"#,
3221        )
3222        .unwrap();
3223
3224        let path = FallowConfig::find_config_path(dir.path());
3225        assert!(path.is_some());
3226        assert!(path.unwrap().ends_with(".fallowrc.json"));
3227    }
3228
3229    #[test]
3230    fn find_config_path_returns_fallow_toml() {
3231        let dir = test_dir("find-path-toml");
3232        std::fs::create_dir(dir.path().join(".git")).unwrap();
3233        std::fs::write(
3234            dir.path().join("fallow.toml"),
3235            "entry = [\"src/main.ts\"]\n",
3236        )
3237        .unwrap();
3238
3239        let path = FallowConfig::find_config_path(dir.path());
3240        assert!(path.is_some());
3241        assert!(path.unwrap().ends_with("fallow.toml"));
3242    }
3243
3244    #[test]
3245    fn find_config_path_returns_dot_fallow_toml() {
3246        let dir = test_dir("find-path-dot-toml");
3247        std::fs::create_dir(dir.path().join(".git")).unwrap();
3248        std::fs::write(
3249            dir.path().join(".fallow.toml"),
3250            "entry = [\"src/main.ts\"]\n",
3251        )
3252        .unwrap();
3253
3254        let path = FallowConfig::find_config_path(dir.path());
3255        assert!(path.is_some());
3256        assert!(path.unwrap().ends_with(".fallow.toml"));
3257    }
3258
3259    #[test]
3260    fn find_config_path_prefers_json_over_toml() {
3261        let dir = test_dir("find-path-priority");
3262        std::fs::create_dir(dir.path().join(".git")).unwrap();
3263        std::fs::write(
3264            dir.path().join(".fallowrc.json"),
3265            r#"{"entry": ["json.ts"]}"#,
3266        )
3267        .unwrap();
3268        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3269
3270        let path = FallowConfig::find_config_path(dir.path());
3271        assert!(path.unwrap().ends_with(".fallowrc.json"));
3272    }
3273
3274    #[test]
3275    fn find_config_path_none_when_no_config() {
3276        let dir = test_dir("find-path-none");
3277        std::fs::create_dir(dir.path().join(".git")).unwrap();
3278
3279        let path = FallowConfig::find_config_path(dir.path());
3280        assert!(path.is_none());
3281    }
3282
3283    #[test]
3284    fn find_config_path_walks_past_package_json_in_monorepo() {
3285        let dir = test_dir("find-path-monorepo");
3286        std::fs::create_dir(dir.path().join(".git")).unwrap();
3287        std::fs::write(
3288            dir.path().join(".fallowrc.json"),
3289            r#"{"entry": ["src/index.ts"]}"#,
3290        )
3291        .unwrap();
3292
3293        let sub = dir.path().join("packages").join("app");
3294        std::fs::create_dir_all(&sub).unwrap();
3295        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3296
3297        let path = FallowConfig::find_config_path(&sub).unwrap();
3298        assert_eq!(path, dir.path().join(".fallowrc.json"));
3299    }
3300
3301    #[test]
3302    fn extends_toml_base() {
3303        let dir = test_dir("extends-toml");
3304
3305        std::fs::write(
3306            dir.path().join("base.json"),
3307            r#"{"rules": {"unused-files": "warn"}}"#,
3308        )
3309        .unwrap();
3310        std::fs::write(
3311            dir.path().join("fallow.toml"),
3312            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3313        )
3314        .unwrap();
3315
3316        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3317        assert_eq!(config.rules.unused_files, Severity::Warn);
3318        assert_eq!(config.entry, vec!["src/index.ts"]);
3319    }
3320
3321    #[test]
3322    fn deep_merge_boolean_overlay() {
3323        let mut base = serde_json::json!(true);
3324        deep_merge_json(&mut base, serde_json::json!(false));
3325        assert_eq!(base, serde_json::json!(false));
3326    }
3327
3328    #[test]
3329    fn deep_merge_number_overlay() {
3330        let mut base = serde_json::json!(42);
3331        deep_merge_json(&mut base, serde_json::json!(99));
3332        assert_eq!(base, serde_json::json!(99));
3333    }
3334
3335    #[test]
3336    fn deep_merge_disjoint_objects() {
3337        let mut base = serde_json::json!({"a": 1});
3338        let overlay = serde_json::json!({"b": 2});
3339        deep_merge_json(&mut base, overlay);
3340        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3341    }
3342
3343    #[test]
3344    fn max_extends_depth_is_reasonable() {
3345        assert_eq!(MAX_EXTENDS_DEPTH, 10);
3346    }
3347
3348    #[test]
3349    fn config_names_has_four_entries() {
3350        assert_eq!(CONFIG_NAMES.len(), 4);
3351        for name in CONFIG_NAMES {
3352            assert!(
3353                name.starts_with('.') || name.starts_with("fallow"),
3354                "unexpected config name: {name}"
3355            );
3356        }
3357    }
3358
3359    #[test]
3360    fn package_json_peer_dependency_names() {
3361        let pkg: PackageJson = serde_json::from_str(
3362            r#"{
3363            "dependencies": {"react": "^18"},
3364            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3365        }"#,
3366        )
3367        .unwrap();
3368        let all = pkg.all_dependency_names();
3369        assert!(all.contains(&"react".to_string()));
3370        assert!(all.contains(&"react-dom".to_string()));
3371        assert!(all.contains(&"react-native".to_string()));
3372    }
3373
3374    #[test]
3375    fn package_json_scripts_field() {
3376        let pkg: PackageJson = serde_json::from_str(
3377            r#"{
3378            "scripts": {
3379                "build": "tsc",
3380                "test": "vitest",
3381                "lint": "fallow check"
3382            }
3383        }"#,
3384        )
3385        .unwrap();
3386        let scripts = pkg.scripts.unwrap();
3387        assert_eq!(scripts.len(), 3);
3388        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3389        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3390    }
3391
3392    #[test]
3393    fn extends_toml_chain() {
3394        let dir = test_dir("extends-toml-chain");
3395
3396        std::fs::write(
3397            dir.path().join("base.json"),
3398            r#"{"entry": ["src/base.ts"]}"#,
3399        )
3400        .unwrap();
3401        std::fs::write(
3402            dir.path().join("middle.json"),
3403            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3404        )
3405        .unwrap();
3406        std::fs::write(
3407            dir.path().join("fallow.toml"),
3408            "extends = [\"middle.json\"]\n",
3409        )
3410        .unwrap();
3411
3412        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3413        assert_eq!(config.entry, vec!["src/base.ts"]);
3414        assert_eq!(config.rules.unused_files, Severity::Off);
3415    }
3416
3417    #[test]
3418    fn find_and_load_walks_up_directories() {
3419        let dir = test_dir("find-walk-up");
3420        let sub = dir.path().join("src").join("deep");
3421        std::fs::create_dir_all(&sub).unwrap();
3422        std::fs::write(
3423            dir.path().join(".fallowrc.json"),
3424            r#"{"entry": ["src/main.ts"]}"#,
3425        )
3426        .unwrap();
3427        std::fs::create_dir(dir.path().join(".git")).unwrap();
3428
3429        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3430        assert_eq!(config.entry, vec!["src/main.ts"]);
3431        assert!(path.ends_with(".fallowrc.json"));
3432    }
3433
3434    #[test]
3435    fn json_schema_contains_entry_field() {
3436        let schema = FallowConfig::json_schema();
3437        let obj = schema.as_object().unwrap();
3438        let props = obj.get("properties").and_then(|v| v.as_object());
3439        assert!(props.is_some(), "schema should have properties");
3440        assert!(
3441            props.unwrap().contains_key("entry"),
3442            "schema should contain entry property"
3443        );
3444    }
3445
3446    #[test]
3447    fn fallow_config_json_duplicates_all_fields() {
3448        let json = r#"{
3449            "duplicates": {
3450                "enabled": true,
3451                "mode": "semantic",
3452                "minTokens": 200,
3453                "minLines": 20,
3454                "threshold": 10.5,
3455                "ignore": ["**/*.test.ts"],
3456                "skipLocal": true,
3457                "crossLanguage": true,
3458                "normalization": {
3459                    "ignoreIdentifiers": true,
3460                    "ignoreStringValues": false
3461                }
3462            }
3463        }"#;
3464        let config: FallowConfig = serde_json::from_str(json).unwrap();
3465        assert!(config.duplicates.enabled);
3466        assert_eq!(
3467            config.duplicates.mode,
3468            crate::config::DetectionMode::Semantic
3469        );
3470        assert_eq!(config.duplicates.min_tokens, 200);
3471        assert_eq!(config.duplicates.min_lines, 20);
3472        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3473        assert!(config.duplicates.skip_local);
3474        assert!(config.duplicates.cross_language);
3475        assert_eq!(
3476            config.duplicates.normalization.ignore_identifiers,
3477            Some(true)
3478        );
3479        assert_eq!(
3480            config.duplicates.normalization.ignore_string_values,
3481            Some(false)
3482        );
3483    }
3484
3485    #[test]
3486    fn normalize_url_basic() {
3487        assert_eq!(
3488            normalize_url_for_dedup("https://example.com/config.json"),
3489            "https://example.com/config.json"
3490        );
3491    }
3492
3493    #[test]
3494    fn normalize_url_trailing_slash() {
3495        assert_eq!(
3496            normalize_url_for_dedup("https://example.com/config/"),
3497            "https://example.com/config"
3498        );
3499    }
3500
3501    #[test]
3502    fn normalize_url_uppercase_scheme_and_host() {
3503        assert_eq!(
3504            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3505            "https://example.com/Config.json"
3506        );
3507    }
3508
3509    #[test]
3510    fn normalize_url_root_path() {
3511        assert_eq!(
3512            normalize_url_for_dedup("https://example.com/"),
3513            "https://example.com"
3514        );
3515        assert_eq!(
3516            normalize_url_for_dedup("https://example.com"),
3517            "https://example.com"
3518        );
3519    }
3520
3521    #[test]
3522    fn normalize_url_preserves_path_case() {
3523        assert_eq!(
3524            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3525            "https://github.com/Org/Repo/Fallow.json"
3526        );
3527    }
3528
3529    #[test]
3530    fn normalize_url_strips_query_string() {
3531        assert_eq!(
3532            normalize_url_for_dedup("https://example.com/config.json?v=1"),
3533            "https://example.com/config.json"
3534        );
3535    }
3536
3537    #[test]
3538    fn normalize_url_strips_fragment() {
3539        assert_eq!(
3540            normalize_url_for_dedup("https://example.com/config.json#section"),
3541            "https://example.com/config.json"
3542        );
3543    }
3544
3545    #[test]
3546    fn normalize_url_strips_query_and_fragment() {
3547        assert_eq!(
3548            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3549            "https://example.com/config.json"
3550        );
3551    }
3552
3553    #[test]
3554    fn normalize_url_default_https_port() {
3555        assert_eq!(
3556            normalize_url_for_dedup("https://example.com:443/config.json"),
3557            "https://example.com/config.json"
3558        );
3559        assert_eq!(
3560            normalize_url_for_dedup("https://example.com:8443/config.json"),
3561            "https://example.com:8443/config.json"
3562        );
3563    }
3564
3565    #[test]
3566    fn extends_http_rejected() {
3567        let dir = test_dir("http-rejected");
3568        std::fs::write(
3569            dir.path().join(".fallowrc.json"),
3570            r#"{"extends": "http://example.com/config.json"}"#,
3571        )
3572        .unwrap();
3573
3574        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3575        assert!(result.is_err());
3576        let err_msg = format!("{}", result.unwrap_err());
3577        assert!(
3578            err_msg.contains("https://"),
3579            "Expected https hint in error, got: {err_msg}"
3580        );
3581        assert!(
3582            err_msg.contains("http://"),
3583            "Expected http:// mention in error, got: {err_msg}"
3584        );
3585    }
3586
3587    #[test]
3588    fn extends_url_circular_detection() {
3589        let mut visited = FxHashSet::default();
3590        let url = "https://example.com/config.json";
3591        let normalized = normalize_url_for_dedup(url);
3592        visited.insert(normalized.clone());
3593
3594        assert!(
3595            !visited.insert(normalized),
3596            "Same URL should be detected as duplicate"
3597        );
3598    }
3599
3600    #[test]
3601    fn extends_url_circular_case_insensitive() {
3602        let mut visited = FxHashSet::default();
3603        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3604
3605        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3606        assert!(
3607            !visited.insert(normalized),
3608            "Case-different URLs should normalize to the same key"
3609        );
3610    }
3611
3612    #[test]
3613    fn extract_extends_array() {
3614        let mut value = serde_json::json!({
3615            "extends": ["a.json", "b.json"],
3616            "entry": ["src/index.ts"]
3617        });
3618        let extends = extract_extends(&mut value);
3619        assert_eq!(extends, vec!["a.json", "b.json"]);
3620        assert!(value.get("extends").is_none());
3621        assert!(value.get("entry").is_some());
3622    }
3623
3624    #[test]
3625    fn extract_extends_string_sugar() {
3626        let mut value = serde_json::json!({
3627            "extends": "base.json",
3628            "entry": ["src/index.ts"]
3629        });
3630        let extends = extract_extends(&mut value);
3631        assert_eq!(extends, vec!["base.json"]);
3632    }
3633
3634    #[test]
3635    fn extract_extends_none() {
3636        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3637        let extends = extract_extends(&mut value);
3638        assert!(extends.is_empty());
3639    }
3640
3641    #[test]
3642    fn url_timeout_default() {
3643        let timeout = url_timeout();
3644        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3645    }
3646
3647    #[test]
3648    fn extends_url_mixed_with_file_and_npm() {
3649        let dir = test_dir("url-mixed");
3650        std::fs::write(
3651            dir.path().join("local.json"),
3652            r#"{"rules": {"unused-files": "warn"}}"#,
3653        )
3654        .unwrap();
3655        std::fs::write(
3656            dir.path().join(".fallowrc.json"),
3657            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3658        )
3659        .unwrap();
3660
3661        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3662        assert!(result.is_err());
3663        let err_msg = format!("{}", result.unwrap_err());
3664        assert!(
3665            err_msg.contains("unreachable.invalid"),
3666            "Expected URL in error message, got: {err_msg}"
3667        );
3668    }
3669
3670    #[test]
3671    fn extends_https_url_unreachable_errors() {
3672        let dir = test_dir("url-unreachable");
3673        std::fs::write(
3674            dir.path().join(".fallowrc.json"),
3675            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3676        )
3677        .unwrap();
3678
3679        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3680        assert!(result.is_err());
3681        let err_msg = format!("{}", result.unwrap_err());
3682        assert!(
3683            err_msg.contains("unreachable.invalid"),
3684            "Expected URL in error, got: {err_msg}"
3685        );
3686        assert!(
3687            err_msg.contains("local path or npm:"),
3688            "Expected remediation hint, got: {err_msg}"
3689        );
3690    }
3691
3692    #[test]
3693    fn collect_unknown_rule_keys_flags_top_level_typo() {
3694        let merged = serde_json::json!({
3695            "rules": {
3696                "unsued-files": "warn",
3697                "unused-exports": "off"
3698            }
3699        });
3700        let findings = collect_unknown_rule_keys(&merged);
3701        assert_eq!(findings.len(), 1);
3702        assert_eq!(findings[0].context, "rules");
3703        assert_eq!(findings[0].key, "unsued-files");
3704        assert_eq!(findings[0].suggestion, Some("unused-files"));
3705    }
3706
3707    #[test]
3708    fn collect_unknown_rule_keys_flags_overrides_typo() {
3709        let merged = serde_json::json!({
3710            "overrides": [
3711                {
3712                    "files": ["src/**/*.ts"],
3713                    "rules": {
3714                        "unsued-files": "warn"
3715                    }
3716                },
3717                {
3718                    "files": ["tests/**/*.ts"],
3719                    "rules": {
3720                        "circular-dependnecy": "off"
3721                    }
3722                }
3723            ]
3724        });
3725        let findings = collect_unknown_rule_keys(&merged);
3726        assert_eq!(findings.len(), 2);
3727        assert_eq!(findings[0].context, "overrides[0].rules");
3728        assert_eq!(findings[1].context, "overrides[1].rules");
3729        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3730    }
3731
3732    #[test]
3733    fn collect_unknown_rule_keys_empty_for_valid_config() {
3734        let merged = serde_json::json!({
3735            "rules": {
3736                "unused-files": "warn",
3737                "unused-file": "off",
3738                "circular-dependency": "off",
3739                "boundary-violations": "warn"
3740            },
3741            "overrides": [
3742                {
3743                    "files": ["src/**"],
3744                    "rules": {
3745                        "unused-exports": "warn"
3746                    }
3747                }
3748            ]
3749        });
3750        let findings = collect_unknown_rule_keys(&merged);
3751        assert!(
3752            findings.is_empty(),
3753            "valid rule names and aliases must not be flagged: {findings:?}"
3754        );
3755    }
3756
3757    #[test]
3758    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3759        let merged = serde_json::json!({
3760            "entry": ["src/main.ts"]
3761        });
3762        let findings = collect_unknown_rule_keys(&merged);
3763        assert!(findings.is_empty());
3764    }
3765
3766    #[test]
3767    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3768        let dir = test_dir("wiring");
3769        let path = dir.path().join(".fallowrc.json");
3770        let typo = format!(
3771            "wiring-probe-{}-{}",
3772            std::process::id(),
3773            std::time::SystemTime::now()
3774                .duration_since(std::time::UNIX_EPOCH)
3775                .map_or(0, |d| d.as_nanos())
3776        );
3777        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3778
3779        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3780
3781        assert!(
3782            config_res.is_ok(),
3783            "load should succeed in phase 1: {:?}",
3784            config_res.err()
3785        );
3786        assert_eq!(
3787            captured.len(),
3788            1,
3789            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3790        );
3791        assert_eq!(captured[0].key, typo);
3792        assert_eq!(captured[0].context, "rules");
3793    }
3794
3795    #[test]
3796    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3797        let dir = test_dir("misspelled-rule");
3798        std::fs::write(
3799            dir.path().join(".fallowrc.json"),
3800            r#"{"rules": {"unsued-files": "warn"}}"#,
3801        )
3802        .unwrap();
3803
3804        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3805            .expect("load should succeed in phase 1");
3806
3807        assert_eq!(config.rules.unused_files, Severity::Error);
3808    }
3809
3810    #[test]
3811    fn validate_resolved_boundaries_passes_on_valid_config() {
3812        let dir = test_dir("boundaries-valid");
3813        let config = FallowConfig {
3814            boundaries: crate::BoundaryConfig {
3815                coverage: crate::BoundaryCoverageConfig::default(),
3816                calls: crate::BoundaryCallsConfig::default(),
3817                preset: None,
3818                zones: vec![
3819                    crate::BoundaryZone {
3820                        name: "ui".to_string(),
3821                        patterns: vec!["src/components/**".to_string()],
3822                        auto_discover: vec![],
3823                        root: None,
3824                    },
3825                    crate::BoundaryZone {
3826                        name: "db".to_string(),
3827                        patterns: vec!["src/db/**".to_string()],
3828                        auto_discover: vec![],
3829                        root: None,
3830                    },
3831                ],
3832                rules: vec![crate::BoundaryRule {
3833                    from: "ui".to_string(),
3834                    allow: vec!["db".to_string()],
3835                    allow_type_only: vec![],
3836                }],
3837            },
3838            ..FallowConfig::default()
3839        };
3840        config
3841            .validate_resolved_boundaries(dir.path())
3842            .expect("valid config should pass");
3843    }
3844
3845    #[test]
3846    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3847        let dir = test_dir("boundaries-unknown-zones");
3848        let config = FallowConfig {
3849            boundaries: crate::BoundaryConfig {
3850                coverage: crate::BoundaryCoverageConfig::default(),
3851                calls: crate::BoundaryCallsConfig::default(),
3852                preset: None,
3853                zones: vec![crate::BoundaryZone {
3854                    name: "ui".to_string(),
3855                    patterns: vec!["src/ui/**".to_string()],
3856                    auto_discover: vec![],
3857                    root: None,
3858                }],
3859                rules: vec![
3860                    crate::BoundaryRule {
3861                        from: "typo-from".to_string(),
3862                        allow: vec!["typo-allow".to_string()],
3863                        allow_type_only: vec!["typo-type-only".to_string()],
3864                    },
3865                    crate::BoundaryRule {
3866                        from: "ui".to_string(),
3867                        allow: vec!["another-typo".to_string()],
3868                        allow_type_only: vec![],
3869                    },
3870                ],
3871            },
3872            ..FallowConfig::default()
3873        };
3874
3875        let errors = config
3876            .validate_resolved_boundaries(dir.path())
3877            .expect_err("invalid zone refs should fail");
3878
3879        assert_eq!(errors.len(), 4, "got: {errors:?}");
3880
3881        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3882        assert!(
3883            rendered
3884                .iter()
3885                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3886        );
3887        assert!(
3888            rendered
3889                .iter()
3890                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3891        );
3892        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3893            && m.contains("rules[0]")
3894            && m.contains("allowTypeOnly")));
3895        assert!(
3896            rendered.iter().any(|m| m.contains("another-typo")
3897                && m.contains("rules[1]")
3898                && m.contains("allow"))
3899        );
3900    }
3901
3902    #[test]
3903    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3904        let dir = test_dir("boundaries-redundant-prefix");
3905        let config = FallowConfig {
3906            boundaries: crate::BoundaryConfig {
3907                coverage: crate::BoundaryCoverageConfig::default(),
3908                calls: crate::BoundaryCallsConfig::default(),
3909                preset: None,
3910                zones: vec![crate::BoundaryZone {
3911                    name: "ui".to_string(),
3912                    patterns: vec!["packages/app/src/**".to_string()],
3913                    auto_discover: vec![],
3914                    root: Some("packages/app/".to_string()),
3915                }],
3916                rules: vec![],
3917            },
3918            ..FallowConfig::default()
3919        };
3920
3921        let errors = config
3922            .validate_resolved_boundaries(dir.path())
3923            .expect_err("redundant root prefix should fail");
3924        assert_eq!(errors.len(), 1, "got: {errors:?}");
3925        let rendered = errors[0].to_string();
3926        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3927        assert!(rendered.contains("zone 'ui'"));
3928    }
3929
3930    #[test]
3931    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3932        let dir = test_dir("boundaries-mixed-errors");
3933        let config = FallowConfig {
3934            boundaries: crate::BoundaryConfig {
3935                coverage: crate::BoundaryCoverageConfig::default(),
3936                calls: crate::BoundaryCallsConfig::default(),
3937                preset: None,
3938                zones: vec![crate::BoundaryZone {
3939                    name: "ui".to_string(),
3940                    patterns: vec!["packages/app/src/**".to_string()],
3941                    auto_discover: vec![],
3942                    root: Some("packages/app/".to_string()),
3943                }],
3944                rules: vec![crate::BoundaryRule {
3945                    from: "ui".to_string(),
3946                    allow: vec!["typo-zone".to_string()],
3947                    allow_type_only: vec![],
3948                }],
3949            },
3950            ..FallowConfig::default()
3951        };
3952        let errors = config
3953            .validate_resolved_boundaries(dir.path())
3954            .expect_err("mixed errors should fail");
3955        assert_eq!(errors.len(), 2, "got: {errors:?}");
3956        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3957        assert!(
3958            rendered
3959                .iter()
3960                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3961        );
3962        assert!(
3963            rendered
3964                .iter()
3965                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3966        );
3967    }
3968
3969    #[test]
3970    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3971        let dir = test_dir("boundaries-bulletproof");
3972        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3973        let config = FallowConfig {
3974            boundaries: crate::BoundaryConfig {
3975                coverage: crate::BoundaryCoverageConfig::default(),
3976                calls: crate::BoundaryCallsConfig::default(),
3977                preset: Some(crate::BoundaryPreset::Bulletproof),
3978                zones: vec![],
3979                rules: vec![],
3980            },
3981            ..FallowConfig::default()
3982        };
3983        config
3984            .validate_resolved_boundaries(dir.path())
3985            .expect("Bulletproof with discoverable features should pass");
3986    }
3987
3988    // ------------------------------------------------------------------
3989    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
3990    // ------------------------------------------------------------------
3991
3992    #[test]
3993    #[cfg_attr(miri, ignore)]
3994    fn parse_config_to_value_strips_utf8_bom() {
3995        let dir = test_dir("parse-bom");
3996        let path = dir.path().join("fallow.toml");
3997        // Write TOML with a UTF-8 BOM prefix
3998        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
3999        std::fs::write(&path, content_with_bom).unwrap();
4000
4001        let value = parse_config_to_value(&path).unwrap();
4002        assert!(
4003            value.get("entry").is_some(),
4004            "BOM should be stripped before TOML parsing"
4005        );
4006    }
4007
4008    #[test]
4009    #[cfg_attr(miri, ignore)]
4010    fn parse_config_to_value_toml_parse_error() {
4011        let dir = test_dir("parse-toml-error");
4012        let path = dir.path().join("fallow.toml");
4013        std::fs::write(&path, "entry = [unquoted\n").unwrap();
4014
4015        let result = parse_config_to_value(&path);
4016        assert!(result.is_err());
4017        let err = result.unwrap_err().to_string();
4018        assert!(
4019            err.contains("Failed to parse config file"),
4020            "error should mention parse failure: {err}"
4021        );
4022    }
4023
4024    #[test]
4025    #[cfg_attr(miri, ignore)]
4026    fn parse_config_to_value_json_parse_error() {
4027        let dir = test_dir("parse-json-error");
4028        let path = dir.path().join(".fallowrc.json");
4029        std::fs::write(&path, "{ this is not json }").unwrap();
4030
4031        let result = parse_config_to_value(&path);
4032        assert!(result.is_err());
4033        let err = result.unwrap_err().to_string();
4034        assert!(
4035            err.contains("Failed to parse config file"),
4036            "error should mention parse failure: {err}"
4037        );
4038    }
4039
4040    #[test]
4041    #[cfg_attr(miri, ignore)]
4042    fn parse_config_to_value_missing_file_error() {
4043        let dir = test_dir("parse-missing");
4044        let path = dir.path().join("nonexistent.toml");
4045
4046        let result = parse_config_to_value(&path);
4047        assert!(result.is_err());
4048        let err = result.unwrap_err().to_string();
4049        assert!(
4050            err.contains("Failed to read config file"),
4051            "error should mention read failure: {err}"
4052        );
4053    }
4054
4055    // ------------------------------------------------------------------
4056    // is_repo_root: svn boundary
4057    // ------------------------------------------------------------------
4058
4059    #[test]
4060    #[cfg_attr(miri, ignore)]
4061    fn find_and_load_stops_at_svn_dir() {
4062        let dir = test_dir("find-svn-stop");
4063        let sub = dir.path().join("sub");
4064        std::fs::create_dir(&sub).unwrap();
4065        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4066
4067        let result = FallowConfig::find_and_load(&sub).unwrap();
4068        assert!(result.is_none(), "svn boundary should stop config walk");
4069    }
4070
4071    // ------------------------------------------------------------------
4072    // validate_npm_package_name: dot-segment in the package name
4073    // (path traversal but using a single dot)
4074    // ------------------------------------------------------------------
4075
4076    #[test]
4077    #[cfg_attr(miri, ignore)]
4078    fn extends_npm_single_dot_package_name_rejected() {
4079        let dir = test_dir("npm-dot-name");
4080        std::fs::write(
4081            dir.path().join(".fallowrc.json"),
4082            r#"{"extends": "npm:./relative"}"#,
4083        )
4084        .unwrap();
4085
4086        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4087        assert!(result.is_err());
4088        let err = result.unwrap_err().to_string();
4089        assert!(
4090            err.contains("path traversal"),
4091            "single-dot component should be rejected as path traversal: {err}"
4092        );
4093    }
4094
4095    // ------------------------------------------------------------------
4096    // find_config_in_npm_package: main field points to nonexistent file,
4097    // falls through to config-name scan
4098    // ------------------------------------------------------------------
4099
4100    #[test]
4101    #[cfg_attr(miri, ignore)]
4102    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4103        let dir = test_dir("npm-main-missing");
4104        let pkg_dir = dir.path().join("node_modules/my-config");
4105        std::fs::create_dir_all(&pkg_dir).unwrap();
4106        // package.json with main pointing at a file that does not exist
4107        std::fs::write(
4108            pkg_dir.join("package.json"),
4109            r#"{"name": "my-config", "main": "./missing.json"}"#,
4110        )
4111        .unwrap();
4112        // But a recognized config name is present for the fallback scan
4113        std::fs::write(
4114            pkg_dir.join(".fallowrc.json"),
4115            r#"{"rules": {"unused-files": "warn"}}"#,
4116        )
4117        .unwrap();
4118
4119        std::fs::write(
4120            dir.path().join(".fallowrc.json"),
4121            r#"{"extends": "npm:my-config"}"#,
4122        )
4123        .unwrap();
4124
4125        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4126        assert_eq!(config.rules.unused_files, Severity::Warn);
4127    }
4128
4129    // ------------------------------------------------------------------
4130    // find_config_in_npm_package: exports present but exports-pointed file
4131    // does not exist, falls through to main then config name
4132    // ------------------------------------------------------------------
4133
4134    #[test]
4135    #[cfg_attr(miri, ignore)]
4136    fn extends_npm_exports_nonexistent_falls_through_to_main() {
4137        let dir = test_dir("npm-exports-missing-file");
4138        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4139        std::fs::create_dir_all(&pkg_dir).unwrap();
4140        // exports points to a file that does not exist; main is valid
4141        std::fs::write(
4142            pkg_dir.join("package.json"),
4143            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4144        )
4145        .unwrap();
4146        std::fs::write(
4147            pkg_dir.join("real.json"),
4148            r#"{"rules": {"unused-types": "off"}}"#,
4149        )
4150        .unwrap();
4151
4152        std::fs::write(
4153            dir.path().join(".fallowrc.json"),
4154            r#"{"extends": "npm:cfg-pkg"}"#,
4155        )
4156        .unwrap();
4157
4158        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4159        assert_eq!(config.rules.unused_types, Severity::Off);
4160    }
4161
4162    // ------------------------------------------------------------------
4163    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
4164    // ------------------------------------------------------------------
4165
4166    #[test]
4167    fn normalize_url_no_scheme_returns_raw() {
4168        // A string without "://" must come back unchanged
4169        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
4170        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
4171    }
4172
4173    // ------------------------------------------------------------------
4174    // normalize_url_for_dedup: query before fragment (fragment then query)
4175    // ------------------------------------------------------------------
4176
4177    #[test]
4178    fn normalize_url_fragment_only_stripped() {
4179        // Fragment-only URL (no query)
4180        assert_eq!(
4181            normalize_url_for_dedup("https://example.com/file.json#anchor"),
4182            "https://example.com/file.json"
4183        );
4184    }
4185
4186    // ------------------------------------------------------------------
4187    // url_timeout: env var override
4188    // ------------------------------------------------------------------
4189
4190    // These exercise the pure `url_timeout_from` parser rather than mutating the
4191    // process-global env var, so they stay deterministic under parallel test
4192    // execution (an env-mutating version raced and failed on Windows CI).
4193    #[test]
4194    fn url_timeout_uses_env_var_when_set() {
4195        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
4196    }
4197
4198    #[test]
4199    fn url_timeout_zero_falls_back_to_default() {
4200        assert_eq!(
4201            url_timeout_from(Some("0")),
4202            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4203            "zero should fall back to the hardcoded default"
4204        );
4205    }
4206
4207    #[test]
4208    fn url_timeout_non_numeric_falls_back_to_default() {
4209        assert_eq!(
4210            url_timeout_from(Some("not-a-number")),
4211            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4212            "non-numeric value should fall back to the hardcoded default"
4213        );
4214    }
4215
4216    #[test]
4217    fn url_timeout_absent_uses_default() {
4218        assert_eq!(
4219            url_timeout_from(None),
4220            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
4221        );
4222    }
4223
4224    // ------------------------------------------------------------------
4225    // resolve_url_extends: depth limit reached
4226    // ------------------------------------------------------------------
4227
4228    #[test]
4229    fn resolve_url_extends_depth_limit_error() {
4230        let mut visited = FxHashSet::default();
4231        let result = resolve_url_extends(
4232            "https://example.invalid/config.json",
4233            &mut visited,
4234            MAX_EXTENDS_DEPTH, // at the limit
4235        );
4236        assert!(result.is_err());
4237        let err = result.unwrap_err().to_string();
4238        assert!(
4239            err.contains("too deep"),
4240            "error should mention depth limit: {err}"
4241        );
4242    }
4243
4244    // ------------------------------------------------------------------
4245    // resolve_extends_file: depth limit reached
4246    // ------------------------------------------------------------------
4247
4248    #[test]
4249    #[cfg_attr(miri, ignore)]
4250    fn resolve_extends_file_depth_limit_error() {
4251        let dir = test_dir("extends-file-depth");
4252        let path = dir.path().join(".fallowrc.json");
4253        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
4254
4255        let mut visited = FxHashSet::default();
4256        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
4257        assert!(result.is_err());
4258        let err = result.unwrap_err().to_string();
4259        assert!(
4260            err.contains("too deep"),
4261            "error should mention depth limit: {err}"
4262        );
4263    }
4264
4265    // ------------------------------------------------------------------
4266    // resolve_extends_file_entry: http:// in file-sourced extends
4267    // ------------------------------------------------------------------
4268
4269    #[test]
4270    #[cfg_attr(miri, ignore)]
4271    fn extends_http_url_in_file_extends_rejected() {
4272        let dir = test_dir("file-extends-http");
4273        std::fs::write(
4274            dir.path().join(".fallowrc.json"),
4275            r#"{"extends": ["http://example.com/config.json"]}"#,
4276        )
4277        .unwrap();
4278
4279        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4280        assert!(result.is_err());
4281        let err = result.unwrap_err().to_string();
4282        assert!(
4283            err.contains("https://"),
4284            "error should suggest https: {err}"
4285        );
4286    }
4287
4288    // ------------------------------------------------------------------
4289    // sealed_config_dir: when sealed = true canonicalization runs
4290    // ------------------------------------------------------------------
4291
4292    #[test]
4293    #[cfg_attr(miri, ignore)]
4294    fn sealed_config_dir_returns_some_when_sealed() {
4295        let dir = test_dir("sealed-dir");
4296        let result = sealed_config_dir(dir.path(), true);
4297        assert!(result.is_ok());
4298        assert!(
4299            result.unwrap().is_some(),
4300            "sealed=true must return Some(canonicalized path)"
4301        );
4302    }
4303
4304    #[test]
4305    fn sealed_config_dir_returns_none_when_not_sealed() {
4306        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
4307        assert!(result.is_ok());
4308        assert!(result.unwrap().is_none(), "sealed=false must return None");
4309    }
4310
4311    // ------------------------------------------------------------------
4312    // collect_unknown_rule_keys: overrides entry without a rules key
4313    // (the inner `if let Some(rules)` branch is not taken)
4314    // ------------------------------------------------------------------
4315
4316    #[test]
4317    fn collect_unknown_rule_keys_override_without_rules_key() {
4318        let merged = serde_json::json!({
4319            "overrides": [
4320                {
4321                    "files": ["src/**/*.ts"]
4322                    // no "rules" key here
4323                },
4324                {
4325                    "files": ["tests/**"],
4326                    "rules": {
4327                        "unsued-exports": "off"
4328                    }
4329                }
4330            ]
4331        });
4332        let findings = collect_unknown_rule_keys(&merged);
4333        assert_eq!(
4334            findings.len(),
4335            1,
4336            "only the entry with rules should produce a finding"
4337        );
4338        assert_eq!(findings[0].context, "overrides[1].rules");
4339    }
4340
4341    // ------------------------------------------------------------------
4342    // FallowConfig::load: deserialization failure
4343    // ------------------------------------------------------------------
4344
4345    #[test]
4346    #[cfg_attr(miri, ignore)]
4347    fn load_fails_on_deserialization_error() {
4348        let dir = test_dir("deser-error");
4349        let path = dir.path().join(".fallowrc.json");
4350        // Valid JSON but contains a field value with the wrong type for the schema
4351        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
4352
4353        let result = FallowConfig::load(&path);
4354        assert!(result.is_err());
4355        let err = result.unwrap_err().to_string();
4356        assert!(
4357            err.contains("Failed to deserialize"),
4358            "error should mention deserialization: {err}"
4359        );
4360    }
4361
4362    // ------------------------------------------------------------------
4363    // FallowConfig::load: threshold override validation failure
4364    // (covers lines 921-927)
4365    // ------------------------------------------------------------------
4366
4367    #[test]
4368    #[cfg_attr(miri, ignore)]
4369    fn load_rejects_threshold_override_with_empty_files() {
4370        let dir = test_dir("threshold-empty-files");
4371        let path = dir.path().join(".fallowrc.json");
4372        std::fs::write(
4373            &path,
4374            r#"{
4375                "health": {
4376                    "thresholdOverrides": [
4377                        {"files": [], "maxCyclomatic": 30}
4378                    ]
4379                }
4380            }"#,
4381        )
4382        .unwrap();
4383
4384        let result = FallowConfig::load(&path);
4385        assert!(result.is_err());
4386        let err = result.unwrap_err().to_string();
4387        assert!(
4388            err.contains("thresholdOverrides"),
4389            "error should mention thresholdOverrides: {err}"
4390        );
4391        assert!(
4392            err.contains("files"),
4393            "error should name the files field: {err}"
4394        );
4395    }
4396
4397    #[test]
4398    #[cfg_attr(miri, ignore)]
4399    fn load_rejects_threshold_override_with_no_threshold_set() {
4400        let dir = test_dir("threshold-no-threshold");
4401        let path = dir.path().join(".fallowrc.json");
4402        std::fs::write(
4403            &path,
4404            r#"{
4405                "health": {
4406                    "thresholdOverrides": [
4407                        {"files": ["src/legacy.ts"]}
4408                    ]
4409                }
4410            }"#,
4411        )
4412        .unwrap();
4413
4414        let result = FallowConfig::load(&path);
4415        assert!(result.is_err());
4416        let err = result.unwrap_err().to_string();
4417        assert!(
4418            err.contains("maxCyclomatic")
4419                || err.contains("maxCognitive")
4420                || err.contains("maxCrap"),
4421            "error should name at least one threshold field: {err}"
4422        );
4423    }
4424
4425    // ------------------------------------------------------------------
4426    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
4427    // validation (covers lines 1026-1032)
4428    // ------------------------------------------------------------------
4429
4430    #[test]
4431    #[cfg_attr(miri, ignore)]
4432    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
4433        let dir = test_dir("invalid-catalog-consumer-glob");
4434        let path = dir.path().join(".fallowrc.json");
4435        std::fs::write(
4436            &path,
4437            r#"{
4438                "ignoreCatalogReferences": [
4439                    {"package": "react", "consumer": "[invalid-glob"}
4440                ]
4441            }"#,
4442        )
4443        .unwrap();
4444
4445        let result = FallowConfig::load(&path);
4446        assert!(result.is_err());
4447        let err = result.unwrap_err().to_string();
4448        assert!(
4449            err.contains("ignoreCatalogReferences"),
4450            "error should mention the field: {err}"
4451        );
4452    }
4453
4454    #[test]
4455    #[cfg_attr(miri, ignore)]
4456    fn load_accepts_ignore_catalog_references_without_consumer() {
4457        let dir = test_dir("catalog-ref-no-consumer");
4458        let path = dir.path().join(".fallowrc.json");
4459        std::fs::write(
4460            &path,
4461            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
4462        )
4463        .unwrap();
4464
4465        let config = FallowConfig::load(&path).unwrap();
4466        assert_eq!(config.ignore_catalog_references.len(), 1);
4467        assert!(config.ignore_catalog_references[0].consumer.is_none());
4468    }
4469
4470    // ------------------------------------------------------------------
4471    // validate_resolved_boundaries: tsconfig rootDir filtering
4472    // (covers lines 1158-1160 - rootDir value is ".", starts with "..", or
4473    // is absolute; all should fall back to "src")
4474    // ------------------------------------------------------------------
4475
4476    #[test]
4477    #[cfg_attr(miri, ignore)]
4478    fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
4479        // No tsconfig.json present; parse_tsconfig_root_dir returns None,
4480        // unwrap_or_else supplies "src". This exercises the filter + fallback branch.
4481        let dir = test_dir("boundaries-preset-no-tsconfig");
4482        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4483        let config = FallowConfig {
4484            boundaries: crate::BoundaryConfig {
4485                coverage: crate::BoundaryCoverageConfig::default(),
4486                calls: crate::BoundaryCallsConfig::default(),
4487                preset: Some(crate::BoundaryPreset::Bulletproof),
4488                zones: vec![],
4489                rules: vec![],
4490            },
4491            ..FallowConfig::default()
4492        };
4493        // Should not panic; no zone-ref errors expected since preset adds zones
4494        let _ = config.validate_resolved_boundaries(dir.path());
4495    }
4496
4497    // ------------------------------------------------------------------
4498    // validate_user_globs: framework plugin invalid glob triggers error path
4499    // (covers lines 970-974)
4500    // ------------------------------------------------------------------
4501
4502    #[test]
4503    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
4504        use crate::ExternalPluginDef;
4505        use crate::external_plugin::EntryPointRole;
4506        let config = FallowConfig {
4507            framework: vec![ExternalPluginDef {
4508                schema: None,
4509                name: "test-plugin".to_owned(),
4510                detection: None,
4511                enablers: vec![],
4512                entry_points: vec!["[invalid-glob".to_owned()],
4513                entry_point_role: EntryPointRole::Support,
4514                config_patterns: vec![],
4515                always_used: vec![],
4516                tooling_dependencies: vec![],
4517                used_exports: vec![],
4518                used_class_members: vec![],
4519            }],
4520            ..FallowConfig::default()
4521        };
4522
4523        let result = config.validate_user_globs();
4524        assert!(
4525            result.is_err(),
4526            "invalid entry_points glob should fail validation"
4527        );
4528        let errors = result.unwrap_err();
4529        assert!(!errors.is_empty());
4530    }
4531
4532    // ------------------------------------------------------------------
4533    // shadowed_config_names: no lower-precedence names after the last index
4534    // ------------------------------------------------------------------
4535
4536    #[test]
4537    #[cfg_attr(miri, ignore)]
4538    fn shadowed_config_names_empty_when_last_config_wins() {
4539        let dir = test_dir("shadow-last");
4540        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
4541        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
4542        assert!(shadowed_config_names(dir.path(), 3).is_empty());
4543    }
4544
4545    // ------------------------------------------------------------------
4546    // warn_on_coexisting_configs: path without filename (edge branch)
4547    // shadowed is empty -> early return without recording
4548    // ------------------------------------------------------------------
4549
4550    #[test]
4551    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
4552        let ((), captured) = capture_coexisting_config_warnings(|| {
4553            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
4554        });
4555        assert!(
4556            captured.is_empty(),
4557            "empty shadowed list must produce no warning"
4558        );
4559    }
4560
4561    // ------------------------------------------------------------------
4562    // extract_extends: array with non-string entries are filtered out
4563    // ------------------------------------------------------------------
4564
4565    #[test]
4566    fn extract_extends_array_filters_non_strings() {
4567        let mut value = serde_json::json!({
4568            "extends": ["a.json", 42, null, "b.json", true]
4569        });
4570        let extends = extract_extends(&mut value);
4571        assert_eq!(extends, vec!["a.json", "b.json"]);
4572    }
4573
4574    // ------------------------------------------------------------------
4575    // record_extends_visit: circular file-extends detected
4576    // (secondary path: same canonical path inserted twice)
4577    // ------------------------------------------------------------------
4578
4579    #[test]
4580    #[cfg_attr(miri, ignore)]
4581    fn record_extends_visit_circular_same_file() {
4582        let dir = test_dir("visit-circular");
4583        let path = dir.path().join("config.json");
4584        std::fs::write(&path, "{}").unwrap();
4585
4586        let mut visited = FxHashSet::default();
4587        record_extends_visit(&path, &mut visited).unwrap();
4588        let result = record_extends_visit(&path, &mut visited);
4589        assert!(result.is_err());
4590        let err = result.unwrap_err().to_string();
4591        assert!(
4592            err.contains("Circular extends"),
4593            "second visit of same file must report circular: {err}"
4594        );
4595    }
4596
4597    // ------------------------------------------------------------------
4598    // find_and_load: stops at .svn dir (is_repo_root branch)
4599    // ------------------------------------------------------------------
4600
4601    #[test]
4602    #[cfg_attr(miri, ignore)]
4603    fn find_config_path_stops_at_svn_dir() {
4604        let dir = test_dir("find-path-svn");
4605        let sub = dir.path().join("sub");
4606        std::fs::create_dir(&sub).unwrap();
4607        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4608
4609        let path = FallowConfig::find_config_path(&sub);
4610        assert!(path.is_none(), "svn root should stop config search");
4611    }
4612
4613    // ------------------------------------------------------------------
4614    // deep_merge: array over object replaces
4615    // ------------------------------------------------------------------
4616
4617    #[test]
4618    fn deep_merge_array_over_object_replaces() {
4619        let mut base = serde_json::json!({"key": "value"});
4620        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
4621        assert_eq!(base, serde_json::json!(["a", "b"]));
4622    }
4623
4624    // ------------------------------------------------------------------
4625    // find_and_load: returns an error when config parses but glob validation fails
4626    // ------------------------------------------------------------------
4627
4628    #[test]
4629    #[cfg_attr(miri, ignore)]
4630    fn find_and_load_returns_error_for_invalid_glob_in_config() {
4631        let dir = test_dir("find-invalid-glob");
4632        std::fs::create_dir(dir.path().join(".git")).unwrap();
4633        std::fs::write(
4634            dir.path().join(".fallowrc.json"),
4635            r#"{"entry": ["[invalid-glob"]}"#,
4636        )
4637        .unwrap();
4638
4639        let result = FallowConfig::find_and_load(dir.path());
4640        assert!(
4641            result.is_err(),
4642            "invalid glob should surface as an error from find_and_load"
4643        );
4644    }
4645
4646    // ------------------------------------------------------------------
4647    // resolve_package_exports: Object map with "." key that is not a
4648    // string or object returns None (the `_ => None` arm)
4649    // ------------------------------------------------------------------
4650
4651    #[test]
4652    fn resolve_package_exports_dot_key_array_returns_none() {
4653        // "." value is an array, which is neither String nor Object
4654        let pkg = serde_json::json!({
4655            "exports": {".": ["array-value"]}
4656        });
4657        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4658        assert!(result.is_none(), "array dot-export should return None");
4659    }
4660
4661    #[test]
4662    fn resolve_package_exports_exports_is_array_returns_none() {
4663        // top-level "exports" is an array (not String or Object)
4664        let pkg = serde_json::json!({
4665            "exports": ["./index.js"]
4666        });
4667        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4668        assert!(result.is_none(), "array-form exports should return None");
4669    }
4670
4671    #[test]
4672    fn resolve_package_exports_object_no_dot_key_returns_none() {
4673        // Object exports without "." key
4674        let pkg = serde_json::json!({
4675            "exports": {"./sub": "./sub.js"}
4676        });
4677        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4678        assert!(result.is_none(), "no dot key should return None");
4679    }
4680
4681    #[test]
4682    fn resolve_package_exports_conditions_without_known_key_returns_none() {
4683        // "." is an Object but none of the known condition keys are present
4684        let pkg = serde_json::json!({
4685            "exports": {".": {"browser": "./browser.js"}}
4686        });
4687        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4688        assert!(result.is_none(), "unknown condition key should return None");
4689    }
4690
4691    // ------------------------------------------------------------------
4692    // npm package: exports condition "import" key (one of the priority keys)
4693    // ------------------------------------------------------------------
4694
4695    #[test]
4696    #[cfg_attr(miri, ignore)]
4697    fn extends_npm_exports_import_condition() {
4698        let dir = test_dir("npm-import-cond");
4699        let pkg_dir = dir.path().join("node_modules/import-config");
4700        std::fs::create_dir_all(&pkg_dir).unwrap();
4701        std::fs::write(
4702            pkg_dir.join("package.json"),
4703            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
4704        )
4705        .unwrap();
4706        std::fs::write(
4707            pkg_dir.join("esm.json"),
4708            r#"{"rules": {"unused-types": "warn"}}"#,
4709        )
4710        .unwrap();
4711
4712        std::fs::write(
4713            dir.path().join(".fallowrc.json"),
4714            r#"{"extends": "npm:import-config"}"#,
4715        )
4716        .unwrap();
4717
4718        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4719        assert_eq!(config.rules.unused_types, Severity::Warn);
4720    }
4721
4722    // ------------------------------------------------------------------
4723    // npm package: exports condition "require" key
4724    // ------------------------------------------------------------------
4725
4726    #[test]
4727    #[cfg_attr(miri, ignore)]
4728    fn extends_npm_exports_require_condition() {
4729        let dir = test_dir("npm-require-cond");
4730        let pkg_dir = dir.path().join("node_modules/require-config");
4731        std::fs::create_dir_all(&pkg_dir).unwrap();
4732        std::fs::write(
4733            pkg_dir.join("package.json"),
4734            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
4735        )
4736        .unwrap();
4737        std::fs::write(
4738            pkg_dir.join("cjs.json"),
4739            r#"{"rules": {"unused-class-members": "warn"}}"#,
4740        )
4741        .unwrap();
4742
4743        std::fs::write(
4744            dir.path().join(".fallowrc.json"),
4745            r#"{"extends": "npm:require-config"}"#,
4746        )
4747        .unwrap();
4748
4749        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4750        assert_eq!(config.rules.unused_class_members, Severity::Warn);
4751    }
4752}