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