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        if let Some(pattern) = &config.unused_component_props.ignore_pattern
934            && let Err(e) = regex::Regex::new(pattern)
935        {
936            return Err(miette::miette!(
937                "invalid config:\n  - unusedComponentProps.ignorePattern is not a valid regex: {e}"
938            ));
939        }
940
941        Ok(config)
942    }
943
944    /// Validate all user-supplied glob patterns and directory paths in this config.
945    ///
946    /// Accumulates errors from every glob- or path-bearing field so the user
947    /// sees ALL offending values in one run rather than fixing them one at a
948    /// time.
949    ///
950    /// Covered filesystem glob fields: `entry`, `ignorePatterns`,
951    /// `dynamicallyLoaded`, `duplicates.ignore`, `health.ignore`,
952    /// `health.thresholdOverrides[].files`, `overrides[].files`, `ignoreExports[].file`,
953    /// `ignoreCatalogReferences[].consumer`, `boundaries.zones[].patterns`,
954    /// `boundaries.coverage.allowUnmatched`,
955    /// plus every glob-bearing field on inline `framework[]` plugin
956    /// definitions (entry points, always-used, config patterns, used-exports
957    /// patterns, and `fileExists` detection patterns; the last reaches
958    /// `glob::glob` on disk so a `..` segment there is a real path traversal).
959    ///
960    /// Covered specifier glob fields: `ignoreUnresolvedImports`. These match
961    /// raw import strings, so parent-relative specifiers like `../generated/**`
962    /// are valid and only glob syntax is checked.
963    ///
964    /// Covered directory-path fields: `boundaries.zones[].root` and
965    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
966    /// globs), so only the absolute-path + traversal checks apply.
967    ///
968    /// # Errors
969    ///
970    /// Returns a non-empty `Vec` of
971    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
972    /// when any field contains a rejected value.
973    pub fn validate_user_globs(
974        &self,
975    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
976        let mut errors = Vec::new();
977
978        self.validate_top_level_globs(&mut errors);
979        self.validate_ignore_rule_globs(&mut errors);
980        self.validate_boundary_globs(&mut errors);
981
982        for plugin in &self.framework {
983            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
984                errors.append(&mut plugin_errors);
985            }
986        }
987
988        if errors.is_empty() {
989            Ok(())
990        } else {
991            Err(errors)
992        }
993    }
994
995    /// Validate the top-level filesystem and specifier glob fields plus the
996    /// per-override and threshold-override file globs.
997    fn validate_top_level_globs(
998        &self,
999        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1000    ) {
1001        use super::glob_validation::{validate_user_globs, validate_user_specifier_globs};
1002
1003        validate_user_globs(&self.entry, "entry", errors);
1004        validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
1005        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
1006        validate_user_specifier_globs(
1007            &self.ignore_unresolved_imports,
1008            "ignoreUnresolvedImports",
1009            errors,
1010        );
1011        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1012        validate_user_globs(&self.health.ignore, "health.ignore", errors);
1013        for override_entry in &self.health.threshold_overrides {
1014            validate_user_globs(
1015                &override_entry.files,
1016                "health.thresholdOverrides[].files",
1017                errors,
1018            );
1019        }
1020        for override_entry in &self.overrides {
1021            validate_user_globs(&override_entry.files, "overrides[].files", errors);
1022        }
1023    }
1024
1025    /// Validate the `ignoreExports` and `ignoreCatalogReferences` rule globs.
1026    fn validate_ignore_rule_globs(
1027        &self,
1028        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1029    ) {
1030        use super::glob_validation::compile_user_glob;
1031
1032        for rule in &self.ignore_exports {
1033            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1034                errors.push(e);
1035            }
1036        }
1037
1038        for rule in &self.ignore_catalog_references {
1039            if let Some(consumer) = &rule.consumer
1040                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1041            {
1042                errors.push(e);
1043            }
1044        }
1045    }
1046
1047    /// Validate the `boundaries.zones[]` patterns/roots/autoDiscover and the
1048    /// coverage `allowUnmatched` globs.
1049    fn validate_boundary_globs(
1050        &self,
1051        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1052    ) {
1053        use super::glob_validation::{
1054            validate_user_globs, validate_user_path, validate_user_paths,
1055        };
1056
1057        for zone in &self.boundaries.zones {
1058            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1059            if let Some(root) = &zone.root
1060                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1061            {
1062                errors.push(e);
1063            }
1064            validate_user_paths(
1065                &zone.auto_discover,
1066                "boundaries.zones[].autoDiscover",
1067                errors,
1068            );
1069        }
1070        validate_user_globs(
1071            &self.boundaries.coverage.allow_unmatched,
1072            "boundaries.coverage.allowUnmatched",
1073            errors,
1074        );
1075    }
1076
1077    /// Find the config file path without loading it.
1078    /// Searches the same locations as `find_and_load`.
1079    #[must_use]
1080    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1081        let mut dir = start;
1082        loop {
1083            for name in CONFIG_NAMES {
1084                let candidate = dir.join(name);
1085                if candidate.exists() {
1086                    return Some(candidate);
1087                }
1088            }
1089            if is_repo_root(dir) {
1090                break;
1091            }
1092            dir = dir.parent()?;
1093        }
1094        None
1095    }
1096
1097    /// Find and load config, searching from `start` up to the project root.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns an error if a config file is found but cannot be read or parsed.
1102    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1103        let mut dir = start;
1104        loop {
1105            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1106                let candidate = dir.join(name);
1107                if candidate.exists() {
1108                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1109                    match Self::load(&candidate) {
1110                        Ok(config) => return Ok(Some((config, candidate))),
1111                        Err(e) => {
1112                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
1113                        }
1114                    }
1115                }
1116            }
1117            if is_repo_root(dir) {
1118                break;
1119            }
1120            dir = match dir.parent() {
1121                Some(parent) => parent,
1122                None => break,
1123            };
1124        }
1125        Ok(None)
1126    }
1127
1128    /// Generate JSON Schema for the configuration format.
1129    #[must_use]
1130    pub fn json_schema() -> serde_json::Value {
1131        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1132    }
1133
1134    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1135    /// preset and auto-discover expansion.
1136    ///
1137    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1138    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1139    /// before invoking
1140    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1141    /// and
1142    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1143    /// so Bulletproof-style presets whose authored rule references logical
1144    /// groups (`features`) still load cleanly.
1145    ///
1146    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1147    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1148    /// surface every collected error in a single rendered diagnostic, then
1149    /// exit with code 2. Previously these failures emitted `tracing::error!`
1150    /// and continued, producing a flood of false-positive boundary violations
1151    /// at analysis time (#468).
1152    ///
1153    /// `root` is the project root used by `expand_auto_discover` to scan for
1154    /// child directories. Caller is responsible for passing the same root it
1155    /// later hands to `resolve()`.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1160    /// offending zone reference and redundant-root-prefix pattern; the empty
1161    /// case becomes `Ok(())`.
1162    pub fn validate_resolved_boundaries(
1163        &self,
1164        root: &Path,
1165    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1166        use super::boundaries::ZoneValidationError;
1167
1168        let mut boundaries = self.boundaries.clone();
1169        if boundaries.preset.is_some() {
1170            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1171                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1172                .unwrap_or_else(|| "src".to_owned());
1173            boundaries.expand(&source_root);
1174        }
1175        let _logical_groups = boundaries.expand_auto_discover(root);
1176
1177        let mut errors: Vec<ZoneValidationError> = boundaries
1178            .validate_zone_references()
1179            .into_iter()
1180            .map(ZoneValidationError::UnknownZoneReference)
1181            .collect();
1182        errors.extend(
1183            boundaries
1184                .validate_root_prefixes()
1185                .into_iter()
1186                .map(ZoneValidationError::RedundantRootPrefix),
1187        );
1188        errors.extend(
1189            boundaries
1190                .validate_call_rules()
1191                .into_iter()
1192                .map(ZoneValidationError::InvalidForbiddenCallee),
1193        );
1194
1195        if errors.is_empty() {
1196            Ok(())
1197        } else {
1198            Err(errors)
1199        }
1200    }
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206    use crate::CacheConfig;
1207    use crate::PackageJson;
1208    use crate::config::format::OutputFormat;
1209    use crate::config::rules::Severity;
1210
1211    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1212    fn test_dir(_name: &str) -> tempfile::TempDir {
1213        tempfile::tempdir().expect("create temp dir")
1214    }
1215
1216    #[test]
1217    fn fallow_config_deserialize_minimal() {
1218        let toml_str = r#"
1219entry = ["src/main.ts"]
1220"#;
1221        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1222        assert_eq!(config.entry, vec!["src/main.ts"]);
1223        assert!(config.ignore_patterns.is_empty());
1224    }
1225
1226    #[test]
1227    fn fallow_config_deserialize_ignore_exports() {
1228        let toml_str = r#"
1229[[ignoreExports]]
1230file = "src/types/*.ts"
1231exports = ["*"]
1232
1233[[ignoreExports]]
1234file = "src/constants.ts"
1235exports = ["FOO", "BAR"]
1236"#;
1237        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1238        assert_eq!(config.ignore_exports.len(), 2);
1239        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1240        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1241        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1242    }
1243
1244    #[test]
1245    fn fallow_config_deserialize_ignore_dependencies() {
1246        let toml_str = r#"
1247ignoreDependencies = ["autoprefixer", "postcss"]
1248"#;
1249        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1250        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1251    }
1252
1253    #[test]
1254    fn fallow_config_deserialize_ignore_unresolved_imports() {
1255        let toml_str = r#"
1256ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1257"#;
1258        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1259        assert_eq!(
1260            config.ignore_unresolved_imports,
1261            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1262        );
1263    }
1264
1265    #[test]
1266    fn fallow_config_resolve_default_ignores() {
1267        let config = FallowConfig::default();
1268        let resolved = config.resolve(
1269            PathBuf::from("/tmp/test"),
1270            OutputFormat::Human,
1271            4,
1272            true,
1273            true,
1274            None,
1275        );
1276
1277        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1278        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1279        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1280        assert!(resolved.ignore_patterns.is_match(".git/config"));
1281        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1282        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1283        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1284    }
1285
1286    #[test]
1287    fn fallow_config_resolve_custom_ignores() {
1288        let config = FallowConfig {
1289            entry: vec!["src/**/*.ts".to_string()],
1290            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1291            ..Default::default()
1292        };
1293        let resolved = config.resolve(
1294            PathBuf::from("/tmp/test"),
1295            OutputFormat::Json,
1296            4,
1297            false,
1298            true,
1299            None,
1300        );
1301
1302        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1303        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1304        assert!(matches!(resolved.output, OutputFormat::Json));
1305        assert!(!resolved.no_cache);
1306    }
1307
1308    #[test]
1309    fn fallow_config_resolve_cache_dir() {
1310        let config = FallowConfig::default();
1311        let resolved = config.resolve(
1312            PathBuf::from("/tmp/project"),
1313            OutputFormat::Human,
1314            4,
1315            true,
1316            true,
1317            None,
1318        );
1319        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1320        assert!(resolved.no_cache);
1321    }
1322
1323    #[test]
1324    fn package_json_entry_points_main() {
1325        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1326        let entries = pkg.entry_points();
1327        assert!(entries.contains(&"dist/index.js".to_string()));
1328    }
1329
1330    #[test]
1331    fn package_json_entry_points_module() {
1332        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1333        let entries = pkg.entry_points();
1334        assert!(entries.contains(&"dist/index.mjs".to_string()));
1335    }
1336
1337    #[test]
1338    fn package_json_entry_points_types() {
1339        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1340        let entries = pkg.entry_points();
1341        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1342    }
1343
1344    #[test]
1345    fn package_json_entry_points_bin_string() {
1346        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1347        let entries = pkg.entry_points();
1348        assert!(entries.contains(&"bin/cli.js".to_string()));
1349    }
1350
1351    #[test]
1352    fn package_json_entry_points_bin_object() {
1353        let pkg: PackageJson =
1354            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1355                .unwrap();
1356        let entries = pkg.entry_points();
1357        assert!(entries.contains(&"bin/cli.js".to_string()));
1358        assert!(entries.contains(&"bin/serve.js".to_string()));
1359    }
1360
1361    #[test]
1362    fn package_json_entry_points_exports_string() {
1363        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1364        let entries = pkg.entry_points();
1365        assert!(entries.contains(&"./dist/index.js".to_string()));
1366    }
1367
1368    #[test]
1369    fn package_json_entry_points_exports_object() {
1370        let pkg: PackageJson = serde_json::from_str(
1371            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1372        )
1373        .unwrap();
1374        let entries = pkg.entry_points();
1375        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1376        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1377    }
1378
1379    #[test]
1380    fn package_json_dependency_names() {
1381        let pkg: PackageJson = serde_json::from_str(
1382            r#"{
1383            "dependencies": {"react": "^18", "lodash": "^4"},
1384            "devDependencies": {"typescript": "^5"},
1385            "peerDependencies": {"react-dom": "^18"}
1386        }"#,
1387        )
1388        .unwrap();
1389
1390        let all = pkg.all_dependency_names();
1391        assert!(all.contains(&"react".to_string()));
1392        assert!(all.contains(&"lodash".to_string()));
1393        assert!(all.contains(&"typescript".to_string()));
1394        assert!(all.contains(&"react-dom".to_string()));
1395
1396        let prod = pkg.production_dependency_names();
1397        assert!(prod.contains(&"react".to_string()));
1398        assert!(!prod.contains(&"typescript".to_string()));
1399
1400        let dev = pkg.dev_dependency_names();
1401        assert!(dev.contains(&"typescript".to_string()));
1402        assert!(!dev.contains(&"react".to_string()));
1403    }
1404
1405    #[test]
1406    fn package_json_no_dependencies() {
1407        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1408        assert!(pkg.all_dependency_names().is_empty());
1409        assert!(pkg.production_dependency_names().is_empty());
1410        assert!(pkg.dev_dependency_names().is_empty());
1411        assert!(pkg.entry_points().is_empty());
1412    }
1413
1414    #[test]
1415    fn rules_deserialize_toml_kebab_case() {
1416        let toml_str = r#"
1417[rules]
1418unused-files = "error"
1419unused-exports = "warn"
1420unused-types = "off"
1421"#;
1422        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1423        assert_eq!(config.rules.unused_files, Severity::Error);
1424        assert_eq!(config.rules.unused_exports, Severity::Warn);
1425        assert_eq!(config.rules.unused_types, Severity::Off);
1426        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1427    }
1428
1429    #[test]
1430    fn config_without_rules_defaults_to_error() {
1431        let toml_str = r#"
1432entry = ["src/main.ts"]
1433"#;
1434        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1435        assert_eq!(config.rules.unused_files, Severity::Error);
1436        assert_eq!(config.rules.unused_exports, Severity::Error);
1437    }
1438
1439    #[test]
1440    fn fallow_config_denies_unknown_fields() {
1441        let toml_str = r"
1442unknown_field = true
1443";
1444        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1445        assert!(result.is_err());
1446    }
1447
1448    #[test]
1449    fn fallow_config_deserialize_json() {
1450        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1451        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1452        assert_eq!(config.entry, vec!["src/main.ts"]);
1453    }
1454
1455    #[test]
1456    fn fallow_config_deserialize_jsonc() {
1457        let jsonc_str = r#"{
1458            "entry": ["src/main.ts"],
1459            "rules": {
1460                "unused-files": "warn"
1461            }
1462        }"#;
1463        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1464        assert_eq!(config.entry, vec!["src/main.ts"]);
1465        assert_eq!(config.rules.unused_files, Severity::Warn);
1466    }
1467
1468    #[test]
1469    fn fallow_config_json_with_schema_field() {
1470        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1471        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1472        assert_eq!(config.entry, vec!["src/main.ts"]);
1473    }
1474
1475    #[test]
1476    fn fallow_config_json_schema_generation() {
1477        let schema = FallowConfig::json_schema();
1478        assert!(schema.is_object());
1479        let obj = schema.as_object().unwrap();
1480        assert!(obj.contains_key("properties"));
1481    }
1482
1483    #[test]
1484    fn config_format_detection() {
1485        assert!(matches!(
1486            ConfigFormat::from_path(Path::new("fallow.toml")),
1487            ConfigFormat::Toml
1488        ));
1489        assert!(matches!(
1490            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1491            ConfigFormat::Json
1492        ));
1493        assert!(matches!(
1494            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1495            ConfigFormat::Json
1496        ));
1497        assert!(matches!(
1498            ConfigFormat::from_path(Path::new(".fallow.toml")),
1499            ConfigFormat::Toml
1500        ));
1501    }
1502
1503    #[test]
1504    fn config_names_priority_order() {
1505        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1506        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1507        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1508        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1509    }
1510
1511    #[test]
1512    fn load_json_config_file() {
1513        let dir = test_dir("json-config");
1514        let config_path = dir.path().join(".fallowrc.json");
1515        std::fs::write(
1516            &config_path,
1517            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1518        )
1519        .unwrap();
1520
1521        let config = FallowConfig::load(&config_path).unwrap();
1522        assert_eq!(config.entry, vec!["src/index.ts"]);
1523        assert_eq!(config.rules.unused_exports, Severity::Warn);
1524    }
1525
1526    #[test]
1527    fn load_json_config_file_with_health_threshold_override() {
1528        let dir = test_dir("json-health-threshold-override");
1529        let config_path = dir.path().join(".fallowrc.json");
1530        std::fs::write(
1531            &config_path,
1532            r#"{
1533                "health": {
1534                    "thresholdOverrides": [
1535                        {
1536                            "files": ["src/legacy.ts"],
1537                            "functions": ["legacyFlow"],
1538                            "maxCyclomatic": 30,
1539                            "maxCognitive": 25,
1540                            "maxCrap": 80.5,
1541                            "reason": "legacy migration"
1542                        }
1543                    ]
1544                }
1545            }"#,
1546        )
1547        .unwrap();
1548
1549        let config = FallowConfig::load(&config_path).unwrap();
1550        let override_config = &config.health.threshold_overrides[0];
1551        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1552        assert_eq!(override_config.functions, vec!["legacyFlow"]);
1553        assert_eq!(override_config.max_cyclomatic, Some(30));
1554        assert_eq!(override_config.max_cognitive, Some(25));
1555        assert_eq!(override_config.max_crap, Some(80.5));
1556        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1557    }
1558
1559    #[test]
1560    fn load_jsonc_config_file() {
1561        let dir = test_dir("jsonc-config");
1562        let config_path = dir.path().join(".fallowrc.json");
1563        std::fs::write(
1564            &config_path,
1565            r#"{
1566                "entry": ["src/index.ts"],
1567                /* Block comment */
1568                "rules": {
1569                    "unused-exports": "warn"
1570                }
1571            }"#,
1572        )
1573        .unwrap();
1574
1575        let config = FallowConfig::load(&config_path).unwrap();
1576        assert_eq!(config.entry, vec!["src/index.ts"]);
1577        assert_eq!(config.rules.unused_exports, Severity::Warn);
1578    }
1579
1580    #[test]
1581    fn load_jsonc_config_file_with_health_threshold_override() {
1582        let dir = test_dir("jsonc-health-threshold-override");
1583        let config_path = dir.path().join(".fallowrc.jsonc");
1584        std::fs::write(
1585            &config_path,
1586            r#"{
1587                "health": {
1588                    // Empty functions means every function in matching files.
1589                    "thresholdOverrides": [
1590                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1591                    ]
1592                }
1593            }"#,
1594        )
1595        .unwrap();
1596
1597        let config = FallowConfig::load(&config_path).unwrap();
1598        let override_config = &config.health.threshold_overrides[0];
1599        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1600        assert!(override_config.functions.is_empty());
1601        assert_eq!(override_config.max_cognitive, Some(25));
1602    }
1603
1604    #[test]
1605    fn load_fallowrc_jsonc_extension() {
1606        let dir = test_dir("jsonc-extension");
1607        let config_path = dir.path().join(".fallowrc.jsonc");
1608        std::fs::write(
1609            &config_path,
1610            r#"{
1611                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1612                "entry": ["src/index.ts"]
1613            }"#,
1614        )
1615        .unwrap();
1616
1617        let config = FallowConfig::load(&config_path).unwrap();
1618        assert_eq!(config.entry, vec!["src/index.ts"]);
1619        assert_eq!(
1620            config.ignore_dependencies,
1621            vec!["tailwindcss-react-aria-components"]
1622        );
1623    }
1624
1625    #[test]
1626    fn json_config_ignore_dependencies_camel_case() {
1627        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1628        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1629        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1630    }
1631
1632    #[test]
1633    fn json_config_ignore_unresolved_imports_camel_case() {
1634        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1635        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1636        assert_eq!(
1637            config.ignore_unresolved_imports,
1638            vec!["@example/icons", "@example/icons/**"]
1639        );
1640    }
1641
1642    #[test]
1643    fn json_config_all_fields() {
1644        let json_str = r#"{
1645            "ignoreDependencies": ["lodash"],
1646            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1647            "rules": {
1648                "unused-files": "off",
1649                "unused-exports": "warn",
1650                "unused-dependencies": "error",
1651                "unused-dev-dependencies": "off",
1652                "unused-types": "warn",
1653                "unused-enum-members": "error",
1654                "unused-class-members": "off",
1655                "unresolved-imports": "warn",
1656                "unlisted-dependencies": "error",
1657                "duplicate-exports": "off"
1658            },
1659            "duplicates": {
1660                "minTokens": 100,
1661                "minLines": 10,
1662                "skipLocal": true
1663            }
1664        }"#;
1665        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1666        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1667        assert_eq!(config.rules.unused_files, Severity::Off);
1668        assert_eq!(config.rules.unused_exports, Severity::Warn);
1669        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1670        assert_eq!(config.duplicates.min_tokens, 100);
1671        assert_eq!(config.duplicates.min_lines, 10);
1672        assert!(config.duplicates.skip_local);
1673    }
1674
1675    #[test]
1676    fn extends_single_base() {
1677        let dir = test_dir("extends-single");
1678
1679        std::fs::write(
1680            dir.path().join("base.json"),
1681            r#"{"rules": {"unused-files": "warn"}}"#,
1682        )
1683        .unwrap();
1684        std::fs::write(
1685            dir.path().join(".fallowrc.json"),
1686            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1687        )
1688        .unwrap();
1689
1690        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1691        assert_eq!(config.rules.unused_files, Severity::Warn);
1692        assert_eq!(config.entry, vec!["src/index.ts"]);
1693        assert_eq!(config.rules.unused_exports, Severity::Error);
1694    }
1695
1696    #[test]
1697    fn extends_overlay_overrides_base() {
1698        let dir = test_dir("extends-overlay");
1699
1700        std::fs::write(
1701            dir.path().join("base.json"),
1702            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1703        )
1704        .unwrap();
1705        std::fs::write(
1706            dir.path().join(".fallowrc.json"),
1707            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1708        )
1709        .unwrap();
1710
1711        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1712        assert_eq!(config.rules.unused_files, Severity::Error);
1713        assert_eq!(config.rules.unused_exports, Severity::Off);
1714    }
1715
1716    #[test]
1717    fn extends_chained() {
1718        let dir = test_dir("extends-chained");
1719
1720        std::fs::write(
1721            dir.path().join("grandparent.json"),
1722            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1723        )
1724        .unwrap();
1725        std::fs::write(
1726            dir.path().join("parent.json"),
1727            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1728        )
1729        .unwrap();
1730        std::fs::write(
1731            dir.path().join(".fallowrc.json"),
1732            r#"{"extends": ["parent.json"]}"#,
1733        )
1734        .unwrap();
1735
1736        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1737        assert_eq!(config.rules.unused_files, Severity::Warn);
1738        assert_eq!(config.rules.unused_exports, Severity::Warn);
1739    }
1740
1741    #[test]
1742    fn extends_circular_detected() {
1743        let dir = test_dir("extends-circular");
1744
1745        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1746        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1747
1748        let result = FallowConfig::load(&dir.path().join("a.json"));
1749        assert!(result.is_err());
1750        let err_msg = format!("{}", result.unwrap_err());
1751        assert!(
1752            err_msg.contains("Circular extends"),
1753            "Expected circular error, got: {err_msg}"
1754        );
1755    }
1756
1757    #[test]
1758    fn extends_missing_file_errors() {
1759        let dir = test_dir("extends-missing");
1760
1761        std::fs::write(
1762            dir.path().join(".fallowrc.json"),
1763            r#"{"extends": ["nonexistent.json"]}"#,
1764        )
1765        .unwrap();
1766
1767        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1768        assert!(result.is_err());
1769        let err_msg = format!("{}", result.unwrap_err());
1770        assert!(
1771            err_msg.contains("not found"),
1772            "Expected not found error, got: {err_msg}"
1773        );
1774    }
1775
1776    #[test]
1777    fn sealed_allows_in_directory_extends() {
1778        let dir = test_dir("sealed-allows-local");
1779        std::fs::write(
1780            dir.path().join("base.json"),
1781            r#"{"ignorePatterns": ["gen/**"]}"#,
1782        )
1783        .unwrap();
1784        std::fs::write(
1785            dir.path().join(".fallowrc.json"),
1786            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1787        )
1788        .unwrap();
1789
1790        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1791        assert!(config.sealed);
1792        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1793    }
1794
1795    #[test]
1796    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
1797        let dir = test_dir("boundary-coverage-invalid-glob");
1798        std::fs::write(
1799            dir.path().join(".fallowrc.json"),
1800            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
1801        )
1802        .unwrap();
1803
1804        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1805        assert!(result.is_err());
1806        let err_msg = format!("{}", result.unwrap_err());
1807        assert!(
1808            err_msg.contains("boundaries.coverage.allowUnmatched"),
1809            "expected coverage field in error, got: {err_msg}"
1810        );
1811    }
1812
1813    #[test]
1814    fn sealed_rejects_extends_escaping_directory() {
1815        let dir = test_dir("sealed-rejects-escape");
1816        let sub = dir.path().join("packages").join("app");
1817        std::fs::create_dir_all(&sub).unwrap();
1818
1819        std::fs::write(
1820            dir.path().join("base.json"),
1821            r#"{"ignorePatterns": ["dist/**"]}"#,
1822        )
1823        .unwrap();
1824        std::fs::write(
1825            sub.join(".fallowrc.json"),
1826            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1827        )
1828        .unwrap();
1829
1830        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1831        assert!(
1832            result.is_err(),
1833            "Expected sealed config to reject escaping extends"
1834        );
1835        let err_msg = format!("{}", result.unwrap_err());
1836        assert!(
1837            err_msg.contains("sealed"),
1838            "Error must mention sealed: {err_msg}"
1839        );
1840        assert!(
1841            err_msg.contains("outside the config's directory"),
1842            "Error must explain the constraint: {err_msg}"
1843        );
1844    }
1845
1846    #[test]
1847    fn sealed_rejects_https_extends() {
1848        let dir = test_dir("sealed-rejects-https");
1849        std::fs::write(
1850            dir.path().join(".fallowrc.json"),
1851            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1852        )
1853        .unwrap();
1854
1855        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1856        assert!(result.is_err());
1857        let err_msg = format!("{}", result.unwrap_err());
1858        assert!(
1859            err_msg.contains("sealed"),
1860            "Error must mention sealed: {err_msg}"
1861        );
1862        assert!(
1863            err_msg.contains("URL extends"),
1864            "Error must mention URL: {err_msg}"
1865        );
1866    }
1867
1868    #[test]
1869    fn sealed_rejects_npm_extends() {
1870        let dir = test_dir("sealed-rejects-npm");
1871        std::fs::write(
1872            dir.path().join(".fallowrc.json"),
1873            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1874        )
1875        .unwrap();
1876
1877        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1878        assert!(result.is_err());
1879        let err_msg = format!("{}", result.unwrap_err());
1880        assert!(
1881            err_msg.contains("sealed"),
1882            "Error must mention sealed: {err_msg}"
1883        );
1884        assert!(
1885            err_msg.contains("npm extends"),
1886            "Error must mention npm: {err_msg}"
1887        );
1888    }
1889
1890    #[test]
1891    fn sealed_default_is_false() {
1892        let dir = test_dir("sealed-default");
1893        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1894        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1895        assert!(!config.sealed);
1896    }
1897
1898    #[test]
1899    fn sealed_false_allows_escaping_extends() {
1900        let dir = test_dir("sealed-false-allows");
1901        let sub = dir.path().join("packages").join("app");
1902        std::fs::create_dir_all(&sub).unwrap();
1903
1904        std::fs::write(
1905            dir.path().join("base.json"),
1906            r#"{"ignorePatterns": ["dist/**"]}"#,
1907        )
1908        .unwrap();
1909        std::fs::write(
1910            sub.join(".fallowrc.json"),
1911            r#"{"extends": ["../../base.json"]}"#,
1912        )
1913        .unwrap();
1914
1915        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1916        assert!(!config.sealed);
1917        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1918    }
1919
1920    #[test]
1921    fn extends_string_sugar() {
1922        let dir = test_dir("extends-string");
1923
1924        std::fs::write(
1925            dir.path().join("base.json"),
1926            r#"{"ignorePatterns": ["gen/**"]}"#,
1927        )
1928        .unwrap();
1929        std::fs::write(
1930            dir.path().join(".fallowrc.json"),
1931            r#"{"extends": "base.json"}"#,
1932        )
1933        .unwrap();
1934
1935        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1936        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1937    }
1938
1939    #[test]
1940    fn extends_deep_merge_preserves_arrays() {
1941        let dir = test_dir("extends-array");
1942
1943        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1944        std::fs::write(
1945            dir.path().join(".fallowrc.json"),
1946            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1947        )
1948        .unwrap();
1949
1950        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1951        assert_eq!(config.entry, vec!["src/b.ts"]);
1952    }
1953
1954    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1955        let pkg_dir = root.join("node_modules").join(name);
1956        std::fs::create_dir_all(&pkg_dir).unwrap();
1957        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1958    }
1959
1960    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1961        let pkg_dir = root.join("node_modules").join(name);
1962        std::fs::create_dir_all(&pkg_dir).unwrap();
1963        std::fs::write(
1964            pkg_dir.join("package.json"),
1965            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1966        )
1967        .unwrap();
1968        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1969    }
1970
1971    #[test]
1972    fn extends_npm_basic_unscoped() {
1973        let dir = test_dir("npm-basic");
1974        create_npm_package(
1975            dir.path(),
1976            "fallow-config-acme",
1977            r#"{"rules": {"unused-files": "warn"}}"#,
1978        );
1979        std::fs::write(
1980            dir.path().join(".fallowrc.json"),
1981            r#"{"extends": "npm:fallow-config-acme"}"#,
1982        )
1983        .unwrap();
1984
1985        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1986        assert_eq!(config.rules.unused_files, Severity::Warn);
1987    }
1988
1989    #[test]
1990    fn extends_npm_scoped_package() {
1991        let dir = test_dir("npm-scoped");
1992        create_npm_package(
1993            dir.path(),
1994            "@company/fallow-config",
1995            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1996        );
1997        std::fs::write(
1998            dir.path().join(".fallowrc.json"),
1999            r#"{"extends": "npm:@company/fallow-config"}"#,
2000        )
2001        .unwrap();
2002
2003        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2004        assert_eq!(config.rules.unused_exports, Severity::Off);
2005        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2006    }
2007
2008    #[test]
2009    fn extends_npm_with_subpath() {
2010        let dir = test_dir("npm-subpath");
2011        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2012        std::fs::create_dir_all(&pkg_dir).unwrap();
2013        std::fs::write(
2014            pkg_dir.join("strict.json"),
2015            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2016        )
2017        .unwrap();
2018
2019        std::fs::write(
2020            dir.path().join(".fallowrc.json"),
2021            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2022        )
2023        .unwrap();
2024
2025        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2026        assert_eq!(config.rules.unused_files, Severity::Error);
2027        assert_eq!(config.rules.unused_exports, Severity::Error);
2028    }
2029
2030    #[test]
2031    fn extends_npm_package_json_main() {
2032        let dir = test_dir("npm-main");
2033        create_npm_package_with_main(
2034            dir.path(),
2035            "fallow-config-acme",
2036            "config.json",
2037            r#"{"rules": {"unused-types": "off"}}"#,
2038        );
2039        std::fs::write(
2040            dir.path().join(".fallowrc.json"),
2041            r#"{"extends": "npm:fallow-config-acme"}"#,
2042        )
2043        .unwrap();
2044
2045        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2046        assert_eq!(config.rules.unused_types, Severity::Off);
2047    }
2048
2049    #[test]
2050    fn extends_npm_package_json_exports_string() {
2051        let dir = test_dir("npm-exports-str");
2052        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2053        std::fs::create_dir_all(&pkg_dir).unwrap();
2054        std::fs::write(
2055            pkg_dir.join("package.json"),
2056            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2057        )
2058        .unwrap();
2059        std::fs::write(
2060            pkg_dir.join("base.json"),
2061            r#"{"rules": {"circular-dependencies": "warn"}}"#,
2062        )
2063        .unwrap();
2064
2065        std::fs::write(
2066            dir.path().join(".fallowrc.json"),
2067            r#"{"extends": "npm:fallow-config-co"}"#,
2068        )
2069        .unwrap();
2070
2071        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2072        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2073    }
2074
2075    #[test]
2076    fn extends_npm_package_json_exports_object() {
2077        let dir = test_dir("npm-exports-obj");
2078        let pkg_dir = dir.path().join("node_modules/@co/cfg");
2079        std::fs::create_dir_all(&pkg_dir).unwrap();
2080        std::fs::write(
2081            pkg_dir.join("package.json"),
2082            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2083        )
2084        .unwrap();
2085        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2086
2087        std::fs::write(
2088            dir.path().join(".fallowrc.json"),
2089            r#"{"extends": "npm:@co/cfg"}"#,
2090        )
2091        .unwrap();
2092
2093        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2094        assert_eq!(config.entry, vec!["src/app.ts"]);
2095    }
2096
2097    #[test]
2098    fn extends_npm_exports_takes_priority_over_main() {
2099        let dir = test_dir("npm-exports-prio");
2100        let pkg_dir = dir.path().join("node_modules/my-config");
2101        std::fs::create_dir_all(&pkg_dir).unwrap();
2102        std::fs::write(
2103            pkg_dir.join("package.json"),
2104            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2105        )
2106        .unwrap();
2107        std::fs::write(
2108            pkg_dir.join("old.json"),
2109            r#"{"rules": {"unused-files": "off"}}"#,
2110        )
2111        .unwrap();
2112        std::fs::write(
2113            pkg_dir.join("new.json"),
2114            r#"{"rules": {"unused-files": "warn"}}"#,
2115        )
2116        .unwrap();
2117
2118        std::fs::write(
2119            dir.path().join(".fallowrc.json"),
2120            r#"{"extends": "npm:my-config"}"#,
2121        )
2122        .unwrap();
2123
2124        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2125        assert_eq!(config.rules.unused_files, Severity::Warn);
2126    }
2127
2128    #[test]
2129    fn extends_npm_walk_up_directories() {
2130        let dir = test_dir("npm-walkup");
2131        create_npm_package(
2132            dir.path(),
2133            "shared-config",
2134            r#"{"rules": {"unused-files": "warn"}}"#,
2135        );
2136        let sub = dir.path().join("packages/app");
2137        std::fs::create_dir_all(&sub).unwrap();
2138        std::fs::write(
2139            sub.join(".fallowrc.json"),
2140            r#"{"extends": "npm:shared-config"}"#,
2141        )
2142        .unwrap();
2143
2144        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2145        assert_eq!(config.rules.unused_files, Severity::Warn);
2146    }
2147
2148    #[test]
2149    fn extends_npm_overlay_overrides_base() {
2150        let dir = test_dir("npm-overlay");
2151        create_npm_package(
2152            dir.path(),
2153            "@company/base",
2154            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2155        );
2156        std::fs::write(
2157            dir.path().join(".fallowrc.json"),
2158            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2159        )
2160        .unwrap();
2161
2162        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2163        assert_eq!(config.rules.unused_files, Severity::Error);
2164        assert_eq!(config.rules.unused_exports, Severity::Off);
2165        assert_eq!(config.entry, vec!["src/app.ts"]);
2166    }
2167
2168    #[test]
2169    fn extends_npm_chained_with_relative() {
2170        let dir = test_dir("npm-chained");
2171        let pkg_dir = dir.path().join("node_modules/my-config");
2172        std::fs::create_dir_all(&pkg_dir).unwrap();
2173        std::fs::write(
2174            pkg_dir.join("base.json"),
2175            r#"{"rules": {"unused-files": "warn"}}"#,
2176        )
2177        .unwrap();
2178        std::fs::write(
2179            pkg_dir.join(".fallowrc.json"),
2180            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2181        )
2182        .unwrap();
2183
2184        std::fs::write(
2185            dir.path().join(".fallowrc.json"),
2186            r#"{"extends": "npm:my-config"}"#,
2187        )
2188        .unwrap();
2189
2190        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2191        assert_eq!(config.rules.unused_files, Severity::Warn);
2192        assert_eq!(config.rules.unused_exports, Severity::Off);
2193    }
2194
2195    #[test]
2196    fn extends_npm_mixed_with_relative_paths() {
2197        let dir = test_dir("npm-mixed");
2198        create_npm_package(
2199            dir.path(),
2200            "shared-base",
2201            r#"{"rules": {"unused-files": "off"}}"#,
2202        );
2203        std::fs::write(
2204            dir.path().join("local-overrides.json"),
2205            r#"{"rules": {"unused-files": "warn"}}"#,
2206        )
2207        .unwrap();
2208        std::fs::write(
2209            dir.path().join(".fallowrc.json"),
2210            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2211        )
2212        .unwrap();
2213
2214        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2215        assert_eq!(config.rules.unused_files, Severity::Warn);
2216    }
2217
2218    #[test]
2219    fn extends_npm_missing_package_errors() {
2220        let dir = test_dir("npm-missing");
2221        std::fs::write(
2222            dir.path().join(".fallowrc.json"),
2223            r#"{"extends": "npm:nonexistent-package"}"#,
2224        )
2225        .unwrap();
2226
2227        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2228        assert!(result.is_err());
2229        let err_msg = format!("{}", result.unwrap_err());
2230        assert!(
2231            err_msg.contains("not found"),
2232            "Expected 'not found' error, got: {err_msg}"
2233        );
2234        assert!(
2235            err_msg.contains("nonexistent-package"),
2236            "Expected package name in error, got: {err_msg}"
2237        );
2238        assert!(
2239            err_msg.contains("install it"),
2240            "Expected install hint in error, got: {err_msg}"
2241        );
2242    }
2243
2244    #[test]
2245    fn extends_npm_no_config_in_package_errors() {
2246        let dir = test_dir("npm-no-config");
2247        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2248        std::fs::create_dir_all(&pkg_dir).unwrap();
2249        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2250
2251        std::fs::write(
2252            dir.path().join(".fallowrc.json"),
2253            r#"{"extends": "npm:empty-pkg"}"#,
2254        )
2255        .unwrap();
2256
2257        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2258        assert!(result.is_err());
2259        let err_msg = format!("{}", result.unwrap_err());
2260        assert!(
2261            err_msg.contains("No fallow config found"),
2262            "Expected 'No fallow config found' error, got: {err_msg}"
2263        );
2264    }
2265
2266    #[test]
2267    fn extends_npm_missing_subpath_errors() {
2268        let dir = test_dir("npm-missing-sub");
2269        let pkg_dir = dir.path().join("node_modules/@co/config");
2270        std::fs::create_dir_all(&pkg_dir).unwrap();
2271
2272        std::fs::write(
2273            dir.path().join(".fallowrc.json"),
2274            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2275        )
2276        .unwrap();
2277
2278        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2279        assert!(result.is_err());
2280        let err_msg = format!("{}", result.unwrap_err());
2281        assert!(
2282            err_msg.contains("nonexistent.json"),
2283            "Expected subpath in error, got: {err_msg}"
2284        );
2285    }
2286
2287    #[test]
2288    fn extends_npm_empty_specifier_errors() {
2289        let dir = test_dir("npm-empty");
2290        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
2291
2292        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2293        assert!(result.is_err());
2294        let err_msg = format!("{}", result.unwrap_err());
2295        assert!(
2296            err_msg.contains("Empty npm specifier"),
2297            "Expected 'Empty npm specifier' error, got: {err_msg}"
2298        );
2299    }
2300
2301    #[test]
2302    fn extends_npm_space_after_colon_trimmed() {
2303        let dir = test_dir("npm-space");
2304        create_npm_package(
2305            dir.path(),
2306            "fallow-config-acme",
2307            r#"{"rules": {"unused-files": "warn"}}"#,
2308        );
2309        std::fs::write(
2310            dir.path().join(".fallowrc.json"),
2311            r#"{"extends": "npm: fallow-config-acme"}"#,
2312        )
2313        .unwrap();
2314
2315        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2316        assert_eq!(config.rules.unused_files, Severity::Warn);
2317    }
2318
2319    #[test]
2320    fn extends_npm_exports_node_condition() {
2321        let dir = test_dir("npm-node-cond");
2322        let pkg_dir = dir.path().join("node_modules/node-config");
2323        std::fs::create_dir_all(&pkg_dir).unwrap();
2324        std::fs::write(
2325            pkg_dir.join("package.json"),
2326            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
2327        )
2328        .unwrap();
2329        std::fs::write(
2330            pkg_dir.join("node.json"),
2331            r#"{"rules": {"unused-files": "off"}}"#,
2332        )
2333        .unwrap();
2334
2335        std::fs::write(
2336            dir.path().join(".fallowrc.json"),
2337            r#"{"extends": "npm:node-config"}"#,
2338        )
2339        .unwrap();
2340
2341        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2342        assert_eq!(config.rules.unused_files, Severity::Off);
2343    }
2344
2345    #[test]
2346    fn parse_npm_specifier_unscoped() {
2347        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
2348    }
2349
2350    #[test]
2351    fn parse_npm_specifier_unscoped_with_subpath() {
2352        assert_eq!(
2353            parse_npm_specifier("my-config/strict.json"),
2354            ("my-config", Some("strict.json"))
2355        );
2356    }
2357
2358    #[test]
2359    fn parse_npm_specifier_scoped() {
2360        assert_eq!(
2361            parse_npm_specifier("@company/fallow-config"),
2362            ("@company/fallow-config", None)
2363        );
2364    }
2365
2366    #[test]
2367    fn parse_npm_specifier_scoped_with_subpath() {
2368        assert_eq!(
2369            parse_npm_specifier("@company/fallow-config/strict.json"),
2370            ("@company/fallow-config", Some("strict.json"))
2371        );
2372    }
2373
2374    #[test]
2375    fn parse_npm_specifier_scoped_with_nested_subpath() {
2376        assert_eq!(
2377            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
2378            ("@company/fallow-config", Some("presets/strict.json"))
2379        );
2380    }
2381
2382    #[test]
2383    fn extends_npm_subpath_traversal_rejected() {
2384        let dir = test_dir("npm-traversal-sub");
2385        let pkg_dir = dir.path().join("node_modules/evil-pkg");
2386        std::fs::create_dir_all(&pkg_dir).unwrap();
2387        std::fs::write(
2388            dir.path().join("secret.json"),
2389            r#"{"entry": ["stolen.ts"]}"#,
2390        )
2391        .unwrap();
2392
2393        std::fs::write(
2394            dir.path().join(".fallowrc.json"),
2395            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
2396        )
2397        .unwrap();
2398
2399        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2400        assert!(result.is_err());
2401        let err_msg = format!("{}", result.unwrap_err());
2402        assert!(
2403            err_msg.contains("traversal") || err_msg.contains("not found"),
2404            "Expected traversal or not-found error, got: {err_msg}"
2405        );
2406    }
2407
2408    #[test]
2409    fn extends_npm_dotdot_package_name_rejected() {
2410        let dir = test_dir("npm-dotdot-name");
2411        std::fs::write(
2412            dir.path().join(".fallowrc.json"),
2413            r#"{"extends": "npm:../relative"}"#,
2414        )
2415        .unwrap();
2416
2417        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2418        assert!(result.is_err());
2419        let err_msg = format!("{}", result.unwrap_err());
2420        assert!(
2421            err_msg.contains("path traversal"),
2422            "Expected 'path traversal' error, got: {err_msg}"
2423        );
2424    }
2425
2426    #[test]
2427    fn extends_npm_scoped_without_name_rejected() {
2428        let dir = test_dir("npm-scope-only");
2429        std::fs::write(
2430            dir.path().join(".fallowrc.json"),
2431            r#"{"extends": "npm:@scope"}"#,
2432        )
2433        .unwrap();
2434
2435        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2436        assert!(result.is_err());
2437        let err_msg = format!("{}", result.unwrap_err());
2438        assert!(
2439            err_msg.contains("@scope/name"),
2440            "Expected scoped name format error, got: {err_msg}"
2441        );
2442    }
2443
2444    #[test]
2445    fn extends_npm_malformed_package_json_errors() {
2446        let dir = test_dir("npm-bad-pkgjson");
2447        let pkg_dir = dir.path().join("node_modules/bad-pkg");
2448        std::fs::create_dir_all(&pkg_dir).unwrap();
2449        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
2450
2451        std::fs::write(
2452            dir.path().join(".fallowrc.json"),
2453            r#"{"extends": "npm:bad-pkg"}"#,
2454        )
2455        .unwrap();
2456
2457        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2458        assert!(result.is_err());
2459        let err_msg = format!("{}", result.unwrap_err());
2460        assert!(
2461            err_msg.contains("Failed to parse"),
2462            "Expected parse error, got: {err_msg}"
2463        );
2464    }
2465
2466    #[test]
2467    fn extends_npm_exports_traversal_rejected() {
2468        let dir = test_dir("npm-exports-escape");
2469        let pkg_dir = dir.path().join("node_modules/evil-exports");
2470        std::fs::create_dir_all(&pkg_dir).unwrap();
2471        std::fs::write(
2472            pkg_dir.join("package.json"),
2473            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
2474        )
2475        .unwrap();
2476        std::fs::write(
2477            dir.path().join("secret.json"),
2478            r#"{"entry": ["stolen.ts"]}"#,
2479        )
2480        .unwrap();
2481
2482        std::fs::write(
2483            dir.path().join(".fallowrc.json"),
2484            r#"{"extends": "npm:evil-exports"}"#,
2485        )
2486        .unwrap();
2487
2488        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2489        assert!(result.is_err());
2490        let err_msg = format!("{}", result.unwrap_err());
2491        assert!(
2492            err_msg.contains("traversal"),
2493            "Expected traversal error, got: {err_msg}"
2494        );
2495    }
2496
2497    #[test]
2498    fn deep_merge_scalar_overlay_replaces_base() {
2499        let mut base = serde_json::json!("hello");
2500        deep_merge_json(&mut base, serde_json::json!("world"));
2501        assert_eq!(base, serde_json::json!("world"));
2502    }
2503
2504    #[test]
2505    fn deep_merge_array_overlay_replaces_base() {
2506        let mut base = serde_json::json!(["a", "b"]);
2507        deep_merge_json(&mut base, serde_json::json!(["c"]));
2508        assert_eq!(base, serde_json::json!(["c"]));
2509    }
2510
2511    #[test]
2512    fn deep_merge_nested_object_merge() {
2513        let mut base = serde_json::json!({
2514            "level1": {
2515                "level2": {
2516                    "a": 1,
2517                    "b": 2
2518                }
2519            }
2520        });
2521        let overlay = serde_json::json!({
2522            "level1": {
2523                "level2": {
2524                    "b": 99,
2525                    "c": 3
2526                }
2527            }
2528        });
2529        deep_merge_json(&mut base, overlay);
2530        assert_eq!(base["level1"]["level2"]["a"], 1);
2531        assert_eq!(base["level1"]["level2"]["b"], 99);
2532        assert_eq!(base["level1"]["level2"]["c"], 3);
2533    }
2534
2535    #[test]
2536    fn deep_merge_overlay_adds_new_fields() {
2537        let mut base = serde_json::json!({"existing": true});
2538        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2539        deep_merge_json(&mut base, overlay);
2540        assert_eq!(base["existing"], true);
2541        assert_eq!(base["new_field"], "added");
2542        assert_eq!(base["another"], 42);
2543    }
2544
2545    #[test]
2546    fn deep_merge_null_overlay_replaces_object() {
2547        let mut base = serde_json::json!({"key": "value"});
2548        deep_merge_json(&mut base, serde_json::json!(null));
2549        assert_eq!(base, serde_json::json!(null));
2550    }
2551
2552    #[test]
2553    fn deep_merge_empty_object_overlay_preserves_base() {
2554        let mut base = serde_json::json!({"a": 1, "b": 2});
2555        deep_merge_json(&mut base, serde_json::json!({}));
2556        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2557    }
2558
2559    #[test]
2560    fn rules_severity_error_warn_off_from_json() {
2561        let json_str = r#"{
2562            "rules": {
2563                "unused-files": "error",
2564                "unused-exports": "warn",
2565                "unused-types": "off"
2566            }
2567        }"#;
2568        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2569        assert_eq!(config.rules.unused_files, Severity::Error);
2570        assert_eq!(config.rules.unused_exports, Severity::Warn);
2571        assert_eq!(config.rules.unused_types, Severity::Off);
2572    }
2573
2574    #[test]
2575    fn rules_omitted_default_to_error() {
2576        let json_str = r#"{
2577            "rules": {
2578                "unused-files": "warn"
2579            }
2580        }"#;
2581        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2582        assert_eq!(config.rules.unused_files, Severity::Warn);
2583        assert_eq!(config.rules.unused_exports, Severity::Error);
2584        assert_eq!(config.rules.unused_types, Severity::Error);
2585        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2586        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2587        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2588        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2589        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2590        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2591    }
2592
2593    #[test]
2594    fn find_and_load_returns_none_when_no_config() {
2595        let dir = test_dir("find-none");
2596        std::fs::create_dir(dir.path().join(".git")).unwrap();
2597
2598        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2599        assert!(result.is_none());
2600    }
2601
2602    #[test]
2603    fn find_and_load_finds_fallowrc_json() {
2604        let dir = test_dir("find-json");
2605        std::fs::create_dir(dir.path().join(".git")).unwrap();
2606        std::fs::write(
2607            dir.path().join(".fallowrc.json"),
2608            r#"{"entry": ["src/main.ts"]}"#,
2609        )
2610        .unwrap();
2611
2612        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2613        assert_eq!(config.entry, vec!["src/main.ts"]);
2614        assert!(path.ends_with(".fallowrc.json"));
2615    }
2616
2617    #[test]
2618    fn find_and_load_finds_fallowrc_jsonc() {
2619        let dir = test_dir("find-jsonc");
2620        std::fs::create_dir(dir.path().join(".git")).unwrap();
2621        std::fs::write(
2622            dir.path().join(".fallowrc.jsonc"),
2623            r#"{
2624                "entry": ["src/main.ts"]
2625            }"#,
2626        )
2627        .unwrap();
2628
2629        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2630        assert_eq!(config.entry, vec!["src/main.ts"]);
2631        assert!(path.ends_with(".fallowrc.jsonc"));
2632    }
2633
2634    #[test]
2635    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
2636        let dir = test_dir("find-json-vs-jsonc");
2637        std::fs::create_dir(dir.path().join(".git")).unwrap();
2638        std::fs::write(
2639            dir.path().join(".fallowrc.json"),
2640            r#"{"entry": ["from-json.ts"]}"#,
2641        )
2642        .unwrap();
2643        std::fs::write(
2644            dir.path().join(".fallowrc.jsonc"),
2645            r#"{"entry": ["from-jsonc.ts"]}"#,
2646        )
2647        .unwrap();
2648
2649        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2650        assert_eq!(config.entry, vec!["from-json.ts"]);
2651        assert!(path.ends_with(".fallowrc.json"));
2652    }
2653
2654    #[test]
2655    fn find_and_load_prefers_fallowrc_json_over_toml() {
2656        let dir = test_dir("find-priority");
2657        std::fs::create_dir(dir.path().join(".git")).unwrap();
2658        std::fs::write(
2659            dir.path().join(".fallowrc.json"),
2660            r#"{"entry": ["from-json.ts"]}"#,
2661        )
2662        .unwrap();
2663        std::fs::write(
2664            dir.path().join("fallow.toml"),
2665            "entry = [\"from-toml.ts\"]\n",
2666        )
2667        .unwrap();
2668
2669        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2670        assert_eq!(config.entry, vec!["from-json.ts"]);
2671        assert!(path.ends_with(".fallowrc.json"));
2672    }
2673
2674    #[test]
2675    fn shadowed_config_names_empty_when_single_config() {
2676        let dir = test_dir("shadow-single");
2677        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2678        assert!(shadowed_config_names(dir.path(), 0).is_empty());
2679    }
2680
2681    #[test]
2682    fn shadowed_config_names_reports_lower_precedence_toml() {
2683        let dir = test_dir("shadow-json-toml");
2684        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2685        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2686        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
2687    }
2688
2689    #[test]
2690    fn shadowed_config_names_reports_jsonc_sibling() {
2691        let dir = test_dir("shadow-json-jsonc");
2692        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
2693        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
2694        assert_eq!(
2695            shadowed_config_names(dir.path(), 0),
2696            vec![".fallowrc.jsonc"]
2697        );
2698    }
2699
2700    #[test]
2701    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
2702        let dir = test_dir("shadow-all-four");
2703        for name in CONFIG_NAMES {
2704            std::fs::write(dir.path().join(name), "").unwrap();
2705        }
2706        assert_eq!(
2707            shadowed_config_names(dir.path(), 0),
2708            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
2709        );
2710    }
2711
2712    #[test]
2713    fn shadowed_config_names_scoped_to_indices_after_winner() {
2714        let dir = test_dir("shadow-toml-dottoml");
2715        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
2716        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
2717        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
2718    }
2719
2720    #[test]
2721    fn find_and_load_warns_when_configs_coexist() {
2722        let dir = test_dir("coexist-warn");
2723        std::fs::create_dir(dir.path().join(".git")).unwrap();
2724        std::fs::write(
2725            dir.path().join(".fallowrc.json"),
2726            r#"{"entry": ["from-json.ts"]}"#,
2727        )
2728        .unwrap();
2729        std::fs::write(
2730            dir.path().join("fallow.toml"),
2731            "entry = [\"from-toml.ts\"]\n",
2732        )
2733        .unwrap();
2734
2735        let (result, captured) =
2736            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2737
2738        let (config, path) = result.unwrap().unwrap();
2739        assert_eq!(config.entry, vec!["from-json.ts"]);
2740        assert!(path.ends_with(".fallowrc.json"));
2741
2742        assert_eq!(captured.len(), 1);
2743        let (chosen, shadowed) = &captured[0];
2744        assert_eq!(chosen, ".fallowrc.json");
2745        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
2746    }
2747
2748    #[test]
2749    fn find_and_load_does_not_warn_for_single_config() {
2750        let dir = test_dir("coexist-none");
2751        std::fs::create_dir(dir.path().join(".git")).unwrap();
2752        std::fs::write(
2753            dir.path().join(".fallowrc.json"),
2754            r#"{"entry": ["only.ts"]}"#,
2755        )
2756        .unwrap();
2757
2758        let (result, captured) =
2759            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
2760        assert!(result.unwrap().is_some());
2761        assert!(captured.is_empty());
2762    }
2763
2764    #[test]
2765    fn find_and_load_warns_per_directory_independently() {
2766        let make = |name: &str| {
2767            let dir = test_dir(name);
2768            std::fs::create_dir(dir.path().join(".git")).unwrap();
2769            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
2770            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
2771            dir
2772        };
2773        let first = make("coexist-dir-a");
2774        let second = make("coexist-dir-b");
2775
2776        let ((), captured) = capture_coexisting_config_warnings(|| {
2777            FallowConfig::find_and_load(first.path()).unwrap();
2778            FallowConfig::find_and_load(second.path()).unwrap();
2779        });
2780
2781        assert_eq!(captured.len(), 2);
2782        assert!(captured.iter().all(|(chosen, shadowed)| {
2783            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
2784        }));
2785    }
2786
2787    #[test]
2788    fn explicit_load_does_not_warn_about_coexisting_configs() {
2789        let dir = test_dir("coexist-explicit");
2790        std::fs::write(
2791            dir.path().join(".fallowrc.json"),
2792            r#"{"entry": ["chosen.ts"]}"#,
2793        )
2794        .unwrap();
2795        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
2796
2797        let chosen = dir.path().join("fallow.toml");
2798        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
2799        assert!(result.is_ok());
2800        assert!(captured.is_empty());
2801    }
2802
2803    #[test]
2804    fn find_and_load_finds_fallow_toml() {
2805        let dir = test_dir("find-toml");
2806        std::fs::create_dir(dir.path().join(".git")).unwrap();
2807        std::fs::write(
2808            dir.path().join("fallow.toml"),
2809            "entry = [\"src/index.ts\"]\n",
2810        )
2811        .unwrap();
2812
2813        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2814        assert_eq!(config.entry, vec!["src/index.ts"]);
2815    }
2816
2817    #[test]
2818    fn find_and_load_stops_at_git_dir() {
2819        let dir = test_dir("find-git-stop");
2820        let sub = dir.path().join("sub");
2821        std::fs::create_dir(&sub).unwrap();
2822        std::fs::create_dir(dir.path().join(".git")).unwrap();
2823        let result = FallowConfig::find_and_load(&sub).unwrap();
2824        assert!(result.is_none());
2825    }
2826
2827    #[test]
2828    fn find_and_load_walks_past_package_json_in_monorepo() {
2829        let dir = test_dir("find-monorepo");
2830        std::fs::create_dir(dir.path().join(".git")).unwrap();
2831        std::fs::write(
2832            dir.path().join(".fallowrc.json"),
2833            r#"{"entry": ["src/index.ts"]}"#,
2834        )
2835        .unwrap();
2836
2837        let sub = dir.path().join("packages").join("app");
2838        std::fs::create_dir_all(&sub).unwrap();
2839        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2840
2841        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2842        assert_eq!(config.entry, vec!["src/index.ts"]);
2843        assert_eq!(path, dir.path().join(".fallowrc.json"));
2844    }
2845
2846    #[test]
2847    fn find_and_load_sub_package_config_wins_over_root() {
2848        let dir = test_dir("find-monorepo-override");
2849        std::fs::create_dir(dir.path().join(".git")).unwrap();
2850        std::fs::write(
2851            dir.path().join(".fallowrc.json"),
2852            r#"{"entry": ["src/root.ts"]}"#,
2853        )
2854        .unwrap();
2855
2856        let sub = dir.path().join("packages").join("app");
2857        std::fs::create_dir_all(&sub).unwrap();
2858        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2859        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2860
2861        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2862        assert_eq!(config.entry, vec!["src/sub.ts"]);
2863        assert_eq!(path, sub.join(".fallowrc.json"));
2864    }
2865
2866    #[test]
2867    fn find_and_load_stops_at_git_file_submodule() {
2868        let dir = test_dir("find-git-file");
2869        std::fs::create_dir(dir.path().join(".git")).unwrap();
2870        std::fs::write(
2871            dir.path().join(".fallowrc.json"),
2872            r#"{"entry": ["src/parent.ts"]}"#,
2873        )
2874        .unwrap();
2875
2876        let submodule = dir.path().join("vendor").join("lib");
2877        std::fs::create_dir_all(&submodule).unwrap();
2878        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2879
2880        let result = FallowConfig::find_and_load(&submodule).unwrap();
2881        assert!(
2882            result.is_none(),
2883            "submodule boundary should stop config walk",
2884        );
2885    }
2886
2887    #[test]
2888    fn find_and_load_stops_at_hg_dir() {
2889        let dir = test_dir("find-hg-stop");
2890        let sub = dir.path().join("sub");
2891        std::fs::create_dir(&sub).unwrap();
2892        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2893
2894        let result = FallowConfig::find_and_load(&sub).unwrap();
2895        assert!(result.is_none());
2896    }
2897
2898    #[test]
2899    fn find_and_load_returns_error_for_invalid_config() {
2900        let dir = test_dir("find-invalid");
2901        std::fs::create_dir(dir.path().join(".git")).unwrap();
2902        std::fs::write(
2903            dir.path().join(".fallowrc.json"),
2904            r"{ this is not valid json }",
2905        )
2906        .unwrap();
2907
2908        let result = FallowConfig::find_and_load(dir.path());
2909        assert!(result.is_err());
2910    }
2911
2912    #[test]
2913    fn load_toml_config_file() {
2914        let dir = test_dir("toml-config");
2915        let config_path = dir.path().join("fallow.toml");
2916        std::fs::write(
2917            &config_path,
2918            r#"
2919entry = ["src/index.ts"]
2920ignorePatterns = ["dist/**"]
2921
2922[rules]
2923unused-files = "warn"
2924
2925[duplicates]
2926minTokens = 100
2927"#,
2928        )
2929        .unwrap();
2930
2931        let config = FallowConfig::load(&config_path).unwrap();
2932        assert_eq!(config.entry, vec!["src/index.ts"]);
2933        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2934        assert_eq!(config.rules.unused_files, Severity::Warn);
2935        assert_eq!(config.duplicates.min_tokens, 100);
2936    }
2937
2938    #[test]
2939    fn load_toml_config_file_with_health_threshold_override() {
2940        let dir = test_dir("toml-health-threshold-override");
2941        let config_path = dir.path().join("fallow.toml");
2942        std::fs::write(
2943            &config_path,
2944            r#"
2945[health]
2946thresholdOverrides = [
2947  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
2948]
2949"#,
2950        )
2951        .unwrap();
2952
2953        let config = FallowConfig::load(&config_path).unwrap();
2954        let override_config = &config.health.threshold_overrides[0];
2955        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2956        assert_eq!(override_config.functions, vec!["legacyFlow"]);
2957        assert_eq!(override_config.max_cyclomatic, Some(30));
2958        assert_eq!(override_config.max_cognitive, Some(25));
2959        assert_eq!(override_config.max_crap, Some(80.5));
2960        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
2961    }
2962
2963    #[test]
2964    fn extends_absolute_path_rejected() {
2965        let dir = test_dir("extends-absolute");
2966
2967        #[cfg(unix)]
2968        let abs_path = "/absolute/path/config.json";
2969        #[cfg(windows)]
2970        let abs_path = "C:\\absolute\\path\\config.json";
2971
2972        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2973        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2974
2975        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2976        assert!(result.is_err());
2977        let err_msg = format!("{}", result.unwrap_err());
2978        assert!(
2979            err_msg.contains("must be relative"),
2980            "Expected 'must be relative' error, got: {err_msg}"
2981        );
2982    }
2983
2984    #[test]
2985    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
2986        let dir = test_dir("extends-windows-absolute");
2987
2988        std::fs::write(
2989            dir.path().join(".fallowrc.json"),
2990            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
2991        )
2992        .unwrap();
2993
2994        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2995        assert!(result.is_err());
2996        let err_msg = format!("{}", result.unwrap_err());
2997        assert!(
2998            err_msg.contains("must be relative"),
2999            "Expected 'must be relative' error, got: {err_msg}"
3000        );
3001    }
3002
3003    #[cfg(windows)]
3004    #[test]
3005    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
3006        let dir = test_dir("extends-posix-rooted-absolute");
3007
3008        std::fs::write(
3009            dir.path().join(".fallowrc.json"),
3010            r#"{"extends": ["/absolute/path/config.json"]}"#,
3011        )
3012        .unwrap();
3013
3014        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3015        assert!(result.is_err());
3016        let err_msg = format!("{}", result.unwrap_err());
3017        assert!(
3018            err_msg.contains("must be relative"),
3019            "Expected 'must be relative' error, got: {err_msg}"
3020        );
3021    }
3022
3023    #[test]
3024    fn resolve_production_mode_disables_dev_deps() {
3025        let config = FallowConfig {
3026            production: true.into(),
3027            ..Default::default()
3028        };
3029        let resolved = config.resolve(
3030            PathBuf::from("/tmp/test"),
3031            OutputFormat::Human,
3032            4,
3033            false,
3034            true,
3035            None,
3036        );
3037        assert!(resolved.production);
3038        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3039        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3040        assert_eq!(resolved.rules.unused_files, Severity::Error);
3041        assert_eq!(resolved.rules.unused_exports, Severity::Error);
3042    }
3043
3044    #[test]
3045    fn include_entry_exports_deserializes_from_camelcase_json() {
3046        let json = r#"{ "includeEntryExports": true }"#;
3047        let config: FallowConfig = serde_json::from_str(json).unwrap();
3048        assert!(config.include_entry_exports);
3049    }
3050
3051    #[test]
3052    fn include_entry_exports_deserializes_from_camelcase_toml() {
3053        let toml_str = "includeEntryExports = true\n";
3054        let config: FallowConfig = toml::from_str(toml_str).unwrap();
3055        assert!(config.include_entry_exports);
3056    }
3057
3058    #[test]
3059    fn include_entry_exports_default_is_false() {
3060        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3061        assert!(!config.include_entry_exports);
3062    }
3063
3064    #[test]
3065    fn include_entry_exports_propagates_through_resolve() {
3066        let config = FallowConfig {
3067            include_entry_exports: true,
3068            auto_imports: false,
3069            cache: CacheConfig::default(),
3070            ..Default::default()
3071        };
3072        let resolved = config.resolve(
3073            PathBuf::from("/tmp/test"),
3074            OutputFormat::Human,
3075            1,
3076            true,
3077            true,
3078            None,
3079        );
3080        assert!(resolved.include_entry_exports);
3081    }
3082
3083    #[test]
3084    fn config_format_defaults_to_toml_for_unknown() {
3085        assert!(matches!(
3086            ConfigFormat::from_path(Path::new("config.yaml")),
3087            ConfigFormat::Toml
3088        ));
3089        assert!(matches!(
3090            ConfigFormat::from_path(Path::new("config")),
3091            ConfigFormat::Toml
3092        ));
3093    }
3094
3095    #[test]
3096    fn deep_merge_object_over_scalar_replaces() {
3097        let mut base = serde_json::json!("just a string");
3098        let overlay = serde_json::json!({"key": "value"});
3099        deep_merge_json(&mut base, overlay);
3100        assert_eq!(base, serde_json::json!({"key": "value"}));
3101    }
3102
3103    #[test]
3104    fn deep_merge_scalar_over_object_replaces() {
3105        let mut base = serde_json::json!({"key": "value"});
3106        let overlay = serde_json::json!(42);
3107        deep_merge_json(&mut base, overlay);
3108        assert_eq!(base, serde_json::json!(42));
3109    }
3110
3111    #[test]
3112    fn extends_non_string_non_array_ignored() {
3113        let dir = test_dir("extends-numeric");
3114        std::fs::write(
3115            dir.path().join(".fallowrc.json"),
3116            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3117        )
3118        .unwrap();
3119
3120        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3121        assert_eq!(config.entry, vec!["src/index.ts"]);
3122    }
3123
3124    #[test]
3125    fn extends_multiple_bases_later_wins() {
3126        let dir = test_dir("extends-multi-base");
3127
3128        std::fs::write(
3129            dir.path().join("base-a.json"),
3130            r#"{"rules": {"unused-files": "warn"}}"#,
3131        )
3132        .unwrap();
3133        std::fs::write(
3134            dir.path().join("base-b.json"),
3135            r#"{"rules": {"unused-files": "off"}}"#,
3136        )
3137        .unwrap();
3138        std::fs::write(
3139            dir.path().join(".fallowrc.json"),
3140            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3141        )
3142        .unwrap();
3143
3144        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3145        assert_eq!(config.rules.unused_files, Severity::Off);
3146    }
3147
3148    #[test]
3149    fn load_rejects_empty_security_request_receivers() {
3150        let dir = test_dir("empty-security-request-receivers");
3151        std::fs::write(
3152            dir.path().join(".fallowrc.json"),
3153            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
3154        )
3155        .unwrap();
3156
3157        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3158        let err = result.expect_err("empty receiver should be rejected");
3159        assert!(
3160            err.to_string().contains("security.requestReceivers"),
3161            "error should name security.requestReceivers: {err}"
3162        );
3163    }
3164
3165    #[test]
3166    fn resolve_normalizes_security_request_receivers() {
3167        let dir = test_dir("normalize-security-request-receivers");
3168        std::fs::write(
3169            dir.path().join(".fallowrc.json"),
3170            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3171        )
3172        .unwrap();
3173
3174        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3175            .unwrap()
3176            .resolve(
3177                dir.path().to_path_buf(),
3178                OutputFormat::Human,
3179                1,
3180                true,
3181                true,
3182                None,
3183            );
3184        assert_eq!(
3185            config.security.request_receivers,
3186            vec!["httpreq".to_string(), "r".to_string()]
3187        );
3188    }
3189
3190    #[test]
3191    fn fallow_config_deserialize_production() {
3192        let json_str = r#"{"production": true}"#;
3193        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3194        assert!(config.production);
3195    }
3196
3197    #[test]
3198    fn fallow_config_production_defaults_false() {
3199        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3200        assert!(!config.production);
3201    }
3202
3203    #[test]
3204    fn package_json_optional_dependency_names() {
3205        let pkg: PackageJson = serde_json::from_str(
3206            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3207        )
3208        .unwrap();
3209        let opt = pkg.optional_dependency_names();
3210        assert_eq!(opt.len(), 2);
3211        assert!(opt.contains(&"fsevents".to_string()));
3212        assert!(opt.contains(&"chokidar".to_string()));
3213    }
3214
3215    #[test]
3216    fn package_json_optional_deps_empty_when_missing() {
3217        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3218        assert!(pkg.optional_dependency_names().is_empty());
3219    }
3220
3221    #[test]
3222    fn find_config_path_returns_fallowrc_json() {
3223        let dir = test_dir("find-path-json");
3224        std::fs::create_dir(dir.path().join(".git")).unwrap();
3225        std::fs::write(
3226            dir.path().join(".fallowrc.json"),
3227            r#"{"entry": ["src/main.ts"]}"#,
3228        )
3229        .unwrap();
3230
3231        let path = FallowConfig::find_config_path(dir.path());
3232        assert!(path.is_some());
3233        assert!(path.unwrap().ends_with(".fallowrc.json"));
3234    }
3235
3236    #[test]
3237    fn find_config_path_returns_fallow_toml() {
3238        let dir = test_dir("find-path-toml");
3239        std::fs::create_dir(dir.path().join(".git")).unwrap();
3240        std::fs::write(
3241            dir.path().join("fallow.toml"),
3242            "entry = [\"src/main.ts\"]\n",
3243        )
3244        .unwrap();
3245
3246        let path = FallowConfig::find_config_path(dir.path());
3247        assert!(path.is_some());
3248        assert!(path.unwrap().ends_with("fallow.toml"));
3249    }
3250
3251    #[test]
3252    fn find_config_path_returns_dot_fallow_toml() {
3253        let dir = test_dir("find-path-dot-toml");
3254        std::fs::create_dir(dir.path().join(".git")).unwrap();
3255        std::fs::write(
3256            dir.path().join(".fallow.toml"),
3257            "entry = [\"src/main.ts\"]\n",
3258        )
3259        .unwrap();
3260
3261        let path = FallowConfig::find_config_path(dir.path());
3262        assert!(path.is_some());
3263        assert!(path.unwrap().ends_with(".fallow.toml"));
3264    }
3265
3266    #[test]
3267    fn find_config_path_prefers_json_over_toml() {
3268        let dir = test_dir("find-path-priority");
3269        std::fs::create_dir(dir.path().join(".git")).unwrap();
3270        std::fs::write(
3271            dir.path().join(".fallowrc.json"),
3272            r#"{"entry": ["json.ts"]}"#,
3273        )
3274        .unwrap();
3275        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
3276
3277        let path = FallowConfig::find_config_path(dir.path());
3278        assert!(path.unwrap().ends_with(".fallowrc.json"));
3279    }
3280
3281    #[test]
3282    fn find_config_path_none_when_no_config() {
3283        let dir = test_dir("find-path-none");
3284        std::fs::create_dir(dir.path().join(".git")).unwrap();
3285
3286        let path = FallowConfig::find_config_path(dir.path());
3287        assert!(path.is_none());
3288    }
3289
3290    #[test]
3291    fn find_config_path_walks_past_package_json_in_monorepo() {
3292        let dir = test_dir("find-path-monorepo");
3293        std::fs::create_dir(dir.path().join(".git")).unwrap();
3294        std::fs::write(
3295            dir.path().join(".fallowrc.json"),
3296            r#"{"entry": ["src/index.ts"]}"#,
3297        )
3298        .unwrap();
3299
3300        let sub = dir.path().join("packages").join("app");
3301        std::fs::create_dir_all(&sub).unwrap();
3302        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3303
3304        let path = FallowConfig::find_config_path(&sub).unwrap();
3305        assert_eq!(path, dir.path().join(".fallowrc.json"));
3306    }
3307
3308    #[test]
3309    fn extends_toml_base() {
3310        let dir = test_dir("extends-toml");
3311
3312        std::fs::write(
3313            dir.path().join("base.json"),
3314            r#"{"rules": {"unused-files": "warn"}}"#,
3315        )
3316        .unwrap();
3317        std::fs::write(
3318            dir.path().join("fallow.toml"),
3319            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
3320        )
3321        .unwrap();
3322
3323        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3324        assert_eq!(config.rules.unused_files, Severity::Warn);
3325        assert_eq!(config.entry, vec!["src/index.ts"]);
3326    }
3327
3328    #[test]
3329    fn deep_merge_boolean_overlay() {
3330        let mut base = serde_json::json!(true);
3331        deep_merge_json(&mut base, serde_json::json!(false));
3332        assert_eq!(base, serde_json::json!(false));
3333    }
3334
3335    #[test]
3336    fn deep_merge_number_overlay() {
3337        let mut base = serde_json::json!(42);
3338        deep_merge_json(&mut base, serde_json::json!(99));
3339        assert_eq!(base, serde_json::json!(99));
3340    }
3341
3342    #[test]
3343    fn deep_merge_disjoint_objects() {
3344        let mut base = serde_json::json!({"a": 1});
3345        let overlay = serde_json::json!({"b": 2});
3346        deep_merge_json(&mut base, overlay);
3347        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3348    }
3349
3350    #[test]
3351    fn max_extends_depth_is_reasonable() {
3352        assert_eq!(MAX_EXTENDS_DEPTH, 10);
3353    }
3354
3355    #[test]
3356    fn config_names_has_four_entries() {
3357        assert_eq!(CONFIG_NAMES.len(), 4);
3358        for name in CONFIG_NAMES {
3359            assert!(
3360                name.starts_with('.') || name.starts_with("fallow"),
3361                "unexpected config name: {name}"
3362            );
3363        }
3364    }
3365
3366    #[test]
3367    fn package_json_peer_dependency_names() {
3368        let pkg: PackageJson = serde_json::from_str(
3369            r#"{
3370            "dependencies": {"react": "^18"},
3371            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
3372        }"#,
3373        )
3374        .unwrap();
3375        let all = pkg.all_dependency_names();
3376        assert!(all.contains(&"react".to_string()));
3377        assert!(all.contains(&"react-dom".to_string()));
3378        assert!(all.contains(&"react-native".to_string()));
3379    }
3380
3381    #[test]
3382    fn package_json_scripts_field() {
3383        let pkg: PackageJson = serde_json::from_str(
3384            r#"{
3385            "scripts": {
3386                "build": "tsc",
3387                "test": "vitest",
3388                "lint": "fallow check"
3389            }
3390        }"#,
3391        )
3392        .unwrap();
3393        let scripts = pkg.scripts.unwrap();
3394        assert_eq!(scripts.len(), 3);
3395        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
3396        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
3397    }
3398
3399    #[test]
3400    fn extends_toml_chain() {
3401        let dir = test_dir("extends-toml-chain");
3402
3403        std::fs::write(
3404            dir.path().join("base.json"),
3405            r#"{"entry": ["src/base.ts"]}"#,
3406        )
3407        .unwrap();
3408        std::fs::write(
3409            dir.path().join("middle.json"),
3410            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
3411        )
3412        .unwrap();
3413        std::fs::write(
3414            dir.path().join("fallow.toml"),
3415            "extends = [\"middle.json\"]\n",
3416        )
3417        .unwrap();
3418
3419        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
3420        assert_eq!(config.entry, vec!["src/base.ts"]);
3421        assert_eq!(config.rules.unused_files, Severity::Off);
3422    }
3423
3424    #[test]
3425    fn find_and_load_walks_up_directories() {
3426        let dir = test_dir("find-walk-up");
3427        let sub = dir.path().join("src").join("deep");
3428        std::fs::create_dir_all(&sub).unwrap();
3429        std::fs::write(
3430            dir.path().join(".fallowrc.json"),
3431            r#"{"entry": ["src/main.ts"]}"#,
3432        )
3433        .unwrap();
3434        std::fs::create_dir(dir.path().join(".git")).unwrap();
3435
3436        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3437        assert_eq!(config.entry, vec!["src/main.ts"]);
3438        assert!(path.ends_with(".fallowrc.json"));
3439    }
3440
3441    #[test]
3442    fn json_schema_contains_entry_field() {
3443        let schema = FallowConfig::json_schema();
3444        let obj = schema.as_object().unwrap();
3445        let props = obj.get("properties").and_then(|v| v.as_object());
3446        assert!(props.is_some(), "schema should have properties");
3447        assert!(
3448            props.unwrap().contains_key("entry"),
3449            "schema should contain entry property"
3450        );
3451    }
3452
3453    #[test]
3454    fn fallow_config_json_duplicates_all_fields() {
3455        let json = r#"{
3456            "duplicates": {
3457                "enabled": true,
3458                "mode": "semantic",
3459                "minTokens": 200,
3460                "minLines": 20,
3461                "threshold": 10.5,
3462                "ignore": ["**/*.test.ts"],
3463                "skipLocal": true,
3464                "crossLanguage": true,
3465                "normalization": {
3466                    "ignoreIdentifiers": true,
3467                    "ignoreStringValues": false
3468                }
3469            }
3470        }"#;
3471        let config: FallowConfig = serde_json::from_str(json).unwrap();
3472        assert!(config.duplicates.enabled);
3473        assert_eq!(
3474            config.duplicates.mode,
3475            crate::config::DetectionMode::Semantic
3476        );
3477        assert_eq!(config.duplicates.min_tokens, 200);
3478        assert_eq!(config.duplicates.min_lines, 20);
3479        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
3480        assert!(config.duplicates.skip_local);
3481        assert!(config.duplicates.cross_language);
3482        assert_eq!(
3483            config.duplicates.normalization.ignore_identifiers,
3484            Some(true)
3485        );
3486        assert_eq!(
3487            config.duplicates.normalization.ignore_string_values,
3488            Some(false)
3489        );
3490    }
3491
3492    #[test]
3493    fn normalize_url_basic() {
3494        assert_eq!(
3495            normalize_url_for_dedup("https://example.com/config.json"),
3496            "https://example.com/config.json"
3497        );
3498    }
3499
3500    #[test]
3501    fn normalize_url_trailing_slash() {
3502        assert_eq!(
3503            normalize_url_for_dedup("https://example.com/config/"),
3504            "https://example.com/config"
3505        );
3506    }
3507
3508    #[test]
3509    fn normalize_url_uppercase_scheme_and_host() {
3510        assert_eq!(
3511            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
3512            "https://example.com/Config.json"
3513        );
3514    }
3515
3516    #[test]
3517    fn normalize_url_root_path() {
3518        assert_eq!(
3519            normalize_url_for_dedup("https://example.com/"),
3520            "https://example.com"
3521        );
3522        assert_eq!(
3523            normalize_url_for_dedup("https://example.com"),
3524            "https://example.com"
3525        );
3526    }
3527
3528    #[test]
3529    fn normalize_url_preserves_path_case() {
3530        assert_eq!(
3531            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
3532            "https://github.com/Org/Repo/Fallow.json"
3533        );
3534    }
3535
3536    #[test]
3537    fn normalize_url_strips_query_string() {
3538        assert_eq!(
3539            normalize_url_for_dedup("https://example.com/config.json?v=1"),
3540            "https://example.com/config.json"
3541        );
3542    }
3543
3544    #[test]
3545    fn normalize_url_strips_fragment() {
3546        assert_eq!(
3547            normalize_url_for_dedup("https://example.com/config.json#section"),
3548            "https://example.com/config.json"
3549        );
3550    }
3551
3552    #[test]
3553    fn normalize_url_strips_query_and_fragment() {
3554        assert_eq!(
3555            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
3556            "https://example.com/config.json"
3557        );
3558    }
3559
3560    #[test]
3561    fn normalize_url_default_https_port() {
3562        assert_eq!(
3563            normalize_url_for_dedup("https://example.com:443/config.json"),
3564            "https://example.com/config.json"
3565        );
3566        assert_eq!(
3567            normalize_url_for_dedup("https://example.com:8443/config.json"),
3568            "https://example.com:8443/config.json"
3569        );
3570    }
3571
3572    #[test]
3573    fn extends_http_rejected() {
3574        let dir = test_dir("http-rejected");
3575        std::fs::write(
3576            dir.path().join(".fallowrc.json"),
3577            r#"{"extends": "http://example.com/config.json"}"#,
3578        )
3579        .unwrap();
3580
3581        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3582        assert!(result.is_err());
3583        let err_msg = format!("{}", result.unwrap_err());
3584        assert!(
3585            err_msg.contains("https://"),
3586            "Expected https hint in error, got: {err_msg}"
3587        );
3588        assert!(
3589            err_msg.contains("http://"),
3590            "Expected http:// mention in error, got: {err_msg}"
3591        );
3592    }
3593
3594    #[test]
3595    fn extends_url_circular_detection() {
3596        let mut visited = FxHashSet::default();
3597        let url = "https://example.com/config.json";
3598        let normalized = normalize_url_for_dedup(url);
3599        visited.insert(normalized.clone());
3600
3601        assert!(
3602            !visited.insert(normalized),
3603            "Same URL should be detected as duplicate"
3604        );
3605    }
3606
3607    #[test]
3608    fn extends_url_circular_case_insensitive() {
3609        let mut visited = FxHashSet::default();
3610        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
3611
3612        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
3613        assert!(
3614            !visited.insert(normalized),
3615            "Case-different URLs should normalize to the same key"
3616        );
3617    }
3618
3619    #[test]
3620    fn extract_extends_array() {
3621        let mut value = serde_json::json!({
3622            "extends": ["a.json", "b.json"],
3623            "entry": ["src/index.ts"]
3624        });
3625        let extends = extract_extends(&mut value);
3626        assert_eq!(extends, vec!["a.json", "b.json"]);
3627        assert!(value.get("extends").is_none());
3628        assert!(value.get("entry").is_some());
3629    }
3630
3631    #[test]
3632    fn extract_extends_string_sugar() {
3633        let mut value = serde_json::json!({
3634            "extends": "base.json",
3635            "entry": ["src/index.ts"]
3636        });
3637        let extends = extract_extends(&mut value);
3638        assert_eq!(extends, vec!["base.json"]);
3639    }
3640
3641    #[test]
3642    fn extract_extends_none() {
3643        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
3644        let extends = extract_extends(&mut value);
3645        assert!(extends.is_empty());
3646    }
3647
3648    #[test]
3649    fn url_timeout_default() {
3650        let timeout = url_timeout();
3651        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
3652    }
3653
3654    #[test]
3655    fn extends_url_mixed_with_file_and_npm() {
3656        let dir = test_dir("url-mixed");
3657        std::fs::write(
3658            dir.path().join("local.json"),
3659            r#"{"rules": {"unused-files": "warn"}}"#,
3660        )
3661        .unwrap();
3662        std::fs::write(
3663            dir.path().join(".fallowrc.json"),
3664            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
3665        )
3666        .unwrap();
3667
3668        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3669        assert!(result.is_err());
3670        let err_msg = format!("{}", result.unwrap_err());
3671        assert!(
3672            err_msg.contains("unreachable.invalid"),
3673            "Expected URL in error message, got: {err_msg}"
3674        );
3675    }
3676
3677    #[test]
3678    fn extends_https_url_unreachable_errors() {
3679        let dir = test_dir("url-unreachable");
3680        std::fs::write(
3681            dir.path().join(".fallowrc.json"),
3682            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
3683        )
3684        .unwrap();
3685
3686        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3687        assert!(result.is_err());
3688        let err_msg = format!("{}", result.unwrap_err());
3689        assert!(
3690            err_msg.contains("unreachable.invalid"),
3691            "Expected URL in error, got: {err_msg}"
3692        );
3693        assert!(
3694            err_msg.contains("local path or npm:"),
3695            "Expected remediation hint, got: {err_msg}"
3696        );
3697    }
3698
3699    #[test]
3700    fn collect_unknown_rule_keys_flags_top_level_typo() {
3701        let merged = serde_json::json!({
3702            "rules": {
3703                "unsued-files": "warn",
3704                "unused-exports": "off"
3705            }
3706        });
3707        let findings = collect_unknown_rule_keys(&merged);
3708        assert_eq!(findings.len(), 1);
3709        assert_eq!(findings[0].context, "rules");
3710        assert_eq!(findings[0].key, "unsued-files");
3711        assert_eq!(findings[0].suggestion, Some("unused-files"));
3712    }
3713
3714    #[test]
3715    fn collect_unknown_rule_keys_flags_overrides_typo() {
3716        let merged = serde_json::json!({
3717            "overrides": [
3718                {
3719                    "files": ["src/**/*.ts"],
3720                    "rules": {
3721                        "unsued-files": "warn"
3722                    }
3723                },
3724                {
3725                    "files": ["tests/**/*.ts"],
3726                    "rules": {
3727                        "circular-dependnecy": "off"
3728                    }
3729                }
3730            ]
3731        });
3732        let findings = collect_unknown_rule_keys(&merged);
3733        assert_eq!(findings.len(), 2);
3734        assert_eq!(findings[0].context, "overrides[0].rules");
3735        assert_eq!(findings[1].context, "overrides[1].rules");
3736        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
3737    }
3738
3739    #[test]
3740    fn collect_unknown_rule_keys_empty_for_valid_config() {
3741        let merged = serde_json::json!({
3742            "rules": {
3743                "unused-files": "warn",
3744                "unused-file": "off",
3745                "circular-dependency": "off",
3746                "boundary-violations": "warn"
3747            },
3748            "overrides": [
3749                {
3750                    "files": ["src/**"],
3751                    "rules": {
3752                        "unused-exports": "warn"
3753                    }
3754                }
3755            ]
3756        });
3757        let findings = collect_unknown_rule_keys(&merged);
3758        assert!(
3759            findings.is_empty(),
3760            "valid rule names and aliases must not be flagged: {findings:?}"
3761        );
3762    }
3763
3764    #[test]
3765    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
3766        let merged = serde_json::json!({
3767            "entry": ["src/main.ts"]
3768        });
3769        let findings = collect_unknown_rule_keys(&merged);
3770        assert!(findings.is_empty());
3771    }
3772
3773    #[test]
3774    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
3775        let dir = test_dir("wiring");
3776        let path = dir.path().join(".fallowrc.json");
3777        let typo = format!(
3778            "wiring-probe-{}-{}",
3779            std::process::id(),
3780            std::time::SystemTime::now()
3781                .duration_since(std::time::UNIX_EPOCH)
3782                .map_or(0, |d| d.as_nanos())
3783        );
3784        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
3785
3786        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
3787
3788        assert!(
3789            config_res.is_ok(),
3790            "load should succeed in phase 1: {:?}",
3791            config_res.err()
3792        );
3793        assert_eq!(
3794            captured.len(),
3795            1,
3796            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
3797        );
3798        assert_eq!(captured[0].key, typo);
3799        assert_eq!(captured[0].context, "rules");
3800    }
3801
3802    #[test]
3803    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
3804        let dir = test_dir("misspelled-rule");
3805        std::fs::write(
3806            dir.path().join(".fallowrc.json"),
3807            r#"{"rules": {"unsued-files": "warn"}}"#,
3808        )
3809        .unwrap();
3810
3811        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3812            .expect("load should succeed in phase 1");
3813
3814        assert_eq!(config.rules.unused_files, Severity::Error);
3815    }
3816
3817    #[test]
3818    fn validate_resolved_boundaries_passes_on_valid_config() {
3819        let dir = test_dir("boundaries-valid");
3820        let config = FallowConfig {
3821            boundaries: crate::BoundaryConfig {
3822                coverage: crate::BoundaryCoverageConfig::default(),
3823                calls: crate::BoundaryCallsConfig::default(),
3824                preset: None,
3825                zones: vec![
3826                    crate::BoundaryZone {
3827                        name: "ui".to_string(),
3828                        patterns: vec!["src/components/**".to_string()],
3829                        auto_discover: vec![],
3830                        root: None,
3831                    },
3832                    crate::BoundaryZone {
3833                        name: "db".to_string(),
3834                        patterns: vec!["src/db/**".to_string()],
3835                        auto_discover: vec![],
3836                        root: None,
3837                    },
3838                ],
3839                rules: vec![crate::BoundaryRule {
3840                    from: "ui".to_string(),
3841                    allow: vec!["db".to_string()],
3842                    allow_type_only: vec![],
3843                }],
3844            },
3845            ..FallowConfig::default()
3846        };
3847        config
3848            .validate_resolved_boundaries(dir.path())
3849            .expect("valid config should pass");
3850    }
3851
3852    #[test]
3853    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
3854        let dir = test_dir("boundaries-unknown-zones");
3855        let config = FallowConfig {
3856            boundaries: crate::BoundaryConfig {
3857                coverage: crate::BoundaryCoverageConfig::default(),
3858                calls: crate::BoundaryCallsConfig::default(),
3859                preset: None,
3860                zones: vec![crate::BoundaryZone {
3861                    name: "ui".to_string(),
3862                    patterns: vec!["src/ui/**".to_string()],
3863                    auto_discover: vec![],
3864                    root: None,
3865                }],
3866                rules: vec![
3867                    crate::BoundaryRule {
3868                        from: "typo-from".to_string(),
3869                        allow: vec!["typo-allow".to_string()],
3870                        allow_type_only: vec!["typo-type-only".to_string()],
3871                    },
3872                    crate::BoundaryRule {
3873                        from: "ui".to_string(),
3874                        allow: vec!["another-typo".to_string()],
3875                        allow_type_only: vec![],
3876                    },
3877                ],
3878            },
3879            ..FallowConfig::default()
3880        };
3881
3882        let errors = config
3883            .validate_resolved_boundaries(dir.path())
3884            .expect_err("invalid zone refs should fail");
3885
3886        assert_eq!(errors.len(), 4, "got: {errors:?}");
3887
3888        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3889        assert!(
3890            rendered
3891                .iter()
3892                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
3893        );
3894        assert!(
3895            rendered
3896                .iter()
3897                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
3898        );
3899        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
3900            && m.contains("rules[0]")
3901            && m.contains("allowTypeOnly")));
3902        assert!(
3903            rendered.iter().any(|m| m.contains("another-typo")
3904                && m.contains("rules[1]")
3905                && m.contains("allow"))
3906        );
3907    }
3908
3909    #[test]
3910    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
3911        let dir = test_dir("boundaries-redundant-prefix");
3912        let config = FallowConfig {
3913            boundaries: crate::BoundaryConfig {
3914                coverage: crate::BoundaryCoverageConfig::default(),
3915                calls: crate::BoundaryCallsConfig::default(),
3916                preset: None,
3917                zones: vec![crate::BoundaryZone {
3918                    name: "ui".to_string(),
3919                    patterns: vec!["packages/app/src/**".to_string()],
3920                    auto_discover: vec![],
3921                    root: Some("packages/app/".to_string()),
3922                }],
3923                rules: vec![],
3924            },
3925            ..FallowConfig::default()
3926        };
3927
3928        let errors = config
3929            .validate_resolved_boundaries(dir.path())
3930            .expect_err("redundant root prefix should fail");
3931        assert_eq!(errors.len(), 1, "got: {errors:?}");
3932        let rendered = errors[0].to_string();
3933        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
3934        assert!(rendered.contains("zone 'ui'"));
3935    }
3936
3937    #[test]
3938    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
3939        let dir = test_dir("boundaries-mixed-errors");
3940        let config = FallowConfig {
3941            boundaries: crate::BoundaryConfig {
3942                coverage: crate::BoundaryCoverageConfig::default(),
3943                calls: crate::BoundaryCallsConfig::default(),
3944                preset: None,
3945                zones: vec![crate::BoundaryZone {
3946                    name: "ui".to_string(),
3947                    patterns: vec!["packages/app/src/**".to_string()],
3948                    auto_discover: vec![],
3949                    root: Some("packages/app/".to_string()),
3950                }],
3951                rules: vec![crate::BoundaryRule {
3952                    from: "ui".to_string(),
3953                    allow: vec!["typo-zone".to_string()],
3954                    allow_type_only: vec![],
3955                }],
3956            },
3957            ..FallowConfig::default()
3958        };
3959        let errors = config
3960            .validate_resolved_boundaries(dir.path())
3961            .expect_err("mixed errors should fail");
3962        assert_eq!(errors.len(), 2, "got: {errors:?}");
3963        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
3964        assert!(
3965            rendered
3966                .iter()
3967                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
3968        );
3969        assert!(
3970            rendered
3971                .iter()
3972                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
3973        );
3974    }
3975
3976    #[test]
3977    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
3978        let dir = test_dir("boundaries-bulletproof");
3979        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
3980        let config = FallowConfig {
3981            boundaries: crate::BoundaryConfig {
3982                coverage: crate::BoundaryCoverageConfig::default(),
3983                calls: crate::BoundaryCallsConfig::default(),
3984                preset: Some(crate::BoundaryPreset::Bulletproof),
3985                zones: vec![],
3986                rules: vec![],
3987            },
3988            ..FallowConfig::default()
3989        };
3990        config
3991            .validate_resolved_boundaries(dir.path())
3992            .expect("Bulletproof with discoverable features should pass");
3993    }
3994
3995    // ------------------------------------------------------------------
3996    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
3997    // ------------------------------------------------------------------
3998
3999    #[test]
4000    #[cfg_attr(miri, ignore)]
4001    fn parse_config_to_value_strips_utf8_bom() {
4002        let dir = test_dir("parse-bom");
4003        let path = dir.path().join("fallow.toml");
4004        // Write TOML with a UTF-8 BOM prefix
4005        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4006        std::fs::write(&path, content_with_bom).unwrap();
4007
4008        let value = parse_config_to_value(&path).unwrap();
4009        assert!(
4010            value.get("entry").is_some(),
4011            "BOM should be stripped before TOML parsing"
4012        );
4013    }
4014
4015    #[test]
4016    #[cfg_attr(miri, ignore)]
4017    fn parse_config_to_value_toml_parse_error() {
4018        let dir = test_dir("parse-toml-error");
4019        let path = dir.path().join("fallow.toml");
4020        std::fs::write(&path, "entry = [unquoted\n").unwrap();
4021
4022        let result = parse_config_to_value(&path);
4023        assert!(result.is_err());
4024        let err = result.unwrap_err().to_string();
4025        assert!(
4026            err.contains("Failed to parse config file"),
4027            "error should mention parse failure: {err}"
4028        );
4029    }
4030
4031    #[test]
4032    #[cfg_attr(miri, ignore)]
4033    fn parse_config_to_value_json_parse_error() {
4034        let dir = test_dir("parse-json-error");
4035        let path = dir.path().join(".fallowrc.json");
4036        std::fs::write(&path, "{ this is not json }").unwrap();
4037
4038        let result = parse_config_to_value(&path);
4039        assert!(result.is_err());
4040        let err = result.unwrap_err().to_string();
4041        assert!(
4042            err.contains("Failed to parse config file"),
4043            "error should mention parse failure: {err}"
4044        );
4045    }
4046
4047    #[test]
4048    #[cfg_attr(miri, ignore)]
4049    fn parse_config_to_value_missing_file_error() {
4050        let dir = test_dir("parse-missing");
4051        let path = dir.path().join("nonexistent.toml");
4052
4053        let result = parse_config_to_value(&path);
4054        assert!(result.is_err());
4055        let err = result.unwrap_err().to_string();
4056        assert!(
4057            err.contains("Failed to read config file"),
4058            "error should mention read failure: {err}"
4059        );
4060    }
4061
4062    // ------------------------------------------------------------------
4063    // is_repo_root: svn boundary
4064    // ------------------------------------------------------------------
4065
4066    #[test]
4067    #[cfg_attr(miri, ignore)]
4068    fn find_and_load_stops_at_svn_dir() {
4069        let dir = test_dir("find-svn-stop");
4070        let sub = dir.path().join("sub");
4071        std::fs::create_dir(&sub).unwrap();
4072        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4073
4074        let result = FallowConfig::find_and_load(&sub).unwrap();
4075        assert!(result.is_none(), "svn boundary should stop config walk");
4076    }
4077
4078    // ------------------------------------------------------------------
4079    // validate_npm_package_name: dot-segment in the package name
4080    // (path traversal but using a single dot)
4081    // ------------------------------------------------------------------
4082
4083    #[test]
4084    #[cfg_attr(miri, ignore)]
4085    fn extends_npm_single_dot_package_name_rejected() {
4086        let dir = test_dir("npm-dot-name");
4087        std::fs::write(
4088            dir.path().join(".fallowrc.json"),
4089            r#"{"extends": "npm:./relative"}"#,
4090        )
4091        .unwrap();
4092
4093        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4094        assert!(result.is_err());
4095        let err = result.unwrap_err().to_string();
4096        assert!(
4097            err.contains("path traversal"),
4098            "single-dot component should be rejected as path traversal: {err}"
4099        );
4100    }
4101
4102    // ------------------------------------------------------------------
4103    // find_config_in_npm_package: main field points to nonexistent file,
4104    // falls through to config-name scan
4105    // ------------------------------------------------------------------
4106
4107    #[test]
4108    #[cfg_attr(miri, ignore)]
4109    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4110        let dir = test_dir("npm-main-missing");
4111        let pkg_dir = dir.path().join("node_modules/my-config");
4112        std::fs::create_dir_all(&pkg_dir).unwrap();
4113        // package.json with main pointing at a file that does not exist
4114        std::fs::write(
4115            pkg_dir.join("package.json"),
4116            r#"{"name": "my-config", "main": "./missing.json"}"#,
4117        )
4118        .unwrap();
4119        // But a recognized config name is present for the fallback scan
4120        std::fs::write(
4121            pkg_dir.join(".fallowrc.json"),
4122            r#"{"rules": {"unused-files": "warn"}}"#,
4123        )
4124        .unwrap();
4125
4126        std::fs::write(
4127            dir.path().join(".fallowrc.json"),
4128            r#"{"extends": "npm:my-config"}"#,
4129        )
4130        .unwrap();
4131
4132        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4133        assert_eq!(config.rules.unused_files, Severity::Warn);
4134    }
4135
4136    // ------------------------------------------------------------------
4137    // find_config_in_npm_package: exports present but exports-pointed file
4138    // does not exist, falls through to main then config name
4139    // ------------------------------------------------------------------
4140
4141    #[test]
4142    #[cfg_attr(miri, ignore)]
4143    fn extends_npm_exports_nonexistent_falls_through_to_main() {
4144        let dir = test_dir("npm-exports-missing-file");
4145        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4146        std::fs::create_dir_all(&pkg_dir).unwrap();
4147        // exports points to a file that does not exist; main is valid
4148        std::fs::write(
4149            pkg_dir.join("package.json"),
4150            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4151        )
4152        .unwrap();
4153        std::fs::write(
4154            pkg_dir.join("real.json"),
4155            r#"{"rules": {"unused-types": "off"}}"#,
4156        )
4157        .unwrap();
4158
4159        std::fs::write(
4160            dir.path().join(".fallowrc.json"),
4161            r#"{"extends": "npm:cfg-pkg"}"#,
4162        )
4163        .unwrap();
4164
4165        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4166        assert_eq!(config.rules.unused_types, Severity::Off);
4167    }
4168
4169    // ------------------------------------------------------------------
4170    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
4171    // ------------------------------------------------------------------
4172
4173    #[test]
4174    fn normalize_url_no_scheme_returns_raw() {
4175        // A string without "://" must come back unchanged
4176        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
4177        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
4178    }
4179
4180    // ------------------------------------------------------------------
4181    // normalize_url_for_dedup: query before fragment (fragment then query)
4182    // ------------------------------------------------------------------
4183
4184    #[test]
4185    fn normalize_url_fragment_only_stripped() {
4186        // Fragment-only URL (no query)
4187        assert_eq!(
4188            normalize_url_for_dedup("https://example.com/file.json#anchor"),
4189            "https://example.com/file.json"
4190        );
4191    }
4192
4193    // ------------------------------------------------------------------
4194    // url_timeout: env var override
4195    // ------------------------------------------------------------------
4196
4197    // These exercise the pure `url_timeout_from` parser rather than mutating the
4198    // process-global env var, so they stay deterministic under parallel test
4199    // execution (an env-mutating version raced and failed on Windows CI).
4200    #[test]
4201    fn url_timeout_uses_env_var_when_set() {
4202        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
4203    }
4204
4205    #[test]
4206    fn url_timeout_zero_falls_back_to_default() {
4207        assert_eq!(
4208            url_timeout_from(Some("0")),
4209            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4210            "zero should fall back to the hardcoded default"
4211        );
4212    }
4213
4214    #[test]
4215    fn url_timeout_non_numeric_falls_back_to_default() {
4216        assert_eq!(
4217            url_timeout_from(Some("not-a-number")),
4218            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
4219            "non-numeric value should fall back to the hardcoded default"
4220        );
4221    }
4222
4223    #[test]
4224    fn url_timeout_absent_uses_default() {
4225        assert_eq!(
4226            url_timeout_from(None),
4227            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
4228        );
4229    }
4230
4231    // ------------------------------------------------------------------
4232    // resolve_url_extends: depth limit reached
4233    // ------------------------------------------------------------------
4234
4235    #[test]
4236    fn resolve_url_extends_depth_limit_error() {
4237        let mut visited = FxHashSet::default();
4238        let result = resolve_url_extends(
4239            "https://example.invalid/config.json",
4240            &mut visited,
4241            MAX_EXTENDS_DEPTH, // at the limit
4242        );
4243        assert!(result.is_err());
4244        let err = result.unwrap_err().to_string();
4245        assert!(
4246            err.contains("too deep"),
4247            "error should mention depth limit: {err}"
4248        );
4249    }
4250
4251    // ------------------------------------------------------------------
4252    // resolve_extends_file: depth limit reached
4253    // ------------------------------------------------------------------
4254
4255    #[test]
4256    #[cfg_attr(miri, ignore)]
4257    fn resolve_extends_file_depth_limit_error() {
4258        let dir = test_dir("extends-file-depth");
4259        let path = dir.path().join(".fallowrc.json");
4260        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
4261
4262        let mut visited = FxHashSet::default();
4263        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
4264        assert!(result.is_err());
4265        let err = result.unwrap_err().to_string();
4266        assert!(
4267            err.contains("too deep"),
4268            "error should mention depth limit: {err}"
4269        );
4270    }
4271
4272    // ------------------------------------------------------------------
4273    // resolve_extends_file_entry: http:// in file-sourced extends
4274    // ------------------------------------------------------------------
4275
4276    #[test]
4277    #[cfg_attr(miri, ignore)]
4278    fn extends_http_url_in_file_extends_rejected() {
4279        let dir = test_dir("file-extends-http");
4280        std::fs::write(
4281            dir.path().join(".fallowrc.json"),
4282            r#"{"extends": ["http://example.com/config.json"]}"#,
4283        )
4284        .unwrap();
4285
4286        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4287        assert!(result.is_err());
4288        let err = result.unwrap_err().to_string();
4289        assert!(
4290            err.contains("https://"),
4291            "error should suggest https: {err}"
4292        );
4293    }
4294
4295    // ------------------------------------------------------------------
4296    // sealed_config_dir: when sealed = true canonicalization runs
4297    // ------------------------------------------------------------------
4298
4299    #[test]
4300    #[cfg_attr(miri, ignore)]
4301    fn sealed_config_dir_returns_some_when_sealed() {
4302        let dir = test_dir("sealed-dir");
4303        let result = sealed_config_dir(dir.path(), true);
4304        assert!(result.is_ok());
4305        assert!(
4306            result.unwrap().is_some(),
4307            "sealed=true must return Some(canonicalized path)"
4308        );
4309    }
4310
4311    #[test]
4312    fn sealed_config_dir_returns_none_when_not_sealed() {
4313        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
4314        assert!(result.is_ok());
4315        assert!(result.unwrap().is_none(), "sealed=false must return None");
4316    }
4317
4318    // ------------------------------------------------------------------
4319    // collect_unknown_rule_keys: overrides entry without a rules key
4320    // (the inner `if let Some(rules)` branch is not taken)
4321    // ------------------------------------------------------------------
4322
4323    #[test]
4324    fn collect_unknown_rule_keys_override_without_rules_key() {
4325        let merged = serde_json::json!({
4326            "overrides": [
4327                {
4328                    "files": ["src/**/*.ts"]
4329                    // no "rules" key here
4330                },
4331                {
4332                    "files": ["tests/**"],
4333                    "rules": {
4334                        "unsued-exports": "off"
4335                    }
4336                }
4337            ]
4338        });
4339        let findings = collect_unknown_rule_keys(&merged);
4340        assert_eq!(
4341            findings.len(),
4342            1,
4343            "only the entry with rules should produce a finding"
4344        );
4345        assert_eq!(findings[0].context, "overrides[1].rules");
4346    }
4347
4348    // ------------------------------------------------------------------
4349    // FallowConfig::load: deserialization failure
4350    // ------------------------------------------------------------------
4351
4352    #[test]
4353    #[cfg_attr(miri, ignore)]
4354    fn load_fails_on_deserialization_error() {
4355        let dir = test_dir("deser-error");
4356        let path = dir.path().join(".fallowrc.json");
4357        // Valid JSON but contains a field value with the wrong type for the schema
4358        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
4359
4360        let result = FallowConfig::load(&path);
4361        assert!(result.is_err());
4362        let err = result.unwrap_err().to_string();
4363        assert!(
4364            err.contains("Failed to deserialize"),
4365            "error should mention deserialization: {err}"
4366        );
4367    }
4368
4369    // ------------------------------------------------------------------
4370    // FallowConfig::load: threshold override validation failure
4371    // (covers lines 921-927)
4372    // ------------------------------------------------------------------
4373
4374    #[test]
4375    #[cfg_attr(miri, ignore)]
4376    fn load_rejects_threshold_override_with_empty_files() {
4377        let dir = test_dir("threshold-empty-files");
4378        let path = dir.path().join(".fallowrc.json");
4379        std::fs::write(
4380            &path,
4381            r#"{
4382                "health": {
4383                    "thresholdOverrides": [
4384                        {"files": [], "maxCyclomatic": 30}
4385                    ]
4386                }
4387            }"#,
4388        )
4389        .unwrap();
4390
4391        let result = FallowConfig::load(&path);
4392        assert!(result.is_err());
4393        let err = result.unwrap_err().to_string();
4394        assert!(
4395            err.contains("thresholdOverrides"),
4396            "error should mention thresholdOverrides: {err}"
4397        );
4398        assert!(
4399            err.contains("files"),
4400            "error should name the files field: {err}"
4401        );
4402    }
4403
4404    #[test]
4405    #[cfg_attr(miri, ignore)]
4406    fn load_rejects_threshold_override_with_no_threshold_set() {
4407        let dir = test_dir("threshold-no-threshold");
4408        let path = dir.path().join(".fallowrc.json");
4409        std::fs::write(
4410            &path,
4411            r#"{
4412                "health": {
4413                    "thresholdOverrides": [
4414                        {"files": ["src/legacy.ts"]}
4415                    ]
4416                }
4417            }"#,
4418        )
4419        .unwrap();
4420
4421        let result = FallowConfig::load(&path);
4422        assert!(result.is_err());
4423        let err = result.unwrap_err().to_string();
4424        assert!(
4425            err.contains("maxCyclomatic")
4426                || err.contains("maxCognitive")
4427                || err.contains("maxCrap"),
4428            "error should name at least one threshold field: {err}"
4429        );
4430    }
4431
4432    // ------------------------------------------------------------------
4433    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
4434    // validation (covers lines 1026-1032)
4435    // ------------------------------------------------------------------
4436
4437    #[test]
4438    #[cfg_attr(miri, ignore)]
4439    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
4440        let dir = test_dir("invalid-catalog-consumer-glob");
4441        let path = dir.path().join(".fallowrc.json");
4442        std::fs::write(
4443            &path,
4444            r#"{
4445                "ignoreCatalogReferences": [
4446                    {"package": "react", "consumer": "[invalid-glob"}
4447                ]
4448            }"#,
4449        )
4450        .unwrap();
4451
4452        let result = FallowConfig::load(&path);
4453        assert!(result.is_err());
4454        let err = result.unwrap_err().to_string();
4455        assert!(
4456            err.contains("ignoreCatalogReferences"),
4457            "error should mention the field: {err}"
4458        );
4459    }
4460
4461    #[test]
4462    #[cfg_attr(miri, ignore)]
4463    fn load_accepts_ignore_catalog_references_without_consumer() {
4464        let dir = test_dir("catalog-ref-no-consumer");
4465        let path = dir.path().join(".fallowrc.json");
4466        std::fs::write(
4467            &path,
4468            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
4469        )
4470        .unwrap();
4471
4472        let config = FallowConfig::load(&path).unwrap();
4473        assert_eq!(config.ignore_catalog_references.len(), 1);
4474        assert!(config.ignore_catalog_references[0].consumer.is_none());
4475    }
4476
4477    #[test]
4478    #[cfg_attr(miri, ignore)]
4479    fn load_accepts_unused_component_props_ignore_pattern() {
4480        let dir = test_dir("unused-component-props-ignore-pattern");
4481        let path = dir.path().join(".fallowrc.json");
4482        std::fs::write(
4483            &path,
4484            r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
4485        )
4486        .unwrap();
4487
4488        let config = FallowConfig::load(&path).unwrap();
4489        assert_eq!(
4490            config.unused_component_props.ignore_pattern.as_deref(),
4491            Some("^_")
4492        );
4493    }
4494
4495    #[test]
4496    #[cfg_attr(miri, ignore)]
4497    fn load_rejects_invalid_unused_component_props_ignore_pattern() {
4498        let dir = test_dir("unused-component-props-bad-regex");
4499        let path = dir.path().join(".fallowrc.json");
4500        // `[` opens an unterminated character class: invalid regex.
4501        std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
4502
4503        let result = FallowConfig::load(&path);
4504        assert!(result.is_err());
4505        let err = result.unwrap_err().to_string();
4506        assert!(
4507            err.contains("unusedComponentProps.ignorePattern"),
4508            "error should mention the field: {err}"
4509        );
4510    }
4511
4512    #[test]
4513    #[cfg_attr(miri, ignore)]
4514    fn load_rejects_unknown_unused_component_props_field() {
4515        let dir = test_dir("unused-component-props-unknown-field");
4516        let path = dir.path().join(".fallowrc.json");
4517        std::fs::write(
4518            &path,
4519            r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
4520        )
4521        .unwrap();
4522
4523        // `deny_unknown_fields` rejects the plural typo.
4524        assert!(FallowConfig::load(&path).is_err());
4525    }
4526
4527    // ------------------------------------------------------------------
4528    // validate_resolved_boundaries: tsconfig rootDir filtering
4529    // (covers lines 1158-1160 - rootDir value is ".", starts with "..", or
4530    // is absolute; all should fall back to "src")
4531    // ------------------------------------------------------------------
4532
4533    #[test]
4534    #[cfg_attr(miri, ignore)]
4535    fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
4536        // No tsconfig.json present; parse_tsconfig_root_dir returns None,
4537        // unwrap_or_else supplies "src". This exercises the filter + fallback branch.
4538        let dir = test_dir("boundaries-preset-no-tsconfig");
4539        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4540        let config = FallowConfig {
4541            boundaries: crate::BoundaryConfig {
4542                coverage: crate::BoundaryCoverageConfig::default(),
4543                calls: crate::BoundaryCallsConfig::default(),
4544                preset: Some(crate::BoundaryPreset::Bulletproof),
4545                zones: vec![],
4546                rules: vec![],
4547            },
4548            ..FallowConfig::default()
4549        };
4550        // Should not panic; no zone-ref errors expected since preset adds zones
4551        let _ = config.validate_resolved_boundaries(dir.path());
4552    }
4553
4554    // ------------------------------------------------------------------
4555    // validate_user_globs: framework plugin invalid glob triggers error path
4556    // (covers lines 970-974)
4557    // ------------------------------------------------------------------
4558
4559    #[test]
4560    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
4561        use crate::ExternalPluginDef;
4562        use crate::external_plugin::EntryPointRole;
4563        let config = FallowConfig {
4564            framework: vec![ExternalPluginDef {
4565                schema: None,
4566                name: "test-plugin".to_owned(),
4567                detection: None,
4568                enablers: vec![],
4569                entry_points: vec!["[invalid-glob".to_owned()],
4570                entry_point_role: EntryPointRole::Support,
4571                config_patterns: vec![],
4572                always_used: vec![],
4573                tooling_dependencies: vec![],
4574                used_exports: vec![],
4575                used_class_members: vec![],
4576            }],
4577            ..FallowConfig::default()
4578        };
4579
4580        let result = config.validate_user_globs();
4581        assert!(
4582            result.is_err(),
4583            "invalid entry_points glob should fail validation"
4584        );
4585        let errors = result.unwrap_err();
4586        assert!(!errors.is_empty());
4587    }
4588
4589    // ------------------------------------------------------------------
4590    // shadowed_config_names: no lower-precedence names after the last index
4591    // ------------------------------------------------------------------
4592
4593    #[test]
4594    #[cfg_attr(miri, ignore)]
4595    fn shadowed_config_names_empty_when_last_config_wins() {
4596        let dir = test_dir("shadow-last");
4597        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
4598        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
4599        assert!(shadowed_config_names(dir.path(), 3).is_empty());
4600    }
4601
4602    // ------------------------------------------------------------------
4603    // warn_on_coexisting_configs: path without filename (edge branch)
4604    // shadowed is empty -> early return without recording
4605    // ------------------------------------------------------------------
4606
4607    #[test]
4608    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
4609        let ((), captured) = capture_coexisting_config_warnings(|| {
4610            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
4611        });
4612        assert!(
4613            captured.is_empty(),
4614            "empty shadowed list must produce no warning"
4615        );
4616    }
4617
4618    // ------------------------------------------------------------------
4619    // extract_extends: array with non-string entries are filtered out
4620    // ------------------------------------------------------------------
4621
4622    #[test]
4623    fn extract_extends_array_filters_non_strings() {
4624        let mut value = serde_json::json!({
4625            "extends": ["a.json", 42, null, "b.json", true]
4626        });
4627        let extends = extract_extends(&mut value);
4628        assert_eq!(extends, vec!["a.json", "b.json"]);
4629    }
4630
4631    // ------------------------------------------------------------------
4632    // record_extends_visit: circular file-extends detected
4633    // (secondary path: same canonical path inserted twice)
4634    // ------------------------------------------------------------------
4635
4636    #[test]
4637    #[cfg_attr(miri, ignore)]
4638    fn record_extends_visit_circular_same_file() {
4639        let dir = test_dir("visit-circular");
4640        let path = dir.path().join("config.json");
4641        std::fs::write(&path, "{}").unwrap();
4642
4643        let mut visited = FxHashSet::default();
4644        record_extends_visit(&path, &mut visited).unwrap();
4645        let result = record_extends_visit(&path, &mut visited);
4646        assert!(result.is_err());
4647        let err = result.unwrap_err().to_string();
4648        assert!(
4649            err.contains("Circular extends"),
4650            "second visit of same file must report circular: {err}"
4651        );
4652    }
4653
4654    // ------------------------------------------------------------------
4655    // find_and_load: stops at .svn dir (is_repo_root branch)
4656    // ------------------------------------------------------------------
4657
4658    #[test]
4659    #[cfg_attr(miri, ignore)]
4660    fn find_config_path_stops_at_svn_dir() {
4661        let dir = test_dir("find-path-svn");
4662        let sub = dir.path().join("sub");
4663        std::fs::create_dir(&sub).unwrap();
4664        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4665
4666        let path = FallowConfig::find_config_path(&sub);
4667        assert!(path.is_none(), "svn root should stop config search");
4668    }
4669
4670    // ------------------------------------------------------------------
4671    // deep_merge: array over object replaces
4672    // ------------------------------------------------------------------
4673
4674    #[test]
4675    fn deep_merge_array_over_object_replaces() {
4676        let mut base = serde_json::json!({"key": "value"});
4677        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
4678        assert_eq!(base, serde_json::json!(["a", "b"]));
4679    }
4680
4681    // ------------------------------------------------------------------
4682    // find_and_load: returns an error when config parses but glob validation fails
4683    // ------------------------------------------------------------------
4684
4685    #[test]
4686    #[cfg_attr(miri, ignore)]
4687    fn find_and_load_returns_error_for_invalid_glob_in_config() {
4688        let dir = test_dir("find-invalid-glob");
4689        std::fs::create_dir(dir.path().join(".git")).unwrap();
4690        std::fs::write(
4691            dir.path().join(".fallowrc.json"),
4692            r#"{"entry": ["[invalid-glob"]}"#,
4693        )
4694        .unwrap();
4695
4696        let result = FallowConfig::find_and_load(dir.path());
4697        assert!(
4698            result.is_err(),
4699            "invalid glob should surface as an error from find_and_load"
4700        );
4701    }
4702
4703    // ------------------------------------------------------------------
4704    // resolve_package_exports: Object map with "." key that is not a
4705    // string or object returns None (the `_ => None` arm)
4706    // ------------------------------------------------------------------
4707
4708    #[test]
4709    fn resolve_package_exports_dot_key_array_returns_none() {
4710        // "." value is an array, which is neither String nor Object
4711        let pkg = serde_json::json!({
4712            "exports": {".": ["array-value"]}
4713        });
4714        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4715        assert!(result.is_none(), "array dot-export should return None");
4716    }
4717
4718    #[test]
4719    fn resolve_package_exports_exports_is_array_returns_none() {
4720        // top-level "exports" is an array (not String or Object)
4721        let pkg = serde_json::json!({
4722            "exports": ["./index.js"]
4723        });
4724        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4725        assert!(result.is_none(), "array-form exports should return None");
4726    }
4727
4728    #[test]
4729    fn resolve_package_exports_object_no_dot_key_returns_none() {
4730        // Object exports without "." key
4731        let pkg = serde_json::json!({
4732            "exports": {"./sub": "./sub.js"}
4733        });
4734        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4735        assert!(result.is_none(), "no dot key should return None");
4736    }
4737
4738    #[test]
4739    fn resolve_package_exports_conditions_without_known_key_returns_none() {
4740        // "." is an Object but none of the known condition keys are present
4741        let pkg = serde_json::json!({
4742            "exports": {".": {"browser": "./browser.js"}}
4743        });
4744        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
4745        assert!(result.is_none(), "unknown condition key should return None");
4746    }
4747
4748    // ------------------------------------------------------------------
4749    // npm package: exports condition "import" key (one of the priority keys)
4750    // ------------------------------------------------------------------
4751
4752    #[test]
4753    #[cfg_attr(miri, ignore)]
4754    fn extends_npm_exports_import_condition() {
4755        let dir = test_dir("npm-import-cond");
4756        let pkg_dir = dir.path().join("node_modules/import-config");
4757        std::fs::create_dir_all(&pkg_dir).unwrap();
4758        std::fs::write(
4759            pkg_dir.join("package.json"),
4760            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
4761        )
4762        .unwrap();
4763        std::fs::write(
4764            pkg_dir.join("esm.json"),
4765            r#"{"rules": {"unused-types": "warn"}}"#,
4766        )
4767        .unwrap();
4768
4769        std::fs::write(
4770            dir.path().join(".fallowrc.json"),
4771            r#"{"extends": "npm:import-config"}"#,
4772        )
4773        .unwrap();
4774
4775        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4776        assert_eq!(config.rules.unused_types, Severity::Warn);
4777    }
4778
4779    // ------------------------------------------------------------------
4780    // npm package: exports condition "require" key
4781    // ------------------------------------------------------------------
4782
4783    #[test]
4784    #[cfg_attr(miri, ignore)]
4785    fn extends_npm_exports_require_condition() {
4786        let dir = test_dir("npm-require-cond");
4787        let pkg_dir = dir.path().join("node_modules/require-config");
4788        std::fs::create_dir_all(&pkg_dir).unwrap();
4789        std::fs::write(
4790            pkg_dir.join("package.json"),
4791            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
4792        )
4793        .unwrap();
4794        std::fs::write(
4795            pkg_dir.join("cjs.json"),
4796            r#"{"rules": {"unused-class-members": "warn"}}"#,
4797        )
4798        .unwrap();
4799
4800        std::fs::write(
4801            dir.path().join(".fallowrc.json"),
4802            r#"{"extends": "npm:require-config"}"#,
4803        )
4804        .unwrap();
4805
4806        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4807        assert_eq!(config.rules.unused_class_members, Severity::Warn);
4808    }
4809}