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