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