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