Skip to main content

fallow_config/config/
parsing.rs

1use std::net::Ipv6Addr;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use fallow_types::path_util::is_absolute_path_any_platform;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use super::FallowConfig;
9
10/// Supported config file names in priority order.
11pub(super) const CONFIG_NAMES: &[&str] = &[
12    ".fallowrc.json",
13    ".fallowrc.jsonc",
14    "fallow.toml",
15    ".fallow.toml",
16];
17
18pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
19
20/// Prefix for npm package specifiers in the `extends` field.
21const NPM_PREFIX: &str = "npm:";
22
23/// Prefix for HTTPS URL specifiers in the `extends` field.
24const HTTPS_PREFIX: &str = "https://";
25
26/// Prefix for HTTP URL specifiers (rejected with a clear error).
27const HTTP_PREFIX: &str = "http://";
28
29/// Default timeout for fetching remote configs via URL extends.
30const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
31
32/// Host-controlled trust policy for loading a fallow config.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct ConfigLoadOptions {
35    /// Permit `https://` entries in the config inheritance graph.
36    pub allow_remote_extends: bool,
37}
38
39/// Detect config format from file extension.
40pub(super) enum ConfigFormat {
41    Toml,
42    Json,
43}
44
45impl ConfigFormat {
46    pub(super) fn from_path(path: &Path) -> Self {
47        match path.extension().and_then(|e| e.to_str()) {
48            Some("json" | "jsonc") => Self::Json,
49            _ => Self::Toml,
50        }
51    }
52}
53
54/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
55/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
56pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
57    match (base, overlay) {
58        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
59            for (key, value) in overlay_map {
60                if let Some(base_value) = base_map.get_mut(&key) {
61                    deep_merge_json(base_value, value);
62                } else {
63                    base_map.insert(key, value);
64                }
65            }
66        }
67        (base, overlay) => {
68            *base = overlay;
69        }
70    }
71}
72
73pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
74    let content = std::fs::read_to_string(path)
75        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
76    let content = content.trim_start_matches('\u{FEFF}');
77
78    match ConfigFormat::from_path(path) {
79        ConfigFormat::Toml => {
80            let toml_value: toml::Value = toml::from_str(content).map_err(|e| {
81                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
82            })?;
83            serde_json::to_value(toml_value).map_err(|e| {
84                miette::miette!(
85                    "Failed to convert TOML to JSON for {}: {}",
86                    path.display(),
87                    e
88                )
89            })
90        }
91        ConfigFormat::Json => crate::jsonc::parse_to_value(content)
92            .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e)),
93    }
94}
95
96fn is_repo_root(dir: &Path) -> bool {
97    dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
98}
99
100fn resolve_confined(
101    base_dir: &Path,
102    resolved: &Path,
103    context: &str,
104    source_config: &Path,
105) -> Result<PathBuf, miette::Report> {
106    let canonical_base = dunce::canonicalize(base_dir)
107        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
108    let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
109        miette::miette!(
110            "Config file not found: {} ({}, referenced from {}): {}",
111            resolved.display(),
112            context,
113            source_config.display(),
114            e
115        )
116    })?;
117    if !canonical_file.starts_with(&canonical_base) {
118        return Err(miette::miette!(
119            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
120            resolved.display(),
121            base_dir.display(),
122            context,
123            source_config.display()
124        ));
125    }
126    Ok(canonical_file)
127}
128
129fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
130    if name.starts_with('@') && !name.contains('/') {
131        return Err(miette::miette!(
132            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
133            name,
134            source_config.display()
135        ));
136    }
137    if name.split('/').any(|c| c == ".." || c == ".") {
138        return Err(miette::miette!(
139            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
140            name,
141            source_config.display()
142        ));
143    }
144    Ok(())
145}
146
147fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
148    if specifier.starts_with('@') {
149        let mut slashes = 0;
150        for (i, ch) in specifier.char_indices() {
151            if ch == '/' {
152                slashes += 1;
153                if slashes == 2 {
154                    return (&specifier[..i], Some(&specifier[i + 1..]));
155                }
156            }
157        }
158        (specifier, None)
159    } else if let Some(slash) = specifier.find('/') {
160        (&specifier[..slash], Some(&specifier[slash + 1..]))
161    } else {
162        (specifier, None)
163    }
164}
165
166fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
167    let exports = pkg.get("exports")?;
168    match exports {
169        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
170        serde_json::Value::Object(map) => {
171            let dot_export = map.get(".")?;
172            match dot_export {
173                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
174                serde_json::Value::Object(conditions) => {
175                    for key in ["default", "node", "import", "require"] {
176                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
177                            return Some(package_dir.join(s.as_str()));
178                        }
179                    }
180                    None
181                }
182                _ => None,
183            }
184        }
185        _ => None,
186    }
187}
188
189fn find_config_in_npm_package(
190    package_dir: &Path,
191    source_config: &Path,
192) -> Result<PathBuf, miette::Report> {
193    let pkg_json_path = package_dir.join("package.json");
194    if pkg_json_path.exists() {
195        let content = std::fs::read_to_string(&pkg_json_path)
196            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
197        let pkg: serde_json::Value = serde_json::from_str(&content)
198            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
199        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
200            && config_path.exists()
201        {
202            return resolve_confined(
203                package_dir,
204                &config_path,
205                "package.json exports",
206                source_config,
207            );
208        }
209        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
210            let main_path = package_dir.join(main);
211            if main_path.exists() {
212                return resolve_confined(
213                    package_dir,
214                    &main_path,
215                    "package.json main",
216                    source_config,
217                );
218            }
219        }
220    }
221
222    for config_name in CONFIG_NAMES {
223        let config_path = package_dir.join(config_name);
224        if config_path.exists() {
225            return resolve_confined(
226                package_dir,
227                &config_path,
228                "config name fallback",
229                source_config,
230            );
231        }
232    }
233
234    Err(miette::miette!(
235        "No fallow config found in npm package at {}. \
236         Expected package.json with main/exports pointing to a config file, \
237         or one of: {}",
238        package_dir.display(),
239        CONFIG_NAMES.join(", ")
240    ))
241}
242
243fn resolve_npm_package(
244    config_dir: &Path,
245    specifier: &str,
246    source_config: &Path,
247) -> Result<PathBuf, miette::Report> {
248    let specifier = specifier.trim();
249    if specifier.is_empty() {
250        return Err(miette::miette!(
251            "Empty npm specifier in extends (in {})",
252            source_config.display()
253        ));
254    }
255
256    let (package_name, subpath) = parse_npm_specifier(specifier);
257    validate_npm_package_name(package_name, source_config)?;
258
259    let mut dir = Some(config_dir);
260    while let Some(d) = dir {
261        let candidate = d.join("node_modules").join(package_name);
262        if candidate.is_dir() {
263            return if let Some(sub) = subpath {
264                let file = candidate.join(sub);
265                if file.exists() {
266                    resolve_confined(
267                        &candidate,
268                        &file,
269                        &format!("subpath '{sub}'"),
270                        source_config,
271                    )
272                } else {
273                    Err(miette::miette!(
274                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
275                        file.display(),
276                        sub,
277                        candidate.display(),
278                        source_config.display()
279                    ))
280                }
281            } else {
282                find_config_in_npm_package(&candidate, source_config)
283            };
284        }
285        dir = d.parent();
286    }
287
288    Err(miette::miette!(
289        "npm package '{}' not found. \
290         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
291         If this package should be available, install it and ensure it is listed in your project's dependencies",
292        package_name,
293        package_name,
294        config_dir.display(),
295        source_config.display()
296    ))
297}
298
299/// Normalize a URL for config resource identity.
300fn normalize_url_for_dedup(url: &str) -> String {
301    let Some((scheme, rest)) = url.split_once("://") else {
302        return url.to_string();
303    };
304    let scheme = scheme.to_ascii_lowercase();
305
306    let rest = rest.split_once('#').map_or(rest, |(value, _)| value);
307    let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
308    let (authority, tail) = rest.split_at(authority_end);
309    let authority = normalize_url_authority(authority, &scheme);
310
311    let tail = if tail == "/" {
312        ""
313    } else if tail.starts_with("/?") {
314        &tail[1..]
315    } else {
316        tail
317    };
318
319    if tail.is_empty() {
320        format!("{scheme}://{authority}")
321    } else {
322        format!("{scheme}://{authority}{tail}")
323    }
324}
325
326/// Normalize only the case-insensitive parts of a URL authority and its default port.
327fn normalize_url_authority(authority: &str, scheme: &str) -> String {
328    let (userinfo, host_port) = authority
329        .rsplit_once('@')
330        .map_or((None, authority), |(userinfo, host_port)| {
331            (Some(userinfo), host_port)
332        });
333
334    let (host, port) = if host_port.starts_with('[') {
335        host_port.find("]:").map_or((host_port, None), |separator| {
336            let (host, port) = host_port.split_at(separator + 1);
337            (host, port.strip_prefix(':'))
338        })
339    } else {
340        host_port
341            .rsplit_once(':')
342            .map_or((host_port, None), |(host, port)| (host, Some(port)))
343    };
344
345    let port = port.map(|value| {
346        value
347            .parse::<u16>()
348            .map_or_else(|_| value.to_string(), |number| number.to_string())
349    });
350    let port =
351        port.filter(|port| !matches!((scheme, port.as_str()), ("http", "80") | ("https", "443")));
352    let host = host
353        .strip_prefix('[')
354        .and_then(|value| value.strip_suffix(']'))
355        .and_then(|value| value.parse::<Ipv6Addr>().ok())
356        .map_or_else(
357            || host.to_ascii_lowercase(),
358            |address| format!("[{address}]"),
359        );
360
361    match (userinfo, port.as_deref()) {
362        (Some(userinfo), Some(port)) => format!("{userinfo}@{host}:{port}"),
363        (Some(userinfo), None) => format!("{userinfo}@{host}"),
364        (None, Some(port)) => format!("{host}:{port}"),
365        (None, None) => host,
366    }
367}
368
369/// Format a remote config location for diagnostics without exposing URL secrets.
370fn remote_config_display(location: &str) -> String {
371    let Some((scheme, rest)) = location.split_once("://") else {
372        return location.to_string();
373    };
374
375    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
376    let (authority, tail) = rest.split_at(authority_end);
377    let authority = authority
378        .rsplit_once('@')
379        .map_or(authority, |(_, host)| host);
380    let path_end = tail.find(['?', '#']).unwrap_or(tail.len());
381    let path = &tail[..path_end];
382
383    format!("{scheme}://{authority}{path}")
384}
385
386/// Format a fetch error without trusting the HTTP client's URL rendering.
387fn remote_fetch_error_display(error: &ureq::Error, _url: &str) -> String {
388    match error {
389        ureq::Error::RequireHttpsOnly(redirect_target) => format!(
390            "configured for https only: {}",
391            remote_config_display(redirect_target)
392        ),
393        ureq::Error::StatusCode(code) => format!("http status {code}"),
394        ureq::Error::HostNotFound => "host not found".to_string(),
395        ureq::Error::Timeout(_) => "request timed out".to_string(),
396        _ => "request failed".to_string(),
397    }
398}
399
400fn starts_with_url_prefix(value: &str, prefix: &str) -> bool {
401    value
402        .get(..prefix.len())
403        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
404}
405
406/// Read the `FALLOW_EXTENDS_TIMEOUT_SECS` env var, falling back to [`DEFAULT_URL_TIMEOUT_SECS`].
407fn url_timeout() -> Duration {
408    url_timeout_from(std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS").ok().as_deref())
409}
410
411/// Parse a raw `FALLOW_EXTENDS_TIMEOUT_SECS` value into a timeout, falling back
412/// to [`DEFAULT_URL_TIMEOUT_SECS`] for absent, zero, or non-numeric input. Pure
413/// so the parsing branches stay testable without mutating the process env.
414fn url_timeout_from(raw: Option<&str>) -> Duration {
415    raw.and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
416        .map_or(
417            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
418            Duration::from_secs,
419        )
420}
421
422/// Maximum response body size for fetched config files.
423const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
424
425/// Fetch a remote JSON config from an HTTPS URL.
426fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
427    let url_display = remote_config_display(url);
428    let source_display = remote_config_display(source);
429    let timeout = url_timeout();
430    let agent = ureq::Agent::config_builder()
431        .timeout_global(Some(timeout))
432        .https_only(true)
433        .build()
434        .new_agent();
435
436    let mut response = agent.get(url).call().map_err(|e| {
437        let error_display = remote_fetch_error_display(&e, url);
438        miette::miette!(
439            "Failed to fetch remote config from {url_display} \
440             (referenced from {source_display}): {error_display}. \
441             If this URL is unavailable, use a local path or npm: specifier instead"
442        )
443    })?;
444
445    let body = response
446        .body_mut()
447        .with_config()
448        .limit(MAX_URL_CONFIG_BYTES)
449        .read_to_string()
450        .map_err(|e| {
451            miette::miette!(
452                "Failed to read response body from {url_display} \
453                 (referenced from {source_display}): {e}"
454            )
455        })?;
456
457    crate::jsonc::parse_to_value(&body).map_err(|e| {
458        miette::miette!(
459            "Failed to parse remote config as JSON from {url_display} \
460             (referenced from {source_display}): {e}. \
461             Only JSON/JSONC is supported for URL-sourced configs"
462        )
463    })
464}
465
466trait RemoteConfigFetcher {
467    fn fetch(&mut self, url: &str, source: &str) -> Result<serde_json::Value, miette::Report>;
468}
469
470struct NetworkRemoteConfigFetcher;
471
472impl RemoteConfigFetcher for NetworkRemoteConfigFetcher {
473    fn fetch(&mut self, url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
474        fetch_url_config(url, source)
475    }
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Hash)]
479enum ConfigResourceId {
480    Local(PathBuf),
481    Remote(String),
482}
483
484struct ExtendsResolver<'a, Fetcher> {
485    options: ConfigLoadOptions,
486    active: FxHashSet<ConfigResourceId>,
487    resolved: FxHashMap<ConfigResourceId, serde_json::Value>,
488    fetcher: &'a mut Fetcher,
489}
490
491#[derive(Clone, Copy)]
492struct LocalExtendsEntry<'a> {
493    path: &'a Path,
494    config_dir: &'a Path,
495    entry: &'a str,
496    sealed: bool,
497    sealed_dir_canonical: Option<&'a Path>,
498    depth: usize,
499}
500
501impl<'a, Fetcher: RemoteConfigFetcher> ExtendsResolver<'a, Fetcher> {
502    fn new(options: ConfigLoadOptions, fetcher: &'a mut Fetcher) -> Self {
503        Self {
504            options,
505            active: FxHashSet::default(),
506            resolved: FxHashMap::default(),
507            fetcher,
508        }
509    }
510
511    fn resolve_local(
512        &mut self,
513        path: &Path,
514        depth: usize,
515    ) -> Result<serde_json::Value, miette::Report> {
516        if depth >= MAX_EXTENDS_DEPTH {
517            return Err(miette::miette!(
518                "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
519                path.display()
520            ));
521        }
522        let canonical = dunce::canonicalize(path).map_err(|e| {
523            miette::miette!(
524                "Config file not found or unresolvable: {}: {}",
525                path.display(),
526                e
527            )
528        })?;
529        let identity = ConfigResourceId::Local(canonical.clone());
530        if let Some(value) = self.resolved.get(&identity) {
531            return Ok(value.clone());
532        }
533        if !self.active.insert(identity.clone()) {
534            return Err(miette::miette!(
535                "Circular extends detected: {} is already active in the extends chain",
536                path.display()
537            ));
538        }
539
540        let result = self.resolve_local_uncached(&canonical, depth);
541        self.active.remove(&identity);
542        if let Ok(value) = &result {
543            self.resolved.insert(identity, value.clone());
544        }
545        result
546    }
547
548    fn resolve_local_uncached(
549        &mut self,
550        path: &Path,
551        depth: usize,
552    ) -> Result<serde_json::Value, miette::Report> {
553        let mut value = parse_config_to_value(path)?;
554        let extends = extract_extends(&mut value, &path.display().to_string())?;
555        if extends.is_empty() {
556            return Ok(value);
557        }
558
559        let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
560        let sealed = value
561            .get("sealed")
562            .and_then(serde_json::Value::as_bool)
563            .unwrap_or(false);
564        let sealed_dir_canonical = sealed_config_dir(config_dir, sealed)?;
565        let mut merged = serde_json::Value::Object(serde_json::Map::new());
566        for entry in &extends {
567            let base = self.resolve_local_entry(LocalExtendsEntry {
568                path,
569                config_dir,
570                entry,
571                sealed,
572                sealed_dir_canonical: sealed_dir_canonical.as_deref(),
573                depth,
574            })?;
575            deep_merge_json(&mut merged, base);
576        }
577        deep_merge_json(&mut merged, value);
578        Ok(merged)
579    }
580
581    fn resolve_local_entry(
582        &mut self,
583        input: LocalExtendsEntry<'_>,
584    ) -> Result<serde_json::Value, miette::Report> {
585        let LocalExtendsEntry {
586            path,
587            config_dir,
588            entry,
589            sealed,
590            sealed_dir_canonical,
591            depth,
592        } = input;
593        if starts_with_url_prefix(entry, HTTPS_PREFIX) {
594            reject_sealed_remote_extends(path, entry, sealed, "URL")?;
595            return self.resolve_remote(entry, depth + 1);
596        }
597        if starts_with_url_prefix(entry, HTTP_PREFIX) {
598            let entry_display = remote_config_display(entry);
599            return Err(miette::miette!(
600                "URL extends must use https://, got http:// URL '{}' (in {}). \
601                 Change the URL to use https:// instead",
602                entry_display,
603                path.display()
604            ));
605        }
606        if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
607            reject_sealed_remote_extends(path, entry, sealed, "npm")?;
608            let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
609            return self.resolve_local(&npm_path, depth + 1);
610        }
611        if is_absolute_path_any_platform(Path::new(entry)) {
612            return Err(miette::miette!(
613                "extends paths must be relative, got absolute path: {} (in {})",
614                entry,
615                path.display()
616            ));
617        }
618        let resolved_path = config_dir.join(entry);
619        if !resolved_path.exists() {
620            return Err(miette::miette!(
621                "Extended config file not found: {} (referenced from {})",
622                resolved_path.display(),
623                path.display()
624            ));
625        }
626        validate_sealed_relative_extends(path, entry, &resolved_path, sealed_dir_canonical)?;
627        self.resolve_local(&resolved_path, depth + 1)
628    }
629
630    fn resolve_remote(
631        &mut self,
632        url: &str,
633        depth: usize,
634    ) -> Result<serde_json::Value, miette::Report> {
635        if depth >= MAX_EXTENDS_DEPTH {
636            let url_display = remote_config_display(url);
637            return Err(miette::miette!(
638                "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url_display}"
639            ));
640        }
641        if !self.options.allow_remote_extends {
642            let url_display = remote_config_display(url);
643            return Err(miette::miette!(
644                "Remote config extends '{url_display}' is disabled by default. \
645                 CLI users can pass --allow-remote-extends. Library callers can use \
646                 ConfigLoadOptions {{ allow_remote_extends: true }}"
647            ));
648        }
649
650        let identity = ConfigResourceId::Remote(normalize_url_for_dedup(url));
651        if let Some(value) = self.resolved.get(&identity) {
652            return Ok(value.clone());
653        }
654        if !self.active.insert(identity.clone()) {
655            let url_display = remote_config_display(url);
656            return Err(miette::miette!(
657                "Circular extends detected: {url_display} is already active in the extends chain"
658            ));
659        }
660
661        let result = self.resolve_remote_uncached(url, depth);
662        self.active.remove(&identity);
663        if let Ok(value) = &result {
664            self.resolved.insert(identity, value.clone());
665        }
666        result
667    }
668
669    fn resolve_remote_uncached(
670        &mut self,
671        url: &str,
672        depth: usize,
673    ) -> Result<serde_json::Value, miette::Report> {
674        let mut value = self.fetcher.fetch(url, url)?;
675        let extends = extract_extends(&mut value, &remote_config_display(url))?;
676        if extends.is_empty() {
677            return Ok(value);
678        }
679
680        let url_display = remote_config_display(url);
681        let mut merged = serde_json::Value::Object(serde_json::Map::new());
682        for entry in &extends {
683            let base = if starts_with_url_prefix(entry, HTTPS_PREFIX) {
684                self.resolve_remote(entry, depth + 1)?
685            } else if starts_with_url_prefix(entry, HTTP_PREFIX) {
686                let entry_display = remote_config_display(entry);
687                return Err(miette::miette!(
688                    "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
689                     Change the URL to use https:// instead",
690                    entry_display,
691                    url_display
692                ));
693            } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
694                let cwd = std::env::current_dir().map_err(|e| {
695                    miette::miette!(
696                        "Cannot resolve npm: specifier from URL-sourced config: \
697                         failed to determine current directory: {e}"
698                    )
699                })?;
700                let path_placeholder = PathBuf::from(&url_display);
701                let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
702                self.resolve_local(&npm_path, depth + 1)?
703            } else {
704                return Err(miette::miette!(
705                    "Relative paths in 'extends' are not supported when the base config was \
706                     fetched from a URL ('{url_display}'). Use another https:// URL or npm: reference \
707                     instead. Got: '{entry}'"
708                ));
709            };
710            deep_merge_json(&mut merged, base);
711        }
712        deep_merge_json(&mut merged, value);
713        Ok(merged)
714    }
715}
716
717/// Extract the `extends` array from a parsed JSON config value.
718///
719/// Fails loud on malformed values: the key is removed before
720/// deserialization, so `FallowConfig`'s `deny_unknown_fields` never sees a
721/// bad `extends` and the base config would otherwise go unmerged silently.
722fn extract_extends(
723    value: &mut serde_json::Value,
724    source: &str,
725) -> Result<Vec<String>, miette::Report> {
726    let Some(extends) = value.as_object_mut().and_then(|obj| obj.remove("extends")) else {
727        return Ok(Vec::new());
728    };
729    match extends {
730        serde_json::Value::String(s) => Ok(vec![s]),
731        serde_json::Value::Array(arr) => arr
732            .into_iter()
733            .map(|entry| match entry {
734                serde_json::Value::String(s) => Ok(s),
735                other => Err(miette::miette!(
736                    "extends entries must be strings, got {} (in {source})",
737                    json_type_name(&other)
738                )),
739            })
740            .collect(),
741        other => Err(miette::miette!(
742            "extends must be a string or an array of strings, got {} (in {source})",
743            json_type_name(&other)
744        )),
745    }
746}
747
748fn json_type_name(value: &serde_json::Value) -> &'static str {
749    match value {
750        serde_json::Value::Null => "null",
751        serde_json::Value::Bool(_) => "a boolean",
752        serde_json::Value::Number(_) => "a number",
753        serde_json::Value::String(_) => "a string",
754        serde_json::Value::Array(_) => "an array",
755        serde_json::Value::Object(_) => "an object",
756    }
757}
758
759#[cfg(test)]
760fn resolve_url_extends(
761    url: &str,
762    _visited: &mut FxHashSet<String>,
763    depth: usize,
764) -> Result<serde_json::Value, miette::Report> {
765    let mut fetcher = NetworkRemoteConfigFetcher;
766    ExtendsResolver::new(
767        ConfigLoadOptions {
768            allow_remote_extends: true,
769        },
770        &mut fetcher,
771    )
772    .resolve_remote(url, depth)
773}
774
775fn sealed_config_dir(config_dir: &Path, sealed: bool) -> Result<Option<PathBuf>, miette::Report> {
776    if !sealed {
777        return Ok(None);
778    }
779    dunce::canonicalize(config_dir).map(Some).map_err(|e| {
780        miette::miette!(
781            "Sealed config directory '{}' could not be canonicalized: {e}",
782            config_dir.display()
783        )
784    })
785}
786
787fn reject_sealed_remote_extends(
788    path: &Path,
789    entry: &str,
790    sealed: bool,
791    kind: &str,
792) -> Result<(), miette::Report> {
793    if sealed {
794        let entry_display = remote_config_display(entry);
795        Err(miette::miette!(
796            "'sealed: true' config at {} rejects {} extends '{}'. \
797             Sealed configs only allow file-relative extends within \
798             the config's directory",
799            path.display(),
800            kind,
801            entry_display
802        ))
803    } else {
804        Ok(())
805    }
806}
807
808fn validate_sealed_relative_extends(
809    path: &Path,
810    entry: &str,
811    resolved_path: &Path,
812    sealed_dir_canonical: Option<&Path>,
813) -> Result<(), miette::Report> {
814    let Some(dir_canonical) = sealed_dir_canonical else {
815        return Ok(());
816    };
817    let p_canonical = dunce::canonicalize(resolved_path).map_err(|e| {
818        miette::miette!(
819            "Sealed config extends path '{}' could not be canonicalized: {e}",
820            resolved_path.display()
821        )
822    })?;
823    if p_canonical.starts_with(dir_canonical) {
824        Ok(())
825    } else {
826        Err(miette::miette!(
827            "'sealed: true' config at {} rejects extends '{}' which resolves \
828             outside the config's directory ({}). Sealed configs only allow \
829             extends within the config's directory",
830            path.display(),
831            entry,
832            p_canonical.display()
833        ))
834    }
835}
836
837/// Public entry point: resolve a config file with all its extends chain.
838///
839/// Delegates to [`resolve_extends_file`] with a fresh visited set.
840#[cfg(test)]
841pub(super) fn resolve_extends(
842    path: &Path,
843    _visited: &mut FxHashSet<String>,
844    depth: usize,
845) -> Result<serde_json::Value, miette::Report> {
846    let mut fetcher = NetworkRemoteConfigFetcher;
847    ExtendsResolver::new(ConfigLoadOptions::default(), &mut fetcher).resolve_local(path, depth)
848}
849
850/// Collect every unknown key under `rules` or `overrides[].rules` in a merged
851/// config value (issue #467, phase 1).
852///
853/// Today `RulesConfig` / `PartialRulesConfig` carry serde aliases but NOT
854/// `deny_unknown_fields`, so typos like `unsued-files` are silently dropped and
855/// the user's intent is lost. This pass walks the merged value before
856/// deserialization and surfaces every unknown key, with a Levenshtein-distance
857/// suggestion when the typo is close to a known name.
858///
859/// Returns the findings so the caller can render them; tests can assert
860/// against the list without subscribing to tracing output.
861///
862/// Phase 2 (a future minor release) flips both structs to
863/// `#[serde(deny_unknown_fields)]` and the warning becomes a hard error.
864pub(super) fn collect_unknown_rule_keys(
865    merged: &serde_json::Value,
866) -> Vec<super::rules::UnknownRuleKey> {
867    use super::rules::find_unknown_rule_keys;
868
869    let mut findings = Vec::new();
870
871    if let Some(rules) = merged.get("rules") {
872        findings.extend(find_unknown_rule_keys(rules, "rules"));
873    }
874
875    if let Some(overrides) = merged.get("overrides").and_then(|v| v.as_array()) {
876        for (i, entry) in overrides.iter().enumerate() {
877            if let Some(rules) = entry.get("rules") {
878                let context = format!("overrides[{i}].rules");
879                findings.extend(find_unknown_rule_keys(rules, &context));
880            }
881        }
882    }
883
884    findings
885}
886
887thread_local! {
888    /// Per-thread capture of unknown-rule findings, for the wiring regression
889    /// test in this module. Each test installs a fresh capture via
890    /// [`capture_unknown_rule_warnings`], runs `FallowConfig::load`, and reads
891    /// back the findings. Thread-local so parallel test execution does not
892    /// race; bypassed entirely in production code (`UnknownRuleCapture::None`).
893    #[cfg(test)]
894    static UNKNOWN_RULE_CAPTURE: std::cell::RefCell<Option<Vec<super::rules::UnknownRuleKey>>> =
895        const { std::cell::RefCell::new(None) };
896}
897
898/// Install a thread-local capture buffer and run `body`. Returns the findings
899/// emitted by every `warn_on_unknown_rule_keys` call within `body`'s call tree
900/// on the current thread, in order. Test-only.
901#[cfg(test)]
902pub(super) fn capture_unknown_rule_warnings<F: FnOnce() -> R, R>(
903    body: F,
904) -> (R, Vec<super::rules::UnknownRuleKey>) {
905    UNKNOWN_RULE_CAPTURE.with(|cell| {
906        *cell.borrow_mut() = Some(Vec::new());
907    });
908    let result = body();
909    let findings = UNKNOWN_RULE_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
910    (result, findings)
911}
912
913/// Emit a `tracing::warn!` per finding from [`collect_unknown_rule_keys`].
914///
915/// `config_path` is the file the merged value originated from; it appears in
916/// the warning text AND in the dedupe key so two different config files with
917/// the same typo each warn once instead of the second one being silenced.
918///
919/// Deduplicates within the process: `FallowConfig::load` runs multiple times
920/// per analysis (combined mode runs check + dupes + health, each through the
921/// same config load path), so without a dedupe the same typo emits 3+ warnings
922/// per run.
923fn warn_on_unknown_rule_keys(config_path: &Path, merged: &serde_json::Value) {
924    use std::sync::{Mutex, OnceLock};
925
926    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
927    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
928
929    let path_display = config_path.display().to_string();
930
931    for finding in collect_unknown_rule_keys(merged) {
932        let dedupe_key = format!("{path_display}::{}::{}", finding.context, finding.key);
933        if let Ok(mut set) = warned.lock()
934            && !set.insert(dedupe_key)
935        {
936            continue;
937        }
938
939        #[cfg(test)]
940        UNKNOWN_RULE_CAPTURE.with(|cell| {
941            if let Some(buf) = cell.borrow_mut().as_mut() {
942                buf.push(finding.clone());
943            }
944        });
945
946        if let Some(suggestion) = finding.suggestion {
947            tracing::warn!(
948                "unknown rule '{key}' in {context} of {path} (did you mean '{suggestion}'?); \
949                 the rule will be ignored. A future release will reject unknown rule names.",
950                key = finding.key,
951                context = finding.context,
952                path = path_display,
953            );
954        } else {
955            tracing::warn!(
956                "unknown rule '{key}' in {context} of {path}; the rule will be ignored. \
957                 A future release will reject unknown rule names.",
958                key = finding.key,
959                context = finding.context,
960                path = path_display,
961            );
962        }
963    }
964}
965
966/// Return the lower-precedence config names from [`CONFIG_NAMES`] that ALSO
967/// exist in `dir`, given that `chosen_index` is the index of the first-match
968/// (winning) name.
969///
970/// Only indices after `chosen_index` are scanned: a higher-precedence name
971/// cannot coexist undetected, because it would have been the first match.
972fn shadowed_config_names(dir: &Path, chosen_index: usize) -> Vec<&'static str> {
973    CONFIG_NAMES
974        .iter()
975        .skip(chosen_index + 1)
976        .filter(|name| dir.join(name).exists())
977        .copied()
978        .collect()
979}
980
981/// A captured coexistence warning: `(chosen file name, shadowed file names)`.
982/// Test-only; populated by `warn_on_coexisting_configs` under capture.
983#[cfg(test)]
984type CoexistWarning = (String, Vec<String>);
985
986thread_local! {
987    /// Per-thread capture of coexisting-config warnings, for the wiring
988    /// regression test in this module. Mirrors [`UNKNOWN_RULE_CAPTURE`]: each
989    /// test installs a fresh capture via
990    /// [`capture_coexisting_config_warnings`], runs `find_and_load`, and reads
991    /// back the `(chosen, shadowed)` pairs. Thread-local so parallel test
992    /// execution does not race; bypassed entirely in production code.
993    #[cfg(test)]
994    static COEXIST_CAPTURE: std::cell::RefCell<Option<Vec<CoexistWarning>>> =
995        const { std::cell::RefCell::new(None) };
996}
997
998/// Install a thread-local capture buffer and run `body`. Returns every
999/// `(chosen, shadowed)` pair emitted by `warn_on_coexisting_configs` within
1000/// `body`'s call tree on the current thread, in order. Test-only.
1001#[cfg(test)]
1002pub(super) fn capture_coexisting_config_warnings<F: FnOnce() -> R, R>(
1003    body: F,
1004) -> (R, Vec<CoexistWarning>) {
1005    COEXIST_CAPTURE.with(|cell| {
1006        *cell.borrow_mut() = Some(Vec::new());
1007    });
1008    let result = body();
1009    let findings = COEXIST_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
1010    (result, findings)
1011}
1012
1013/// Emit a `tracing::warn!` when `find_and_load` picked `chosen_path` while one
1014/// or more lower-precedence config files (`shadowed`) coexist in the same
1015/// directory. Silent precedence is the worst class of config bug: the user
1016/// sees correct-looking output produced from the wrong source (#458).
1017///
1018/// `chosen_path` is the absolute candidate path of the winning config;
1019/// `shadowed` are the bare names of the lower-precedence files that also exist.
1020///
1021/// Deduplicates within the process keyed on the canonical directory, because
1022/// `find_and_load` runs multiple times per analysis (combined mode loads config
1023/// for check + dupes + health); without the dedupe the same directory would
1024/// warn 3+ times per run. Two different directories with coexisting configs
1025/// warn independently.
1026fn warn_on_coexisting_configs(chosen_path: &Path, shadowed: &[&str]) {
1027    use std::sync::{Mutex, OnceLock};
1028
1029    if shadowed.is_empty() {
1030        return;
1031    }
1032
1033    let chosen_name = chosen_path.file_name().map_or_else(
1034        || chosen_path.display().to_string(),
1035        |n| n.to_string_lossy().into_owned(),
1036    );
1037    let dir = chosen_path.parent().unwrap_or(chosen_path);
1038
1039    #[cfg(test)]
1040    COEXIST_CAPTURE.with(|cell| {
1041        if let Some(buf) = cell.borrow_mut().as_mut() {
1042            buf.push((
1043                chosen_name.clone(),
1044                shadowed.iter().map(|s| (*s).to_owned()).collect(),
1045            ));
1046        }
1047    });
1048
1049    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
1050    let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
1051    let dedupe_key = std::fs::canonicalize(dir)
1052        .unwrap_or_else(|_| dir.to_path_buf())
1053        .display()
1054        .to_string();
1055    if let Ok(mut set) = warned.lock()
1056        && !set.insert(dedupe_key)
1057    {
1058        return;
1059    }
1060
1061    tracing::warn!(
1062        "multiple fallow config files in {dir}: loaded '{chosen}', ignoring '{shadowed}'. \
1063         fallow uses the first match in precedence order \
1064         (.fallowrc.json > .fallowrc.jsonc > fallow.toml > .fallow.toml); \
1065         remove the unused file(s) to silence this warning.",
1066        dir = dir.display(),
1067        chosen = chosen_name,
1068        shadowed = shadowed.join(", "),
1069    );
1070}
1071
1072fn load_with_fetcher<Fetcher: RemoteConfigFetcher>(
1073    path: &Path,
1074    options: ConfigLoadOptions,
1075    fetcher: &mut Fetcher,
1076) -> Result<FallowConfig, miette::Report> {
1077    let merged = ExtendsResolver::new(options, fetcher).resolve_local(path, 0)?;
1078    FallowConfig::from_merged(path, merged)
1079}
1080
1081impl FallowConfig {
1082    /// Load config from a fallow config file (TOML or JSON/JSONC).
1083    ///
1084    /// The format is detected from the file extension:
1085    /// - `.toml` → TOML
1086    /// - `.json` → JSON (with JSONC comment stripping)
1087    ///
1088    /// Supports `extends` for config inheritance. Extended configs are loaded
1089    /// and deep-merged before this config's values are applied.
1090    ///
1091    /// User-supplied glob patterns (`entry`, `ignorePatterns`,
1092    /// `dynamicallyLoaded`, `duplicates.ignore`, `similarCode.ignore`, `health.ignore`,
1093    /// `health.thresholdOverrides[].files`, `boundaries.zones[].patterns`, `overrides[].files`,
1094    /// `ignoreExports[].file`, `ignoreCatalogReferences[].consumer`) are
1095    /// validated against absolute paths, `..` traversal segments, and invalid
1096    /// glob syntax. Loading fails loud on any rejection so silent no-match
1097    /// configs surface to the user. See issue #463.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns an error when the config file cannot be read, merged, or
1102    /// deserialized, or when any user-supplied glob pattern is rejected.
1103    pub fn load(path: &Path) -> Result<Self, miette::Report> {
1104        Self::load_with_options(path, ConfigLoadOptions::default())
1105    }
1106
1107    /// Load config with a host-controlled inheritance trust policy.
1108    ///
1109    /// Remote `https://` extends are denied unless
1110    /// [`ConfigLoadOptions::allow_remote_extends`] is explicitly enabled for
1111    /// this call. Local and `npm:` extends are unaffected.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns the same errors as [`Self::load`], plus a trust-policy error
1116    /// when a remote extends target is encountered without opt-in.
1117    pub fn load_with_options(
1118        path: &Path,
1119        options: ConfigLoadOptions,
1120    ) -> Result<Self, miette::Report> {
1121        let mut fetcher = NetworkRemoteConfigFetcher;
1122        load_with_fetcher(path, options, &mut fetcher)
1123    }
1124
1125    fn from_merged(path: &Path, merged: serde_json::Value) -> Result<Self, miette::Report> {
1126        warn_on_unknown_rule_keys(path, &merged);
1127
1128        let private_type_leaks_configured = merged
1129            .get("rules")
1130            .and_then(serde_json::Value::as_object)
1131            .is_some_and(|rules| {
1132                rules.contains_key("private-type-leaks") || rules.contains_key("private-type-leak")
1133            });
1134        let mut config: Self = serde_json::from_value(merged).map_err(|e| {
1135            let reason = e.to_string();
1136            // Unknown fields on overrides/ignoreExports entries are usually
1137            // inline annotations; point at JSONC comments as the fix.
1138            let hint = if matches!(ConfigFormat::from_path(path), ConfigFormat::Json)
1139                && reason.contains("unknown field")
1140                && (reason.contains("expected `files` or `rules`")
1141                    || reason.contains("expected `file` or `exports`"))
1142            {
1143                " (annotations belong in a // comment; .fallowrc.json accepts JSONC)"
1144            } else {
1145                ""
1146            };
1147            miette::miette!("{reason} in {}{hint}", path.display())
1148        })?;
1149        config.rules.private_type_leaks_configured = private_type_leaks_configured;
1150
1151        config.validate_user_globs().map_err(|errors| {
1152            let joined = errors
1153                .iter()
1154                .map(ToString::to_string)
1155                .collect::<Vec<_>>()
1156                .join("\n  - ");
1157            miette::miette!("invalid config:\n  - {}", joined)
1158        })?;
1159        if !config.security.request_receivers_are_valid() {
1160            return Err(miette::miette!(
1161                "invalid config:\n  - security.requestReceivers entries must be non-empty strings"
1162            ));
1163        }
1164        let threshold_override_errors = config.health.threshold_override_errors();
1165        if !threshold_override_errors.is_empty() {
1166            return Err(miette::miette!(
1167                "invalid config:\n  - {}",
1168                threshold_override_errors.join("\n  - ")
1169            ));
1170        }
1171        if let Some(pattern) = &config.unused_component_props.ignore_pattern
1172            && let Err(e) = regex::Regex::new(pattern)
1173        {
1174            return Err(miette::miette!(
1175                "invalid config:\n  - unusedComponentProps.ignorePattern is not a valid regex: {e}"
1176            ));
1177        }
1178
1179        Ok(config)
1180    }
1181
1182    /// Validate all user-supplied glob patterns and directory paths in this config.
1183    ///
1184    /// Accumulates errors from every glob- or path-bearing field so the user
1185    /// sees ALL offending values in one run rather than fixing them one at a
1186    /// time.
1187    ///
1188    /// Covered filesystem glob fields: `entry`, `ignorePatterns`, `ignoreFindings`,
1189    /// `dynamicallyLoaded`, `duplicates.ignore`, `similarCode.ignore`, `health.ignore`,
1190    /// `health.thresholdOverrides[].files`, `overrides[].files`, `ignoreExports[].file`,
1191    /// `ignoreCatalogReferences[].consumer`, `boundaries.zones[].patterns`,
1192    /// `boundaries.coverage.allowUnmatched`,
1193    /// plus every glob-bearing field on inline `framework[]` plugin
1194    /// definitions (entry points, always-used, config patterns, used-exports
1195    /// patterns, and `fileExists` detection patterns; the last reaches
1196    /// `glob::glob` on disk so a `..` segment there is a real path traversal).
1197    ///
1198    /// Covered specifier glob fields: `ignoreUnresolvedImports`. These match
1199    /// raw import strings, so parent-relative specifiers like `../generated/**`
1200    /// are valid and only glob syntax is checked.
1201    ///
1202    /// Covered directory-path fields: `boundaries.zones[].root` and
1203    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
1204    /// globs), so only the absolute-path + traversal checks apply.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns a non-empty `Vec` of
1209    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
1210    /// when any field contains a rejected value.
1211    pub fn validate_user_globs(
1212        &self,
1213    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
1214        let mut errors = Vec::new();
1215
1216        self.validate_top_level_globs(&mut errors);
1217        self.validate_ignore_rule_globs(&mut errors);
1218        self.validate_boundary_globs(&mut errors);
1219
1220        for plugin in &self.framework {
1221            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
1222                errors.append(&mut plugin_errors);
1223            }
1224        }
1225
1226        if errors.is_empty() {
1227            Ok(())
1228        } else {
1229            Err(errors)
1230        }
1231    }
1232
1233    /// Validate the top-level filesystem and specifier glob fields plus the
1234    /// per-override and threshold-override file globs.
1235    fn validate_top_level_globs(
1236        &self,
1237        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1238    ) {
1239        use super::glob_validation::{
1240            validate_user_finding_ignore_globs, validate_user_globs, validate_user_specifier_globs,
1241        };
1242
1243        validate_user_globs(&self.entry, "entry", errors);
1244        validate_user_globs(&self.ignore_patterns, "ignorePatterns", errors);
1245        validate_user_finding_ignore_globs(&self.ignore_findings, "ignoreFindings", errors);
1246        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
1247        validate_user_specifier_globs(
1248            &self.ignore_unresolved_imports,
1249            "ignoreUnresolvedImports",
1250            errors,
1251        );
1252        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1253        validate_user_globs(&self.similar_code.ignore, "similarCode.ignore", errors);
1254        validate_user_globs(&self.health.ignore, "health.ignore", errors);
1255        for override_entry in &self.health.threshold_overrides {
1256            validate_user_globs(
1257                &override_entry.files,
1258                "health.thresholdOverrides[].files",
1259                errors,
1260            );
1261        }
1262        for override_entry in &self.overrides {
1263            validate_user_globs(&override_entry.files, "overrides[].files", errors);
1264        }
1265    }
1266
1267    /// Validate the `ignoreExports` and `ignoreCatalogReferences` rule globs.
1268    fn validate_ignore_rule_globs(
1269        &self,
1270        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1271    ) {
1272        use super::glob_validation::compile_user_glob;
1273
1274        for rule in &self.ignore_exports {
1275            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1276                errors.push(e);
1277            }
1278        }
1279
1280        for rule in &self.ignore_catalog_references {
1281            if let Some(consumer) = &rule.consumer
1282                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1283            {
1284                errors.push(e);
1285            }
1286        }
1287    }
1288
1289    /// Validate the `boundaries.zones[]` patterns/roots/autoDiscover and the
1290    /// coverage `allowUnmatched` globs.
1291    fn validate_boundary_globs(
1292        &self,
1293        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1294    ) {
1295        use super::glob_validation::{
1296            validate_user_globs, validate_user_path, validate_user_paths,
1297        };
1298
1299        for zone in &self.boundaries.zones {
1300            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1301            if let Some(root) = &zone.root
1302                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1303            {
1304                errors.push(e);
1305            }
1306            validate_user_paths(
1307                &zone.auto_discover,
1308                "boundaries.zones[].autoDiscover",
1309                errors,
1310            );
1311        }
1312        validate_user_globs(
1313            &self.boundaries.coverage.allow_unmatched,
1314            "boundaries.coverage.allowUnmatched",
1315            errors,
1316        );
1317    }
1318
1319    /// Find the config file path without loading it.
1320    /// Searches the same locations as `find_and_load`.
1321    #[must_use]
1322    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1323        let mut dir = start;
1324        loop {
1325            for name in CONFIG_NAMES {
1326                let candidate = dir.join(name);
1327                if candidate.exists() {
1328                    return Some(candidate);
1329                }
1330            }
1331            if is_repo_root(dir) {
1332                break;
1333            }
1334            dir = dir.parent()?;
1335        }
1336        None
1337    }
1338
1339    /// Find and load config, searching from `start` up to the project root.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns an error if a config file is found but cannot be read or parsed.
1344    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1345        Self::find_and_load_with_options(start, ConfigLoadOptions::default())
1346    }
1347
1348    /// Find and load config with a host-controlled inheritance trust policy.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns an error if a config file is found but cannot be read, parsed,
1353    /// or is not permitted by `options`.
1354    pub fn find_and_load_with_options(
1355        start: &Path,
1356        options: ConfigLoadOptions,
1357    ) -> Result<Option<(Self, PathBuf)>, String> {
1358        let mut dir = start;
1359        loop {
1360            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1361                let candidate = dir.join(name);
1362                if candidate.exists() {
1363                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1364                    match Self::load_with_options(&candidate, options) {
1365                        Ok(config) => return Ok(Some((config, candidate))),
1366                        Err(e) => {
1367                            // Most load errors already name the config file;
1368                            // add the path only when the reason lacks it, so
1369                            // it is mentioned exactly once.
1370                            let msg = e.to_string();
1371                            return Err(if msg.contains(&candidate.display().to_string()) {
1372                                msg
1373                            } else {
1374                                format!("Failed to parse {}: {msg}", candidate.display())
1375                            });
1376                        }
1377                    }
1378                }
1379            }
1380            if is_repo_root(dir) {
1381                break;
1382            }
1383            dir = match dir.parent() {
1384                Some(parent) => parent,
1385                None => break,
1386            };
1387        }
1388        Ok(None)
1389    }
1390
1391    /// Generate JSON Schema for the configuration format.
1392    #[must_use]
1393    pub fn json_schema() -> serde_json::Value {
1394        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1395    }
1396
1397    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1398    /// preset and auto-discover expansion.
1399    ///
1400    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1401    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1402    /// before invoking
1403    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1404    /// and
1405    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1406    /// so Bulletproof-style presets whose authored rule references logical
1407    /// groups (`features`) still load cleanly.
1408    ///
1409    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1410    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1411    /// surface every collected error in a single rendered diagnostic, then
1412    /// exit with code 2. Previously these failures emitted `tracing::error!`
1413    /// and continued, producing a flood of false-positive boundary violations
1414    /// at analysis time (#468).
1415    ///
1416    /// `root` is the project root used by `expand_auto_discover` to scan for
1417    /// child directories. Caller is responsible for passing the same root it
1418    /// later hands to `resolve()`.
1419    ///
1420    /// # Errors
1421    ///
1422    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1423    /// offending zone reference and redundant-root-prefix pattern; the empty
1424    /// case becomes `Ok(())`.
1425    pub fn validate_resolved_boundaries(
1426        &self,
1427        root: &Path,
1428    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1429        use super::boundaries::ZoneValidationError;
1430
1431        let mut boundaries = self.boundaries.clone();
1432        if boundaries.preset.is_some() {
1433            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1434                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1435                .unwrap_or_else(|| "src".to_owned());
1436            boundaries.expand(&source_root);
1437        }
1438        let _logical_groups = boundaries.expand_auto_discover(root);
1439
1440        let mut errors: Vec<ZoneValidationError> = boundaries
1441            .validate_zone_references()
1442            .into_iter()
1443            .map(ZoneValidationError::UnknownZoneReference)
1444            .collect();
1445        errors.extend(
1446            boundaries
1447                .validate_root_prefixes()
1448                .into_iter()
1449                .map(ZoneValidationError::RedundantRootPrefix),
1450        );
1451        errors.extend(
1452            boundaries
1453                .validate_call_rules()
1454                .into_iter()
1455                .map(ZoneValidationError::InvalidForbiddenCallee),
1456        );
1457
1458        if errors.is_empty() {
1459            Ok(())
1460        } else {
1461            Err(errors)
1462        }
1463    }
1464}
1465
1466#[cfg(test)]
1467mod tests {
1468    use super::*;
1469    use crate::CacheConfig;
1470    use crate::PackageJson;
1471    use crate::config::format::OutputFormat;
1472    use crate::config::rules::Severity;
1473
1474    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1475    fn test_dir(_name: &str) -> tempfile::TempDir {
1476        tempfile::tempdir().expect("create temp dir")
1477    }
1478
1479    #[derive(Default)]
1480    struct MockRemoteFetcher {
1481        responses: rustc_hash::FxHashMap<String, serde_json::Value>,
1482        requests: Vec<String>,
1483    }
1484
1485    impl MockRemoteFetcher {
1486        fn with_response(mut self, url: &str, value: serde_json::Value) -> Self {
1487            self.responses.insert(url.to_string(), value);
1488            self
1489        }
1490    }
1491
1492    impl RemoteConfigFetcher for MockRemoteFetcher {
1493        fn fetch(&mut self, url: &str, _source: &str) -> Result<serde_json::Value, miette::Report> {
1494            self.requests.push(url.to_string());
1495            self.responses
1496                .get(url)
1497                .cloned()
1498                .ok_or_else(|| miette::miette!("missing mock response for {url}"))
1499        }
1500    }
1501
1502    #[test]
1503    fn fallow_config_deserialize_minimal() {
1504        let toml_str = r#"
1505entry = ["src/main.ts"]
1506"#;
1507        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1508        assert_eq!(config.entry, vec!["src/main.ts"]);
1509        assert!(config.ignore_patterns.is_empty());
1510    }
1511
1512    #[test]
1513    fn fallow_config_deserialize_ignore_exports() {
1514        let toml_str = r#"
1515[[ignoreExports]]
1516file = "src/types/*.ts"
1517exports = ["*"]
1518
1519[[ignoreExports]]
1520file = "src/constants.ts"
1521exports = ["FOO", "BAR"]
1522"#;
1523        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1524        assert_eq!(config.ignore_exports.len(), 2);
1525        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1526        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1527        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1528    }
1529
1530    #[test]
1531    fn fallow_config_deserialize_ignore_dependencies() {
1532        let toml_str = r#"
1533ignoreDependencies = ["autoprefixer", "postcss"]
1534"#;
1535        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1536        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1537    }
1538
1539    #[test]
1540    fn fallow_config_deserialize_ignore_unresolved_imports() {
1541        let toml_str = r#"
1542ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1543"#;
1544        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1545        assert_eq!(
1546            config.ignore_unresolved_imports,
1547            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1548        );
1549    }
1550
1551    #[test]
1552    fn fallow_config_resolve_default_ignores() {
1553        let config = FallowConfig::default();
1554        let resolved = config.resolve(
1555            PathBuf::from("/tmp/test"),
1556            OutputFormat::Human,
1557            4,
1558            true,
1559            true,
1560            None,
1561        );
1562
1563        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1564        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1565        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1566        assert!(resolved.ignore_patterns.is_match(".git/config"));
1567        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1568        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1569        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1570    }
1571
1572    #[test]
1573    fn fallow_config_resolve_custom_ignores() {
1574        let config = FallowConfig {
1575            entry: vec!["src/**/*.ts".to_string()],
1576            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1577            ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
1578            ..Default::default()
1579        };
1580        let resolved = config.resolve(
1581            PathBuf::from("/tmp/test"),
1582            OutputFormat::Json,
1583            4,
1584            false,
1585            true,
1586            None,
1587        );
1588
1589        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1590        assert!(
1591            resolved
1592                .ignore_findings
1593                .is_ignored("src/private/app.test.ts")
1594        );
1595        assert!(
1596            !resolved
1597                .ignore_findings
1598                .is_ignored("src/public/app.test.ts")
1599        );
1600        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1601        assert!(matches!(resolved.output, OutputFormat::Json));
1602        assert!(!resolved.no_cache);
1603    }
1604
1605    #[test]
1606    fn fallow_config_resolve_cache_dir() {
1607        let config = FallowConfig::default();
1608        let resolved = config.resolve(
1609            PathBuf::from("/tmp/project"),
1610            OutputFormat::Human,
1611            4,
1612            true,
1613            true,
1614            None,
1615        );
1616        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1617        assert!(resolved.no_cache);
1618    }
1619
1620    #[test]
1621    fn package_json_entry_points_main() {
1622        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1623        let entries = pkg.entry_points();
1624        assert!(entries.contains(&"dist/index.js".to_string()));
1625    }
1626
1627    #[test]
1628    fn package_json_entry_points_module() {
1629        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1630        let entries = pkg.entry_points();
1631        assert!(entries.contains(&"dist/index.mjs".to_string()));
1632    }
1633
1634    #[test]
1635    fn package_json_entry_points_types() {
1636        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1637        let entries = pkg.entry_points();
1638        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1639    }
1640
1641    #[test]
1642    fn package_json_entry_points_bin_string() {
1643        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1644        let entries = pkg.entry_points();
1645        assert!(entries.contains(&"bin/cli.js".to_string()));
1646    }
1647
1648    #[test]
1649    fn package_json_entry_points_bin_object() {
1650        let pkg: PackageJson =
1651            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1652                .unwrap();
1653        let entries = pkg.entry_points();
1654        assert!(entries.contains(&"bin/cli.js".to_string()));
1655        assert!(entries.contains(&"bin/serve.js".to_string()));
1656    }
1657
1658    #[test]
1659    fn package_json_entry_points_exports_string() {
1660        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1661        let entries = pkg.entry_points();
1662        assert!(entries.contains(&"./dist/index.js".to_string()));
1663    }
1664
1665    #[test]
1666    fn package_json_entry_points_exports_object() {
1667        let pkg: PackageJson = serde_json::from_str(
1668            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1669        )
1670        .unwrap();
1671        let entries = pkg.entry_points();
1672        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1673        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1674    }
1675
1676    #[test]
1677    fn package_json_dependency_names() {
1678        let pkg: PackageJson = serde_json::from_str(
1679            r#"{
1680            "dependencies": {"react": "^18", "lodash": "^4"},
1681            "devDependencies": {"typescript": "^5"},
1682            "peerDependencies": {"react-dom": "^18"}
1683        }"#,
1684        )
1685        .unwrap();
1686
1687        let all = pkg.all_dependency_names();
1688        assert!(all.contains(&"react".to_string()));
1689        assert!(all.contains(&"lodash".to_string()));
1690        assert!(all.contains(&"typescript".to_string()));
1691        assert!(all.contains(&"react-dom".to_string()));
1692
1693        let prod = pkg.production_dependency_names();
1694        assert!(prod.contains(&"react".to_string()));
1695        assert!(!prod.contains(&"typescript".to_string()));
1696
1697        let dev = pkg.dev_dependency_names();
1698        assert!(dev.contains(&"typescript".to_string()));
1699        assert!(!dev.contains(&"react".to_string()));
1700    }
1701
1702    #[test]
1703    fn package_json_no_dependencies() {
1704        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1705        assert!(pkg.all_dependency_names().is_empty());
1706        assert!(pkg.production_dependency_names().is_empty());
1707        assert!(pkg.dev_dependency_names().is_empty());
1708        assert!(pkg.entry_points().is_empty());
1709    }
1710
1711    #[test]
1712    fn rules_deserialize_toml_kebab_case() {
1713        let toml_str = r#"
1714[rules]
1715unused-files = "error"
1716unused-exports = "warn"
1717unused-types = "off"
1718"#;
1719        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1720        assert_eq!(config.rules.unused_files, Severity::Error);
1721        assert_eq!(config.rules.unused_exports, Severity::Warn);
1722        assert_eq!(config.rules.unused_types, Severity::Off);
1723        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1724    }
1725
1726    #[test]
1727    fn config_without_rules_defaults_to_error() {
1728        let toml_str = r#"
1729entry = ["src/main.ts"]
1730"#;
1731        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1732        assert_eq!(config.rules.unused_files, Severity::Error);
1733        assert_eq!(config.rules.unused_exports, Severity::Error);
1734    }
1735
1736    #[test]
1737    fn fallow_config_denies_unknown_fields() {
1738        let toml_str = r"
1739unknown_field = true
1740";
1741        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1742        assert!(result.is_err());
1743    }
1744
1745    #[test]
1746    fn fallow_config_deserialize_json() {
1747        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1748        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1749        assert_eq!(config.entry, vec!["src/main.ts"]);
1750    }
1751
1752    #[test]
1753    fn fallow_config_deserialize_jsonc() {
1754        let jsonc_str = r#"{
1755            "entry": ["src/main.ts"],
1756            "rules": {
1757                "unused-files": "warn"
1758            }
1759        }"#;
1760        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1761        assert_eq!(config.entry, vec!["src/main.ts"]);
1762        assert_eq!(config.rules.unused_files, Severity::Warn);
1763    }
1764
1765    #[test]
1766    fn fallow_config_json_with_schema_field() {
1767        let json_str =
1768            r#"{"$schema": "./node_modules/fallow/schema.json", "entry": ["src/main.ts"]}"#;
1769        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1770        assert_eq!(config.entry, vec!["src/main.ts"]);
1771    }
1772
1773    #[test]
1774    fn fallow_config_json_schema_generation() {
1775        let schema = FallowConfig::json_schema();
1776        assert!(schema.is_object());
1777        let obj = schema.as_object().unwrap();
1778        assert!(obj.contains_key("properties"));
1779    }
1780
1781    #[test]
1782    fn config_format_detection() {
1783        assert!(matches!(
1784            ConfigFormat::from_path(Path::new("fallow.toml")),
1785            ConfigFormat::Toml
1786        ));
1787        assert!(matches!(
1788            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1789            ConfigFormat::Json
1790        ));
1791        assert!(matches!(
1792            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1793            ConfigFormat::Json
1794        ));
1795        assert!(matches!(
1796            ConfigFormat::from_path(Path::new(".fallow.toml")),
1797            ConfigFormat::Toml
1798        ));
1799    }
1800
1801    #[test]
1802    fn config_names_priority_order() {
1803        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1804        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1805        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1806        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1807    }
1808
1809    #[test]
1810    fn load_json_config_file() {
1811        let dir = test_dir("json-config");
1812        let config_path = dir.path().join(".fallowrc.json");
1813        std::fs::write(
1814            &config_path,
1815            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1816        )
1817        .unwrap();
1818
1819        let config = FallowConfig::load(&config_path).unwrap();
1820        assert_eq!(config.entry, vec!["src/index.ts"]);
1821        assert_eq!(config.rules.unused_exports, Severity::Warn);
1822    }
1823
1824    #[test]
1825    fn load_records_explicit_private_type_leaks_setting() {
1826        let dir = test_dir("explicit-private-type-leaks");
1827        let config_path = dir.path().join(".fallowrc.json");
1828        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
1829
1830        let config = FallowConfig::load(&config_path).unwrap();
1831        assert_eq!(config.rules.private_type_leaks, Severity::Off);
1832        assert!(config.rules.private_type_leaks_configured);
1833    }
1834
1835    #[test]
1836    fn load_records_explicit_private_type_leaks_singular_alias() {
1837        let dir = test_dir("explicit-private-type-leak-alias");
1838        let config_path = dir.path().join(".fallowrc.json");
1839        std::fs::write(&config_path, r#"{"rules": {"private-type-leak": "off"}}"#).unwrap();
1840
1841        let config = FallowConfig::load(&config_path).unwrap();
1842        assert!(config.rules.private_type_leaks_configured);
1843    }
1844
1845    #[test]
1846    fn load_leaves_defaulted_private_type_leaks_unmarked() {
1847        let dir = test_dir("defaulted-private-type-leaks");
1848        let config_path = dir.path().join(".fallowrc.json");
1849        std::fs::write(&config_path, r#"{"rules": {"unused-exports": "warn"}}"#).unwrap();
1850
1851        let config = FallowConfig::load(&config_path).unwrap();
1852        assert_eq!(config.rules.private_type_leaks, Severity::Off);
1853        assert!(!config.rules.private_type_leaks_configured);
1854    }
1855
1856    /// The explicit-configuration flag is `serde(skip)`, so a serde round-trip
1857    /// of a loaded config drops it and the type-aware `warn` default would
1858    /// force the rule back on. No production path round-trips a
1859    /// [`FallowConfig`] today; this test pins the hazard so introducing one
1860    /// fails loudly instead of silently re-enabling an explicitly-off rule.
1861    #[test]
1862    fn serde_round_trip_drops_explicit_private_type_leaks_flag() {
1863        let dir = test_dir("round-trip-private-type-leaks");
1864        let config_path = dir.path().join(".fallowrc.json");
1865        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
1866
1867        let config = FallowConfig::load(&config_path).unwrap();
1868        assert!(config.rules.private_type_leaks_configured);
1869
1870        let serialized = serde_json::to_value(&config).unwrap();
1871        let round_tripped: FallowConfig = serde_json::from_value(serialized).unwrap();
1872
1873        assert_eq!(round_tripped.rules.private_type_leaks, Severity::Off);
1874        assert!(
1875            !round_tripped.rules.private_type_leaks_configured,
1876            "a serde round-trip drops the serde(skip) flag; any code path that \
1877             round-trips a loaded FallowConfig must re-record it (see the field \
1878             docs on RulesConfig::private_type_leaks_configured)"
1879        );
1880    }
1881
1882    #[test]
1883    fn load_json_config_file_with_health_threshold_override() {
1884        let dir = test_dir("json-health-threshold-override");
1885        let config_path = dir.path().join(".fallowrc.json");
1886        std::fs::write(
1887            &config_path,
1888            r#"{
1889                "health": {
1890                    "thresholdOverrides": [
1891                        {
1892                            "files": ["src/legacy.ts"],
1893                            "functions": ["legacyFlow"],
1894                            "maxCyclomatic": 30,
1895                            "maxCognitive": 25,
1896                            "maxCrap": 80.5,
1897                            "reason": "legacy migration"
1898                        }
1899                    ]
1900                }
1901            }"#,
1902        )
1903        .unwrap();
1904
1905        let config = FallowConfig::load(&config_path).unwrap();
1906        let override_config = &config.health.threshold_overrides[0];
1907        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1908        assert_eq!(override_config.functions, vec!["legacyFlow"]);
1909        assert_eq!(override_config.max_cyclomatic, Some(30));
1910        assert_eq!(override_config.max_cognitive, Some(25));
1911        assert_eq!(override_config.max_crap, Some(80.5));
1912        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1913    }
1914
1915    #[test]
1916    fn load_jsonc_config_file() {
1917        let dir = test_dir("jsonc-config");
1918        let config_path = dir.path().join(".fallowrc.json");
1919        std::fs::write(
1920            &config_path,
1921            r#"{
1922                "entry": ["src/index.ts"],
1923                /* Block comment */
1924                "rules": {
1925                    "unused-exports": "warn"
1926                }
1927            }"#,
1928        )
1929        .unwrap();
1930
1931        let config = FallowConfig::load(&config_path).unwrap();
1932        assert_eq!(config.entry, vec!["src/index.ts"]);
1933        assert_eq!(config.rules.unused_exports, Severity::Warn);
1934    }
1935
1936    #[test]
1937    fn load_jsonc_config_file_with_health_threshold_override() {
1938        let dir = test_dir("jsonc-health-threshold-override");
1939        let config_path = dir.path().join(".fallowrc.jsonc");
1940        std::fs::write(
1941            &config_path,
1942            r#"{
1943                "health": {
1944                    // Empty functions means every function in matching files.
1945                    "thresholdOverrides": [
1946                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1947                    ]
1948                }
1949            }"#,
1950        )
1951        .unwrap();
1952
1953        let config = FallowConfig::load(&config_path).unwrap();
1954        let override_config = &config.health.threshold_overrides[0];
1955        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1956        assert!(override_config.functions.is_empty());
1957        assert_eq!(override_config.max_cognitive, Some(25));
1958    }
1959
1960    #[test]
1961    fn load_fallowrc_jsonc_extension() {
1962        let dir = test_dir("jsonc-extension");
1963        let config_path = dir.path().join(".fallowrc.jsonc");
1964        std::fs::write(
1965            &config_path,
1966            r#"{
1967                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1968                "entry": ["src/index.ts"]
1969            }"#,
1970        )
1971        .unwrap();
1972
1973        let config = FallowConfig::load(&config_path).unwrap();
1974        assert_eq!(config.entry, vec!["src/index.ts"]);
1975        assert_eq!(
1976            config.ignore_dependencies,
1977            vec!["tailwindcss-react-aria-components"]
1978        );
1979    }
1980
1981    #[test]
1982    fn json_config_ignore_dependencies_camel_case() {
1983        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1984        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1985        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1986    }
1987
1988    #[test]
1989    fn json_config_ignore_unresolved_imports_camel_case() {
1990        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1991        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1992        assert_eq!(
1993            config.ignore_unresolved_imports,
1994            vec!["@example/icons", "@example/icons/**"]
1995        );
1996    }
1997
1998    #[test]
1999    fn json_config_all_fields() {
2000        let json_str = r#"{
2001            "ignoreDependencies": ["lodash"],
2002            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
2003            "rules": {
2004                "unused-files": "off",
2005                "unused-exports": "warn",
2006                "unused-dependencies": "error",
2007                "unused-dev-dependencies": "off",
2008                "unused-types": "warn",
2009                "unused-enum-members": "error",
2010                "unused-class-members": "off",
2011                "unresolved-imports": "warn",
2012                "unlisted-dependencies": "error",
2013                "duplicate-exports": "off"
2014            },
2015            "duplicates": {
2016                "minTokens": 100,
2017                "minLines": 10,
2018                "skipLocal": true
2019            }
2020        }"#;
2021        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2022        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
2023        assert_eq!(config.rules.unused_files, Severity::Off);
2024        assert_eq!(config.rules.unused_exports, Severity::Warn);
2025        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2026        assert_eq!(config.duplicates.min_tokens, 100);
2027        assert_eq!(config.duplicates.min_lines, 10);
2028        assert!(config.duplicates.skip_local);
2029    }
2030
2031    #[test]
2032    fn extends_single_base() {
2033        let dir = test_dir("extends-single");
2034
2035        std::fs::write(
2036            dir.path().join("base.json"),
2037            r#"{"rules": {"unused-files": "warn"}}"#,
2038        )
2039        .unwrap();
2040        std::fs::write(
2041            dir.path().join(".fallowrc.json"),
2042            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
2043        )
2044        .unwrap();
2045
2046        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2047        assert_eq!(config.rules.unused_files, Severity::Warn);
2048        assert_eq!(config.entry, vec!["src/index.ts"]);
2049        assert_eq!(config.rules.unused_exports, Severity::Error);
2050    }
2051
2052    #[test]
2053    fn extends_overlay_overrides_base() {
2054        let dir = test_dir("extends-overlay");
2055
2056        std::fs::write(
2057            dir.path().join("base.json"),
2058            r#"{"ignoreFindings": ["generated/**"], "rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
2059        )
2060        .unwrap();
2061        std::fs::write(
2062            dir.path().join(".fallowrc.json"),
2063            r#"{"extends": ["base.json"], "ignoreFindings": ["**/*.test.ts"], "rules": {"unused-files": "error"}}"#,
2064        )
2065        .unwrap();
2066
2067        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2068        assert_eq!(config.ignore_findings, vec!["**/*.test.ts"]);
2069        assert_eq!(config.rules.unused_files, Severity::Error);
2070        assert_eq!(config.rules.unused_exports, Severity::Off);
2071    }
2072
2073    #[test]
2074    fn extends_chained() {
2075        let dir = test_dir("extends-chained");
2076
2077        std::fs::write(
2078            dir.path().join("grandparent.json"),
2079            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
2080        )
2081        .unwrap();
2082        std::fs::write(
2083            dir.path().join("parent.json"),
2084            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
2085        )
2086        .unwrap();
2087        std::fs::write(
2088            dir.path().join(".fallowrc.json"),
2089            r#"{"extends": ["parent.json"]}"#,
2090        )
2091        .unwrap();
2092
2093        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2094        assert_eq!(config.rules.unused_files, Severity::Warn);
2095        assert_eq!(config.rules.unused_exports, Severity::Warn);
2096    }
2097
2098    #[test]
2099    fn extends_local_diamond_reuses_resolved_base() {
2100        let dir = test_dir("extends-local-diamond");
2101        std::fs::write(
2102            dir.path().join("base.json"),
2103            r#"{"ignorePatterns": ["generated/**"]}"#,
2104        )
2105        .unwrap();
2106        std::fs::write(
2107            dir.path().join("left.json"),
2108            r#"{"extends": ["base.json"], "rules": {"unused-files": "warn"}}"#,
2109        )
2110        .unwrap();
2111        std::fs::write(
2112            dir.path().join("right.json"),
2113            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2114        )
2115        .unwrap();
2116        std::fs::write(
2117            dir.path().join(".fallowrc.json"),
2118            r#"{"extends": ["left.json", "right.json"]}"#,
2119        )
2120        .unwrap();
2121
2122        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2123        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2124        assert_eq!(config.rules.unused_files, Severity::Warn);
2125        assert_eq!(config.rules.unused_exports, Severity::Off);
2126    }
2127
2128    #[test]
2129    fn extends_circular_detected() {
2130        let dir = test_dir("extends-circular");
2131
2132        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
2133        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
2134
2135        let result = FallowConfig::load(&dir.path().join("a.json"));
2136        assert!(result.is_err());
2137        let err_msg = format!("{}", result.unwrap_err());
2138        assert!(
2139            err_msg.contains("Circular extends"),
2140            "Expected circular error, got: {err_msg}"
2141        );
2142    }
2143
2144    #[test]
2145    fn remote_extends_are_denied_before_fetch_by_default() {
2146        let dir = test_dir("remote-default-denied");
2147        let url = "https://config-user:config-password@config.example:8443/base.json?token=config-token#config-fragment";
2148        std::fs::write(
2149            dir.path().join(".fallowrc.json"),
2150            format!(r#"{{"extends": "{url}"}}"#),
2151        )
2152        .unwrap();
2153        let mut fetcher = MockRemoteFetcher::default().with_response(url, serde_json::json!({}));
2154
2155        let error = load_with_fetcher(
2156            &dir.path().join(".fallowrc.json"),
2157            ConfigLoadOptions::default(),
2158            &mut fetcher,
2159        )
2160        .unwrap_err()
2161        .to_string();
2162
2163        assert!(
2164            error.contains("https://config.example:8443/base.json"),
2165            "denial must name the URL without secrets"
2166        );
2167        for secret in [
2168            "config-user",
2169            "config-password",
2170            "config-token",
2171            "config-fragment",
2172        ] {
2173            assert!(
2174                !error.contains(secret),
2175                "denial must not expose a secret value"
2176            );
2177        }
2178        assert!(
2179            error.contains("--allow-remote-extends"),
2180            "denial must name the CLI opt-in"
2181        );
2182        assert!(
2183            error.contains("ConfigLoadOptions"),
2184            "denial must name the library opt-in"
2185        );
2186        assert!(
2187            fetcher.requests.is_empty(),
2188            "denial must happen before fetch"
2189        );
2190    }
2191
2192    #[test]
2193    fn remote_parent_and_requested_url_secrets_are_redacted_in_errors() {
2194        let dir = test_dir("remote-error-redaction");
2195        let parent = "https://parent-user:parent-password@[2001:db8::1]:8443/base.json?token=parent-token#parent-fragment";
2196        let requested = "http://child-user:child-password@child.example/child.json?token=child-token#child-fragment";
2197        std::fs::write(
2198            dir.path().join(".fallowrc.json"),
2199            format!(r#"{{"extends": "{parent}"}}"#),
2200        )
2201        .unwrap();
2202        let mut fetcher = MockRemoteFetcher::default()
2203            .with_response(parent, serde_json::json!({"extends": requested}));
2204
2205        let error = load_with_fetcher(
2206            &dir.path().join(".fallowrc.json"),
2207            ConfigLoadOptions {
2208                allow_remote_extends: true,
2209            },
2210            &mut fetcher,
2211        )
2212        .unwrap_err()
2213        .to_string();
2214
2215        assert!(
2216            error.contains("http://child.example/child.json"),
2217            "error must preserve the requested host and path"
2218        );
2219        assert!(
2220            error.contains("https://[2001:db8::1]:8443/base.json"),
2221            "error must preserve the remote parent host, port, and path"
2222        );
2223        for secret in [
2224            "parent-user",
2225            "parent-password",
2226            "parent-token",
2227            "parent-fragment",
2228            "child-user",
2229            "child-password",
2230            "child-token",
2231            "child-fragment",
2232        ] {
2233            assert!(
2234                !error.contains(secret),
2235                "remote error must not expose a secret value"
2236            );
2237        }
2238    }
2239
2240    #[test]
2241    fn remote_fetch_network_error_redacts_requested_url_and_source() {
2242        let url = "https://request-user:request-password@127.0.0.1:0/config.json?token=request-token#request-fragment";
2243        let source = "https://source-user:source-password@source.example/parent.json?token=source-token#source-fragment";
2244
2245        let error = fetch_url_config(url, source)
2246            .expect_err("the reserved local port must reject the request")
2247            .to_string();
2248
2249        assert!(
2250            error.contains("https://127.0.0.1:0/config.json"),
2251            "network error must preserve the requested host, port, and path"
2252        );
2253        assert!(
2254            error.contains("https://source.example/parent.json"),
2255            "network error must preserve the source host and path"
2256        );
2257        for secret in [
2258            "request-user",
2259            "request-password",
2260            "request-token",
2261            "request-fragment",
2262            "source-user",
2263            "source-password",
2264            "source-token",
2265            "source-fragment",
2266        ] {
2267            assert!(
2268                !error.contains(secret),
2269                "network error must not expose a secret value"
2270            );
2271        }
2272    }
2273
2274    #[test]
2275    fn remote_fetch_error_detail_does_not_trust_normalized_urls() {
2276        let url = "https://request-user:request-password@config.example/config.json?token=request-token#request-fragment";
2277        let normalized_error = ureq::Error::BadUri(
2278            "https://request-user:request-password@config.example/config.json?token=request-token"
2279                .to_string(),
2280        );
2281
2282        assert!(
2283            remote_fetch_error_display(&normalized_error, url) == "request failed",
2284            "normalized network errors must not bypass URL redaction"
2285        );
2286    }
2287
2288    #[test]
2289    fn remote_fetch_error_detail_does_not_trust_redirect_like_payloads() {
2290        let original = "https://config.example/config.json";
2291        let error = ureq::Error::BadUri(
2292            "http://redirect-user:redirect-password@example.com/next.json?token=secret#anchor"
2293                .to_string(),
2294        );
2295
2296        let display = remote_fetch_error_display(&error, original);
2297        assert_eq!(display, "request failed");
2298        for secret in [
2299            "redirect-user",
2300            "redirect-password",
2301            "token=secret",
2302            "anchor",
2303        ] {
2304            assert!(
2305                !display.contains(secret),
2306                "untrusted error detail must be hidden"
2307            );
2308        }
2309    }
2310
2311    #[test]
2312    fn remote_extends_dispatch_when_explicitly_allowed() {
2313        let dir = test_dir("remote-explicitly-allowed");
2314        let url = "https://config.example/base.json";
2315        std::fs::write(
2316            dir.path().join(".fallowrc.json"),
2317            format!(r#"{{"extends": "{url}"}}"#),
2318        )
2319        .unwrap();
2320        let mut fetcher = MockRemoteFetcher::default()
2321            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2322
2323        let config = load_with_fetcher(
2324            &dir.path().join(".fallowrc.json"),
2325            ConfigLoadOptions {
2326                allow_remote_extends: true,
2327            },
2328            &mut fetcher,
2329        )
2330        .unwrap();
2331
2332        assert_eq!(config.rules.unused_files, Severity::Warn);
2333        assert_eq!(fetcher.requests, vec![url]);
2334    }
2335
2336    #[test]
2337    fn uppercase_https_remote_extends_dispatch_when_allowed() {
2338        let dir = test_dir("remote-uppercase-scheme");
2339        let url = "HTTPS://config.example/base.json";
2340        std::fs::write(
2341            dir.path().join(".fallowrc.json"),
2342            format!(r#"{{"extends": "{url}"}}"#),
2343        )
2344        .unwrap();
2345        let mut fetcher = MockRemoteFetcher::default()
2346            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2347
2348        let config = load_with_fetcher(
2349            &dir.path().join(".fallowrc.json"),
2350            ConfigLoadOptions {
2351                allow_remote_extends: true,
2352            },
2353            &mut fetcher,
2354        )
2355        .unwrap();
2356
2357        assert_eq!(config.rules.unused_files, Severity::Warn);
2358        assert_eq!(fetcher.requests, vec![url]);
2359    }
2360
2361    #[test]
2362    fn remote_extends_diamond_reuses_mocked_base() {
2363        let dir = test_dir("remote-diamond");
2364        let left = "https://config.example/left.json";
2365        let right = "https://config.example/right.json";
2366        let base = "https://config.example/base.json";
2367        std::fs::write(
2368            dir.path().join(".fallowrc.json"),
2369            format!(r#"{{"extends": ["{left}", "{right}"]}}"#),
2370        )
2371        .unwrap();
2372        let mut fetcher = MockRemoteFetcher::default()
2373            .with_response(
2374                left,
2375                serde_json::json!({
2376                    "extends": base,
2377                    "rules": {"unused-files": "warn"}
2378                }),
2379            )
2380            .with_response(
2381                right,
2382                serde_json::json!({
2383                    "extends": base,
2384                    "rules": {"unused-exports": "off"}
2385                }),
2386            )
2387            .with_response(
2388                base,
2389                serde_json::json!({"ignorePatterns": ["generated/**"]}),
2390            );
2391
2392        let config = load_with_fetcher(
2393            &dir.path().join(".fallowrc.json"),
2394            ConfigLoadOptions {
2395                allow_remote_extends: true,
2396            },
2397            &mut fetcher,
2398        )
2399        .unwrap();
2400
2401        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2402        assert_eq!(config.rules.unused_files, Severity::Warn);
2403        assert_eq!(config.rules.unused_exports, Severity::Off);
2404        assert_eq!(
2405            fetcher
2406                .requests
2407                .iter()
2408                .filter(|request| *request == base)
2409                .count(),
2410            1,
2411            "the shared remote base should be fetched once"
2412        );
2413    }
2414
2415    #[test]
2416    fn remote_extends_active_cycle_still_fails() {
2417        let dir = test_dir("remote-cycle");
2418        let first = "https://config.example/first.json";
2419        let second = "https://config.example/second.json";
2420        std::fs::write(
2421            dir.path().join(".fallowrc.json"),
2422            format!(r#"{{"extends": "{first}"}}"#),
2423        )
2424        .unwrap();
2425        let mut fetcher = MockRemoteFetcher::default()
2426            .with_response(first, serde_json::json!({"extends": second}))
2427            .with_response(second, serde_json::json!({"extends": first}));
2428
2429        let error = load_with_fetcher(
2430            &dir.path().join(".fallowrc.json"),
2431            ConfigLoadOptions {
2432                allow_remote_extends: true,
2433            },
2434            &mut fetcher,
2435        )
2436        .unwrap_err()
2437        .to_string();
2438
2439        assert!(
2440            error.contains("Circular extends"),
2441            "unexpected error: {error}"
2442        );
2443    }
2444
2445    #[test]
2446    fn query_distinct_remote_extends_remain_distinct() {
2447        let dir = test_dir("remote-query-distinct");
2448        let first = "https://config.example/base.json?profile=one";
2449        let second = "https://config.example/base.json?profile=two";
2450        std::fs::write(
2451            dir.path().join(".fallowrc.json"),
2452            format!(r#"{{"extends": ["{first}", "{second}"]}}"#),
2453        )
2454        .unwrap();
2455        let mut fetcher = MockRemoteFetcher::default()
2456            .with_response(
2457                first,
2458                serde_json::json!({"rules": {"unused-files": "warn"}}),
2459            )
2460            .with_response(
2461                second,
2462                serde_json::json!({"rules": {"unused-exports": "off"}}),
2463            );
2464
2465        let config = load_with_fetcher(
2466            &dir.path().join(".fallowrc.json"),
2467            ConfigLoadOptions {
2468                allow_remote_extends: true,
2469            },
2470            &mut fetcher,
2471        )
2472        .unwrap();
2473
2474        assert_eq!(config.rules.unused_files, Severity::Warn);
2475        assert_eq!(config.rules.unused_exports, Severity::Off);
2476        assert_eq!(fetcher.requests, vec![first, second]);
2477    }
2478
2479    #[test]
2480    fn extends_missing_file_errors() {
2481        let dir = test_dir("extends-missing");
2482
2483        std::fs::write(
2484            dir.path().join(".fallowrc.json"),
2485            r#"{"extends": ["nonexistent.json"]}"#,
2486        )
2487        .unwrap();
2488
2489        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2490        assert!(result.is_err());
2491        let err_msg = format!("{}", result.unwrap_err());
2492        assert!(
2493            err_msg.contains("not found"),
2494            "Expected not found error, got: {err_msg}"
2495        );
2496    }
2497
2498    #[test]
2499    fn sealed_allows_in_directory_extends() {
2500        let dir = test_dir("sealed-allows-local");
2501        std::fs::write(
2502            dir.path().join("base.json"),
2503            r#"{"ignorePatterns": ["gen/**"]}"#,
2504        )
2505        .unwrap();
2506        std::fs::write(
2507            dir.path().join(".fallowrc.json"),
2508            r#"{"sealed": true, "extends": ["./base.json"]}"#,
2509        )
2510        .unwrap();
2511
2512        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2513        assert!(config.sealed);
2514        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2515    }
2516
2517    #[test]
2518    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
2519        let dir = test_dir("boundary-coverage-invalid-glob");
2520        std::fs::write(
2521            dir.path().join(".fallowrc.json"),
2522            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
2523        )
2524        .unwrap();
2525
2526        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2527        assert!(result.is_err());
2528        let err_msg = format!("{}", result.unwrap_err());
2529        assert!(
2530            err_msg.contains("boundaries.coverage.allowUnmatched"),
2531            "expected coverage field in error, got: {err_msg}"
2532        );
2533    }
2534
2535    #[test]
2536    fn sealed_rejects_extends_escaping_directory() {
2537        let dir = test_dir("sealed-rejects-escape");
2538        let sub = dir.path().join("packages").join("app");
2539        std::fs::create_dir_all(&sub).unwrap();
2540
2541        std::fs::write(
2542            dir.path().join("base.json"),
2543            r#"{"ignorePatterns": ["dist/**"]}"#,
2544        )
2545        .unwrap();
2546        std::fs::write(
2547            sub.join(".fallowrc.json"),
2548            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
2549        )
2550        .unwrap();
2551
2552        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
2553        assert!(
2554            result.is_err(),
2555            "Expected sealed config to reject escaping extends"
2556        );
2557        let err_msg = format!("{}", result.unwrap_err());
2558        assert!(
2559            err_msg.contains("sealed"),
2560            "Error must mention sealed: {err_msg}"
2561        );
2562        assert!(
2563            err_msg.contains("outside the config's directory"),
2564            "Error must explain the constraint: {err_msg}"
2565        );
2566    }
2567
2568    #[test]
2569    fn sealed_rejects_https_extends() {
2570        let dir = test_dir("sealed-rejects-https");
2571        std::fs::write(
2572            dir.path().join(".fallowrc.json"),
2573            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
2574        )
2575        .unwrap();
2576
2577        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2578        assert!(result.is_err());
2579        let err_msg = format!("{}", result.unwrap_err());
2580        assert!(
2581            err_msg.contains("sealed"),
2582            "Error must mention sealed: {err_msg}"
2583        );
2584        assert!(
2585            err_msg.contains("URL extends"),
2586            "Error must mention URL: {err_msg}"
2587        );
2588    }
2589
2590    #[test]
2591    fn sealed_rejects_npm_extends() {
2592        let dir = test_dir("sealed-rejects-npm");
2593        std::fs::write(
2594            dir.path().join(".fallowrc.json"),
2595            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
2596        )
2597        .unwrap();
2598
2599        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2600        assert!(result.is_err());
2601        let err_msg = format!("{}", result.unwrap_err());
2602        assert!(
2603            err_msg.contains("sealed"),
2604            "Error must mention sealed: {err_msg}"
2605        );
2606        assert!(
2607            err_msg.contains("npm extends"),
2608            "Error must mention npm: {err_msg}"
2609        );
2610    }
2611
2612    #[test]
2613    fn sealed_default_is_false() {
2614        let dir = test_dir("sealed-default");
2615        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
2616        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2617        assert!(!config.sealed);
2618    }
2619
2620    #[test]
2621    fn sealed_false_allows_escaping_extends() {
2622        let dir = test_dir("sealed-false-allows");
2623        let sub = dir.path().join("packages").join("app");
2624        std::fs::create_dir_all(&sub).unwrap();
2625
2626        std::fs::write(
2627            dir.path().join("base.json"),
2628            r#"{"ignorePatterns": ["dist/**"]}"#,
2629        )
2630        .unwrap();
2631        std::fs::write(
2632            sub.join(".fallowrc.json"),
2633            r#"{"extends": ["../../base.json"]}"#,
2634        )
2635        .unwrap();
2636
2637        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2638        assert!(!config.sealed);
2639        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2640    }
2641
2642    #[test]
2643    fn extends_string_sugar() {
2644        let dir = test_dir("extends-string");
2645
2646        std::fs::write(
2647            dir.path().join("base.json"),
2648            r#"{"ignorePatterns": ["gen/**"]}"#,
2649        )
2650        .unwrap();
2651        std::fs::write(
2652            dir.path().join(".fallowrc.json"),
2653            r#"{"extends": "base.json"}"#,
2654        )
2655        .unwrap();
2656
2657        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2658        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2659    }
2660
2661    #[test]
2662    fn extends_deep_merge_preserves_arrays() {
2663        let dir = test_dir("extends-array");
2664
2665        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
2666        std::fs::write(
2667            dir.path().join(".fallowrc.json"),
2668            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
2669        )
2670        .unwrap();
2671
2672        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2673        assert_eq!(config.entry, vec!["src/b.ts"]);
2674    }
2675
2676    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
2677        let pkg_dir = root.join("node_modules").join(name);
2678        std::fs::create_dir_all(&pkg_dir).unwrap();
2679        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
2680    }
2681
2682    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
2683        let pkg_dir = root.join("node_modules").join(name);
2684        std::fs::create_dir_all(&pkg_dir).unwrap();
2685        std::fs::write(
2686            pkg_dir.join("package.json"),
2687            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
2688        )
2689        .unwrap();
2690        std::fs::write(pkg_dir.join(main), config_json).unwrap();
2691    }
2692
2693    #[test]
2694    fn extends_npm_basic_unscoped() {
2695        let dir = test_dir("npm-basic");
2696        create_npm_package(
2697            dir.path(),
2698            "fallow-config-acme",
2699            r#"{"rules": {"unused-files": "warn"}}"#,
2700        );
2701        std::fs::write(
2702            dir.path().join(".fallowrc.json"),
2703            r#"{"extends": "npm:fallow-config-acme"}"#,
2704        )
2705        .unwrap();
2706
2707        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2708        assert_eq!(config.rules.unused_files, Severity::Warn);
2709    }
2710
2711    #[test]
2712    fn extends_npm_scoped_package() {
2713        let dir = test_dir("npm-scoped");
2714        create_npm_package(
2715            dir.path(),
2716            "@company/fallow-config",
2717            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
2718        );
2719        std::fs::write(
2720            dir.path().join(".fallowrc.json"),
2721            r#"{"extends": "npm:@company/fallow-config"}"#,
2722        )
2723        .unwrap();
2724
2725        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2726        assert_eq!(config.rules.unused_exports, Severity::Off);
2727        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2728    }
2729
2730    #[test]
2731    fn extends_npm_with_subpath() {
2732        let dir = test_dir("npm-subpath");
2733        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2734        std::fs::create_dir_all(&pkg_dir).unwrap();
2735        std::fs::write(
2736            pkg_dir.join("strict.json"),
2737            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2738        )
2739        .unwrap();
2740
2741        std::fs::write(
2742            dir.path().join(".fallowrc.json"),
2743            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2744        )
2745        .unwrap();
2746
2747        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2748        assert_eq!(config.rules.unused_files, Severity::Error);
2749        assert_eq!(config.rules.unused_exports, Severity::Error);
2750    }
2751
2752    #[test]
2753    fn extends_npm_package_json_main() {
2754        let dir = test_dir("npm-main");
2755        create_npm_package_with_main(
2756            dir.path(),
2757            "fallow-config-acme",
2758            "config.json",
2759            r#"{"rules": {"unused-types": "off"}}"#,
2760        );
2761        std::fs::write(
2762            dir.path().join(".fallowrc.json"),
2763            r#"{"extends": "npm:fallow-config-acme"}"#,
2764        )
2765        .unwrap();
2766
2767        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2768        assert_eq!(config.rules.unused_types, Severity::Off);
2769    }
2770
2771    #[test]
2772    fn extends_npm_package_json_exports_string() {
2773        let dir = test_dir("npm-exports-str");
2774        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2775        std::fs::create_dir_all(&pkg_dir).unwrap();
2776        std::fs::write(
2777            pkg_dir.join("package.json"),
2778            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2779        )
2780        .unwrap();
2781        std::fs::write(
2782            pkg_dir.join("base.json"),
2783            r#"{"rules": {"circular-dependencies": "warn"}}"#,
2784        )
2785        .unwrap();
2786
2787        std::fs::write(
2788            dir.path().join(".fallowrc.json"),
2789            r#"{"extends": "npm:fallow-config-co"}"#,
2790        )
2791        .unwrap();
2792
2793        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2794        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2795    }
2796
2797    #[test]
2798    fn extends_npm_package_json_exports_object() {
2799        let dir = test_dir("npm-exports-obj");
2800        let pkg_dir = dir.path().join("node_modules/@co/cfg");
2801        std::fs::create_dir_all(&pkg_dir).unwrap();
2802        std::fs::write(
2803            pkg_dir.join("package.json"),
2804            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2805        )
2806        .unwrap();
2807        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2808
2809        std::fs::write(
2810            dir.path().join(".fallowrc.json"),
2811            r#"{"extends": "npm:@co/cfg"}"#,
2812        )
2813        .unwrap();
2814
2815        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2816        assert_eq!(config.entry, vec!["src/app.ts"]);
2817    }
2818
2819    #[test]
2820    fn extends_npm_exports_takes_priority_over_main() {
2821        let dir = test_dir("npm-exports-prio");
2822        let pkg_dir = dir.path().join("node_modules/my-config");
2823        std::fs::create_dir_all(&pkg_dir).unwrap();
2824        std::fs::write(
2825            pkg_dir.join("package.json"),
2826            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2827        )
2828        .unwrap();
2829        std::fs::write(
2830            pkg_dir.join("old.json"),
2831            r#"{"rules": {"unused-files": "off"}}"#,
2832        )
2833        .unwrap();
2834        std::fs::write(
2835            pkg_dir.join("new.json"),
2836            r#"{"rules": {"unused-files": "warn"}}"#,
2837        )
2838        .unwrap();
2839
2840        std::fs::write(
2841            dir.path().join(".fallowrc.json"),
2842            r#"{"extends": "npm:my-config"}"#,
2843        )
2844        .unwrap();
2845
2846        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2847        assert_eq!(config.rules.unused_files, Severity::Warn);
2848    }
2849
2850    #[test]
2851    fn extends_npm_walk_up_directories() {
2852        let dir = test_dir("npm-walkup");
2853        create_npm_package(
2854            dir.path(),
2855            "shared-config",
2856            r#"{"rules": {"unused-files": "warn"}}"#,
2857        );
2858        let sub = dir.path().join("packages/app");
2859        std::fs::create_dir_all(&sub).unwrap();
2860        std::fs::write(
2861            sub.join(".fallowrc.json"),
2862            r#"{"extends": "npm:shared-config"}"#,
2863        )
2864        .unwrap();
2865
2866        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2867        assert_eq!(config.rules.unused_files, Severity::Warn);
2868    }
2869
2870    #[test]
2871    fn extends_npm_overlay_overrides_base() {
2872        let dir = test_dir("npm-overlay");
2873        create_npm_package(
2874            dir.path(),
2875            "@company/base",
2876            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2877        );
2878        std::fs::write(
2879            dir.path().join(".fallowrc.json"),
2880            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2881        )
2882        .unwrap();
2883
2884        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2885        assert_eq!(config.rules.unused_files, Severity::Error);
2886        assert_eq!(config.rules.unused_exports, Severity::Off);
2887        assert_eq!(config.entry, vec!["src/app.ts"]);
2888    }
2889
2890    #[test]
2891    fn extends_npm_chained_with_relative() {
2892        let dir = test_dir("npm-chained");
2893        let pkg_dir = dir.path().join("node_modules/my-config");
2894        std::fs::create_dir_all(&pkg_dir).unwrap();
2895        std::fs::write(
2896            pkg_dir.join("base.json"),
2897            r#"{"rules": {"unused-files": "warn"}}"#,
2898        )
2899        .unwrap();
2900        std::fs::write(
2901            pkg_dir.join(".fallowrc.json"),
2902            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2903        )
2904        .unwrap();
2905
2906        std::fs::write(
2907            dir.path().join(".fallowrc.json"),
2908            r#"{"extends": "npm:my-config"}"#,
2909        )
2910        .unwrap();
2911
2912        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2913        assert_eq!(config.rules.unused_files, Severity::Warn);
2914        assert_eq!(config.rules.unused_exports, Severity::Off);
2915    }
2916
2917    #[test]
2918    fn extends_npm_mixed_with_relative_paths() {
2919        let dir = test_dir("npm-mixed");
2920        create_npm_package(
2921            dir.path(),
2922            "shared-base",
2923            r#"{"rules": {"unused-files": "off"}}"#,
2924        );
2925        std::fs::write(
2926            dir.path().join("local-overrides.json"),
2927            r#"{"rules": {"unused-files": "warn"}}"#,
2928        )
2929        .unwrap();
2930        std::fs::write(
2931            dir.path().join(".fallowrc.json"),
2932            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2933        )
2934        .unwrap();
2935
2936        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2937        assert_eq!(config.rules.unused_files, Severity::Warn);
2938    }
2939
2940    #[test]
2941    fn extends_npm_missing_package_errors() {
2942        let dir = test_dir("npm-missing");
2943        std::fs::write(
2944            dir.path().join(".fallowrc.json"),
2945            r#"{"extends": "npm:nonexistent-package"}"#,
2946        )
2947        .unwrap();
2948
2949        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2950        assert!(result.is_err());
2951        let err_msg = format!("{}", result.unwrap_err());
2952        assert!(
2953            err_msg.contains("not found"),
2954            "Expected 'not found' error, got: {err_msg}"
2955        );
2956        assert!(
2957            err_msg.contains("nonexistent-package"),
2958            "Expected package name in error, got: {err_msg}"
2959        );
2960        assert!(
2961            err_msg.contains("install it"),
2962            "Expected install hint in error, got: {err_msg}"
2963        );
2964    }
2965
2966    #[test]
2967    fn extends_npm_no_config_in_package_errors() {
2968        let dir = test_dir("npm-no-config");
2969        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2970        std::fs::create_dir_all(&pkg_dir).unwrap();
2971        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2972
2973        std::fs::write(
2974            dir.path().join(".fallowrc.json"),
2975            r#"{"extends": "npm:empty-pkg"}"#,
2976        )
2977        .unwrap();
2978
2979        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2980        assert!(result.is_err());
2981        let err_msg = format!("{}", result.unwrap_err());
2982        assert!(
2983            err_msg.contains("No fallow config found"),
2984            "Expected 'No fallow config found' error, got: {err_msg}"
2985        );
2986    }
2987
2988    #[test]
2989    fn extends_npm_missing_subpath_errors() {
2990        let dir = test_dir("npm-missing-sub");
2991        let pkg_dir = dir.path().join("node_modules/@co/config");
2992        std::fs::create_dir_all(&pkg_dir).unwrap();
2993
2994        std::fs::write(
2995            dir.path().join(".fallowrc.json"),
2996            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2997        )
2998        .unwrap();
2999
3000        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3001        assert!(result.is_err());
3002        let err_msg = format!("{}", result.unwrap_err());
3003        assert!(
3004            err_msg.contains("nonexistent.json"),
3005            "Expected subpath in error, got: {err_msg}"
3006        );
3007    }
3008
3009    #[test]
3010    fn extends_npm_empty_specifier_errors() {
3011        let dir = test_dir("npm-empty");
3012        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
3013
3014        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3015        assert!(result.is_err());
3016        let err_msg = format!("{}", result.unwrap_err());
3017        assert!(
3018            err_msg.contains("Empty npm specifier"),
3019            "Expected 'Empty npm specifier' error, got: {err_msg}"
3020        );
3021    }
3022
3023    #[test]
3024    fn extends_npm_space_after_colon_trimmed() {
3025        let dir = test_dir("npm-space");
3026        create_npm_package(
3027            dir.path(),
3028            "fallow-config-acme",
3029            r#"{"rules": {"unused-files": "warn"}}"#,
3030        );
3031        std::fs::write(
3032            dir.path().join(".fallowrc.json"),
3033            r#"{"extends": "npm: fallow-config-acme"}"#,
3034        )
3035        .unwrap();
3036
3037        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3038        assert_eq!(config.rules.unused_files, Severity::Warn);
3039    }
3040
3041    #[test]
3042    fn extends_npm_exports_node_condition() {
3043        let dir = test_dir("npm-node-cond");
3044        let pkg_dir = dir.path().join("node_modules/node-config");
3045        std::fs::create_dir_all(&pkg_dir).unwrap();
3046        std::fs::write(
3047            pkg_dir.join("package.json"),
3048            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
3049        )
3050        .unwrap();
3051        std::fs::write(
3052            pkg_dir.join("node.json"),
3053            r#"{"rules": {"unused-files": "off"}}"#,
3054        )
3055        .unwrap();
3056
3057        std::fs::write(
3058            dir.path().join(".fallowrc.json"),
3059            r#"{"extends": "npm:node-config"}"#,
3060        )
3061        .unwrap();
3062
3063        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3064        assert_eq!(config.rules.unused_files, Severity::Off);
3065    }
3066
3067    #[test]
3068    fn parse_npm_specifier_unscoped() {
3069        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
3070    }
3071
3072    #[test]
3073    fn parse_npm_specifier_unscoped_with_subpath() {
3074        assert_eq!(
3075            parse_npm_specifier("my-config/strict.json"),
3076            ("my-config", Some("strict.json"))
3077        );
3078    }
3079
3080    #[test]
3081    fn parse_npm_specifier_scoped() {
3082        assert_eq!(
3083            parse_npm_specifier("@company/fallow-config"),
3084            ("@company/fallow-config", None)
3085        );
3086    }
3087
3088    #[test]
3089    fn parse_npm_specifier_scoped_with_subpath() {
3090        assert_eq!(
3091            parse_npm_specifier("@company/fallow-config/strict.json"),
3092            ("@company/fallow-config", Some("strict.json"))
3093        );
3094    }
3095
3096    #[test]
3097    fn parse_npm_specifier_scoped_with_nested_subpath() {
3098        assert_eq!(
3099            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
3100            ("@company/fallow-config", Some("presets/strict.json"))
3101        );
3102    }
3103
3104    #[test]
3105    fn extends_npm_subpath_traversal_rejected() {
3106        let dir = test_dir("npm-traversal-sub");
3107        let pkg_dir = dir.path().join("node_modules/evil-pkg");
3108        std::fs::create_dir_all(&pkg_dir).unwrap();
3109        std::fs::write(
3110            dir.path().join("secret.json"),
3111            r#"{"entry": ["stolen.ts"]}"#,
3112        )
3113        .unwrap();
3114
3115        std::fs::write(
3116            dir.path().join(".fallowrc.json"),
3117            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
3118        )
3119        .unwrap();
3120
3121        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3122        assert!(result.is_err());
3123        let err_msg = format!("{}", result.unwrap_err());
3124        assert!(
3125            err_msg.contains("traversal") || err_msg.contains("not found"),
3126            "Expected traversal or not-found error, got: {err_msg}"
3127        );
3128    }
3129
3130    #[test]
3131    fn extends_npm_dotdot_package_name_rejected() {
3132        let dir = test_dir("npm-dotdot-name");
3133        std::fs::write(
3134            dir.path().join(".fallowrc.json"),
3135            r#"{"extends": "npm:../relative"}"#,
3136        )
3137        .unwrap();
3138
3139        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3140        assert!(result.is_err());
3141        let err_msg = format!("{}", result.unwrap_err());
3142        assert!(
3143            err_msg.contains("path traversal"),
3144            "Expected 'path traversal' error, got: {err_msg}"
3145        );
3146    }
3147
3148    #[test]
3149    fn extends_npm_scoped_without_name_rejected() {
3150        let dir = test_dir("npm-scope-only");
3151        std::fs::write(
3152            dir.path().join(".fallowrc.json"),
3153            r#"{"extends": "npm:@scope"}"#,
3154        )
3155        .unwrap();
3156
3157        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3158        assert!(result.is_err());
3159        let err_msg = format!("{}", result.unwrap_err());
3160        assert!(
3161            err_msg.contains("@scope/name"),
3162            "Expected scoped name format error, got: {err_msg}"
3163        );
3164    }
3165
3166    #[test]
3167    fn extends_npm_malformed_package_json_errors() {
3168        let dir = test_dir("npm-bad-pkgjson");
3169        let pkg_dir = dir.path().join("node_modules/bad-pkg");
3170        std::fs::create_dir_all(&pkg_dir).unwrap();
3171        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
3172
3173        std::fs::write(
3174            dir.path().join(".fallowrc.json"),
3175            r#"{"extends": "npm:bad-pkg"}"#,
3176        )
3177        .unwrap();
3178
3179        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3180        assert!(result.is_err());
3181        let err_msg = format!("{}", result.unwrap_err());
3182        assert!(
3183            err_msg.contains("Failed to parse"),
3184            "Expected parse error, got: {err_msg}"
3185        );
3186    }
3187
3188    #[test]
3189    fn extends_npm_exports_traversal_rejected() {
3190        let dir = test_dir("npm-exports-escape");
3191        let pkg_dir = dir.path().join("node_modules/evil-exports");
3192        std::fs::create_dir_all(&pkg_dir).unwrap();
3193        std::fs::write(
3194            pkg_dir.join("package.json"),
3195            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
3196        )
3197        .unwrap();
3198        std::fs::write(
3199            dir.path().join("secret.json"),
3200            r#"{"entry": ["stolen.ts"]}"#,
3201        )
3202        .unwrap();
3203
3204        std::fs::write(
3205            dir.path().join(".fallowrc.json"),
3206            r#"{"extends": "npm:evil-exports"}"#,
3207        )
3208        .unwrap();
3209
3210        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3211        assert!(result.is_err());
3212        let err_msg = format!("{}", result.unwrap_err());
3213        assert!(
3214            err_msg.contains("traversal"),
3215            "Expected traversal error, got: {err_msg}"
3216        );
3217    }
3218
3219    #[test]
3220    fn deep_merge_scalar_overlay_replaces_base() {
3221        let mut base = serde_json::json!("hello");
3222        deep_merge_json(&mut base, serde_json::json!("world"));
3223        assert_eq!(base, serde_json::json!("world"));
3224    }
3225
3226    #[test]
3227    fn deep_merge_array_overlay_replaces_base() {
3228        let mut base = serde_json::json!(["a", "b"]);
3229        deep_merge_json(&mut base, serde_json::json!(["c"]));
3230        assert_eq!(base, serde_json::json!(["c"]));
3231    }
3232
3233    #[test]
3234    fn deep_merge_nested_object_merge() {
3235        let mut base = serde_json::json!({
3236            "level1": {
3237                "level2": {
3238                    "a": 1,
3239                    "b": 2
3240                }
3241            }
3242        });
3243        let overlay = serde_json::json!({
3244            "level1": {
3245                "level2": {
3246                    "b": 99,
3247                    "c": 3
3248                }
3249            }
3250        });
3251        deep_merge_json(&mut base, overlay);
3252        assert_eq!(base["level1"]["level2"]["a"], 1);
3253        assert_eq!(base["level1"]["level2"]["b"], 99);
3254        assert_eq!(base["level1"]["level2"]["c"], 3);
3255    }
3256
3257    #[test]
3258    fn deep_merge_overlay_adds_new_fields() {
3259        let mut base = serde_json::json!({"existing": true});
3260        let overlay = serde_json::json!({"new_field": "added", "another": 42});
3261        deep_merge_json(&mut base, overlay);
3262        assert_eq!(base["existing"], true);
3263        assert_eq!(base["new_field"], "added");
3264        assert_eq!(base["another"], 42);
3265    }
3266
3267    #[test]
3268    fn deep_merge_null_overlay_replaces_object() {
3269        let mut base = serde_json::json!({"key": "value"});
3270        deep_merge_json(&mut base, serde_json::json!(null));
3271        assert_eq!(base, serde_json::json!(null));
3272    }
3273
3274    #[test]
3275    fn deep_merge_empty_object_overlay_preserves_base() {
3276        let mut base = serde_json::json!({"a": 1, "b": 2});
3277        deep_merge_json(&mut base, serde_json::json!({}));
3278        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3279    }
3280
3281    #[test]
3282    fn rules_severity_error_warn_off_from_json() {
3283        let json_str = r#"{
3284            "rules": {
3285                "unused-files": "error",
3286                "unused-exports": "warn",
3287                "unused-types": "off"
3288            }
3289        }"#;
3290        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3291        assert_eq!(config.rules.unused_files, Severity::Error);
3292        assert_eq!(config.rules.unused_exports, Severity::Warn);
3293        assert_eq!(config.rules.unused_types, Severity::Off);
3294    }
3295
3296    #[test]
3297    fn rules_omitted_default_to_error() {
3298        let json_str = r#"{
3299            "rules": {
3300                "unused-files": "warn"
3301            }
3302        }"#;
3303        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3304        assert_eq!(config.rules.unused_files, Severity::Warn);
3305        assert_eq!(config.rules.unused_exports, Severity::Error);
3306        assert_eq!(config.rules.unused_types, Severity::Error);
3307        assert_eq!(config.rules.unused_dependencies, Severity::Error);
3308        assert_eq!(config.rules.unresolved_imports, Severity::Error);
3309        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
3310        assert_eq!(config.rules.duplicate_exports, Severity::Error);
3311        assert_eq!(config.rules.circular_dependencies, Severity::Error);
3312        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
3313    }
3314
3315    #[test]
3316    fn find_and_load_returns_none_when_no_config() {
3317        let dir = test_dir("find-none");
3318        std::fs::create_dir(dir.path().join(".git")).unwrap();
3319
3320        let result = FallowConfig::find_and_load(dir.path()).unwrap();
3321        assert!(result.is_none());
3322    }
3323
3324    #[test]
3325    fn find_and_load_finds_fallowrc_json() {
3326        let dir = test_dir("find-json");
3327        std::fs::create_dir(dir.path().join(".git")).unwrap();
3328        std::fs::write(
3329            dir.path().join(".fallowrc.json"),
3330            r#"{"entry": ["src/main.ts"]}"#,
3331        )
3332        .unwrap();
3333
3334        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3335        assert_eq!(config.entry, vec!["src/main.ts"]);
3336        assert!(path.ends_with(".fallowrc.json"));
3337    }
3338
3339    #[test]
3340    fn find_and_load_finds_fallowrc_jsonc() {
3341        let dir = test_dir("find-jsonc");
3342        std::fs::create_dir(dir.path().join(".git")).unwrap();
3343        std::fs::write(
3344            dir.path().join(".fallowrc.jsonc"),
3345            r#"{
3346                "entry": ["src/main.ts"]
3347            }"#,
3348        )
3349        .unwrap();
3350
3351        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3352        assert_eq!(config.entry, vec!["src/main.ts"]);
3353        assert!(path.ends_with(".fallowrc.jsonc"));
3354    }
3355
3356    #[test]
3357    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
3358        let dir = test_dir("find-json-vs-jsonc");
3359        std::fs::create_dir(dir.path().join(".git")).unwrap();
3360        std::fs::write(
3361            dir.path().join(".fallowrc.json"),
3362            r#"{"entry": ["from-json.ts"]}"#,
3363        )
3364        .unwrap();
3365        std::fs::write(
3366            dir.path().join(".fallowrc.jsonc"),
3367            r#"{"entry": ["from-jsonc.ts"]}"#,
3368        )
3369        .unwrap();
3370
3371        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3372        assert_eq!(config.entry, vec!["from-json.ts"]);
3373        assert!(path.ends_with(".fallowrc.json"));
3374    }
3375
3376    #[test]
3377    fn find_and_load_prefers_fallowrc_json_over_toml() {
3378        let dir = test_dir("find-priority");
3379        std::fs::create_dir(dir.path().join(".git")).unwrap();
3380        std::fs::write(
3381            dir.path().join(".fallowrc.json"),
3382            r#"{"entry": ["from-json.ts"]}"#,
3383        )
3384        .unwrap();
3385        std::fs::write(
3386            dir.path().join("fallow.toml"),
3387            "entry = [\"from-toml.ts\"]\n",
3388        )
3389        .unwrap();
3390
3391        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3392        assert_eq!(config.entry, vec!["from-json.ts"]);
3393        assert!(path.ends_with(".fallowrc.json"));
3394    }
3395
3396    #[test]
3397    fn shadowed_config_names_empty_when_single_config() {
3398        let dir = test_dir("shadow-single");
3399        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3400        assert!(shadowed_config_names(dir.path(), 0).is_empty());
3401    }
3402
3403    #[test]
3404    fn shadowed_config_names_reports_lower_precedence_toml() {
3405        let dir = test_dir("shadow-json-toml");
3406        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3407        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3408        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
3409    }
3410
3411    #[test]
3412    fn shadowed_config_names_reports_jsonc_sibling() {
3413        let dir = test_dir("shadow-json-jsonc");
3414        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3415        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
3416        assert_eq!(
3417            shadowed_config_names(dir.path(), 0),
3418            vec![".fallowrc.jsonc"]
3419        );
3420    }
3421
3422    #[test]
3423    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
3424        let dir = test_dir("shadow-all-four");
3425        for name in CONFIG_NAMES {
3426            std::fs::write(dir.path().join(name), "").unwrap();
3427        }
3428        assert_eq!(
3429            shadowed_config_names(dir.path(), 0),
3430            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
3431        );
3432    }
3433
3434    #[test]
3435    fn shadowed_config_names_scoped_to_indices_after_winner() {
3436        let dir = test_dir("shadow-toml-dottoml");
3437        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3438        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
3439        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
3440    }
3441
3442    #[test]
3443    fn find_and_load_warns_when_configs_coexist() {
3444        let dir = test_dir("coexist-warn");
3445        std::fs::create_dir(dir.path().join(".git")).unwrap();
3446        std::fs::write(
3447            dir.path().join(".fallowrc.json"),
3448            r#"{"entry": ["from-json.ts"]}"#,
3449        )
3450        .unwrap();
3451        std::fs::write(
3452            dir.path().join("fallow.toml"),
3453            "entry = [\"from-toml.ts\"]\n",
3454        )
3455        .unwrap();
3456
3457        let (result, captured) =
3458            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3459
3460        let (config, path) = result.unwrap().unwrap();
3461        assert_eq!(config.entry, vec!["from-json.ts"]);
3462        assert!(path.ends_with(".fallowrc.json"));
3463
3464        assert_eq!(captured.len(), 1);
3465        let (chosen, shadowed) = &captured[0];
3466        assert_eq!(chosen, ".fallowrc.json");
3467        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
3468    }
3469
3470    #[test]
3471    fn find_and_load_does_not_warn_for_single_config() {
3472        let dir = test_dir("coexist-none");
3473        std::fs::create_dir(dir.path().join(".git")).unwrap();
3474        std::fs::write(
3475            dir.path().join(".fallowrc.json"),
3476            r#"{"entry": ["only.ts"]}"#,
3477        )
3478        .unwrap();
3479
3480        let (result, captured) =
3481            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3482        assert!(result.unwrap().is_some());
3483        assert!(captured.is_empty());
3484    }
3485
3486    #[test]
3487    fn find_and_load_warns_per_directory_independently() {
3488        let make = |name: &str| {
3489            let dir = test_dir(name);
3490            std::fs::create_dir(dir.path().join(".git")).unwrap();
3491            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
3492            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
3493            dir
3494        };
3495        let first = make("coexist-dir-a");
3496        let second = make("coexist-dir-b");
3497
3498        let ((), captured) = capture_coexisting_config_warnings(|| {
3499            FallowConfig::find_and_load(first.path()).unwrap();
3500            FallowConfig::find_and_load(second.path()).unwrap();
3501        });
3502
3503        assert_eq!(captured.len(), 2);
3504        assert!(captured.iter().all(|(chosen, shadowed)| {
3505            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
3506        }));
3507    }
3508
3509    #[test]
3510    fn explicit_load_does_not_warn_about_coexisting_configs() {
3511        let dir = test_dir("coexist-explicit");
3512        std::fs::write(
3513            dir.path().join(".fallowrc.json"),
3514            r#"{"entry": ["chosen.ts"]}"#,
3515        )
3516        .unwrap();
3517        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
3518
3519        let chosen = dir.path().join("fallow.toml");
3520        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
3521        assert!(result.is_ok());
3522        assert!(captured.is_empty());
3523    }
3524
3525    #[test]
3526    fn find_and_load_finds_fallow_toml() {
3527        let dir = test_dir("find-toml");
3528        std::fs::create_dir(dir.path().join(".git")).unwrap();
3529        std::fs::write(
3530            dir.path().join("fallow.toml"),
3531            "entry = [\"src/index.ts\"]\n",
3532        )
3533        .unwrap();
3534
3535        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3536        assert_eq!(config.entry, vec!["src/index.ts"]);
3537    }
3538
3539    #[test]
3540    fn find_and_load_stops_at_git_dir() {
3541        let dir = test_dir("find-git-stop");
3542        let sub = dir.path().join("sub");
3543        std::fs::create_dir(&sub).unwrap();
3544        std::fs::create_dir(dir.path().join(".git")).unwrap();
3545        let result = FallowConfig::find_and_load(&sub).unwrap();
3546        assert!(result.is_none());
3547    }
3548
3549    #[test]
3550    fn find_and_load_walks_past_package_json_in_monorepo() {
3551        let dir = test_dir("find-monorepo");
3552        std::fs::create_dir(dir.path().join(".git")).unwrap();
3553        std::fs::write(
3554            dir.path().join(".fallowrc.json"),
3555            r#"{"entry": ["src/index.ts"]}"#,
3556        )
3557        .unwrap();
3558
3559        let sub = dir.path().join("packages").join("app");
3560        std::fs::create_dir_all(&sub).unwrap();
3561        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3562
3563        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3564        assert_eq!(config.entry, vec!["src/index.ts"]);
3565        assert_eq!(path, dir.path().join(".fallowrc.json"));
3566    }
3567
3568    #[test]
3569    fn find_and_load_sub_package_config_wins_over_root() {
3570        let dir = test_dir("find-monorepo-override");
3571        std::fs::create_dir(dir.path().join(".git")).unwrap();
3572        std::fs::write(
3573            dir.path().join(".fallowrc.json"),
3574            r#"{"entry": ["src/root.ts"]}"#,
3575        )
3576        .unwrap();
3577
3578        let sub = dir.path().join("packages").join("app");
3579        std::fs::create_dir_all(&sub).unwrap();
3580        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3581        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
3582
3583        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3584        assert_eq!(config.entry, vec!["src/sub.ts"]);
3585        assert_eq!(path, sub.join(".fallowrc.json"));
3586    }
3587
3588    #[test]
3589    fn find_and_load_stops_at_git_file_submodule() {
3590        let dir = test_dir("find-git-file");
3591        std::fs::create_dir(dir.path().join(".git")).unwrap();
3592        std::fs::write(
3593            dir.path().join(".fallowrc.json"),
3594            r#"{"entry": ["src/parent.ts"]}"#,
3595        )
3596        .unwrap();
3597
3598        let submodule = dir.path().join("vendor").join("lib");
3599        std::fs::create_dir_all(&submodule).unwrap();
3600        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
3601
3602        let result = FallowConfig::find_and_load(&submodule).unwrap();
3603        assert!(
3604            result.is_none(),
3605            "submodule boundary should stop config walk",
3606        );
3607    }
3608
3609    #[test]
3610    fn find_and_load_stops_at_hg_dir() {
3611        let dir = test_dir("find-hg-stop");
3612        let sub = dir.path().join("sub");
3613        std::fs::create_dir(&sub).unwrap();
3614        std::fs::create_dir(dir.path().join(".hg")).unwrap();
3615
3616        let result = FallowConfig::find_and_load(&sub).unwrap();
3617        assert!(result.is_none());
3618    }
3619
3620    #[test]
3621    fn find_and_load_returns_error_for_invalid_config() {
3622        let dir = test_dir("find-invalid");
3623        std::fs::create_dir(dir.path().join(".git")).unwrap();
3624        std::fs::write(
3625            dir.path().join(".fallowrc.json"),
3626            r"{ this is not valid json }",
3627        )
3628        .unwrap();
3629
3630        let result = FallowConfig::find_and_load(dir.path());
3631        assert!(result.is_err());
3632    }
3633
3634    #[test]
3635    fn load_toml_config_file() {
3636        let dir = test_dir("toml-config");
3637        let config_path = dir.path().join("fallow.toml");
3638        std::fs::write(
3639            &config_path,
3640            r#"
3641entry = ["src/index.ts"]
3642ignorePatterns = ["dist/**"]
3643
3644[rules]
3645unused-files = "warn"
3646
3647[duplicates]
3648minTokens = 100
3649"#,
3650        )
3651        .unwrap();
3652
3653        let config = FallowConfig::load(&config_path).unwrap();
3654        assert_eq!(config.entry, vec!["src/index.ts"]);
3655        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
3656        assert_eq!(config.rules.unused_files, Severity::Warn);
3657        assert_eq!(config.duplicates.min_tokens, 100);
3658    }
3659
3660    #[test]
3661    fn load_toml_config_file_with_health_threshold_override() {
3662        let dir = test_dir("toml-health-threshold-override");
3663        let config_path = dir.path().join("fallow.toml");
3664        std::fs::write(
3665            &config_path,
3666            r#"
3667[health]
3668thresholdOverrides = [
3669  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
3670]
3671"#,
3672        )
3673        .unwrap();
3674
3675        let config = FallowConfig::load(&config_path).unwrap();
3676        let override_config = &config.health.threshold_overrides[0];
3677        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
3678        assert_eq!(override_config.functions, vec!["legacyFlow"]);
3679        assert_eq!(override_config.max_cyclomatic, Some(30));
3680        assert_eq!(override_config.max_cognitive, Some(25));
3681        assert_eq!(override_config.max_crap, Some(80.5));
3682        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
3683    }
3684
3685    #[test]
3686    fn extends_absolute_path_rejected() {
3687        let dir = test_dir("extends-absolute");
3688
3689        #[cfg(unix)]
3690        let abs_path = "/absolute/path/config.json";
3691        #[cfg(windows)]
3692        let abs_path = "C:\\absolute\\path\\config.json";
3693
3694        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
3695        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
3696
3697        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3698        assert!(result.is_err());
3699        let err_msg = format!("{}", result.unwrap_err());
3700        assert!(
3701            err_msg.contains("must be relative"),
3702            "Expected 'must be relative' error, got: {err_msg}"
3703        );
3704    }
3705
3706    #[test]
3707    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
3708        let dir = test_dir("extends-windows-absolute");
3709
3710        std::fs::write(
3711            dir.path().join(".fallowrc.json"),
3712            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
3713        )
3714        .unwrap();
3715
3716        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3717        assert!(result.is_err());
3718        let err_msg = format!("{}", result.unwrap_err());
3719        assert!(
3720            err_msg.contains("must be relative"),
3721            "Expected 'must be relative' error, got: {err_msg}"
3722        );
3723    }
3724
3725    #[cfg(windows)]
3726    #[test]
3727    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
3728        let dir = test_dir("extends-posix-rooted-absolute");
3729
3730        std::fs::write(
3731            dir.path().join(".fallowrc.json"),
3732            r#"{"extends": ["/absolute/path/config.json"]}"#,
3733        )
3734        .unwrap();
3735
3736        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3737        assert!(result.is_err());
3738        let err_msg = format!("{}", result.unwrap_err());
3739        assert!(
3740            err_msg.contains("must be relative"),
3741            "Expected 'must be relative' error, got: {err_msg}"
3742        );
3743    }
3744
3745    #[test]
3746    fn resolve_production_mode_disables_dev_deps() {
3747        let config = FallowConfig {
3748            production: true.into(),
3749            ..Default::default()
3750        };
3751        let resolved = config.resolve(
3752            PathBuf::from("/tmp/test"),
3753            OutputFormat::Human,
3754            4,
3755            false,
3756            true,
3757            None,
3758        );
3759        assert!(resolved.production);
3760        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3761        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3762        assert_eq!(resolved.rules.unused_files, Severity::Error);
3763        assert_eq!(resolved.rules.unused_exports, Severity::Error);
3764    }
3765
3766    #[test]
3767    fn include_entry_exports_deserializes_from_camelcase_json() {
3768        let json = r#"{ "includeEntryExports": true }"#;
3769        let config: FallowConfig = serde_json::from_str(json).unwrap();
3770        assert!(config.include_entry_exports);
3771    }
3772
3773    #[test]
3774    fn include_entry_exports_deserializes_from_camelcase_toml() {
3775        let toml_str = "includeEntryExports = true\n";
3776        let config: FallowConfig = toml::from_str(toml_str).unwrap();
3777        assert!(config.include_entry_exports);
3778    }
3779
3780    #[test]
3781    fn include_entry_exports_default_is_false() {
3782        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3783        assert!(!config.include_entry_exports);
3784    }
3785
3786    #[test]
3787    fn include_entry_exports_propagates_through_resolve() {
3788        let config = FallowConfig {
3789            include_entry_exports: true,
3790            auto_imports: false,
3791            cache: CacheConfig::default(),
3792            ..Default::default()
3793        };
3794        let resolved = config.resolve(
3795            PathBuf::from("/tmp/test"),
3796            OutputFormat::Human,
3797            1,
3798            true,
3799            true,
3800            None,
3801        );
3802        assert!(resolved.include_entry_exports);
3803    }
3804
3805    #[test]
3806    fn config_format_defaults_to_toml_for_unknown() {
3807        assert!(matches!(
3808            ConfigFormat::from_path(Path::new("config.yaml")),
3809            ConfigFormat::Toml
3810        ));
3811        assert!(matches!(
3812            ConfigFormat::from_path(Path::new("config")),
3813            ConfigFormat::Toml
3814        ));
3815    }
3816
3817    #[test]
3818    fn deep_merge_object_over_scalar_replaces() {
3819        let mut base = serde_json::json!("just a string");
3820        let overlay = serde_json::json!({"key": "value"});
3821        deep_merge_json(&mut base, overlay);
3822        assert_eq!(base, serde_json::json!({"key": "value"}));
3823    }
3824
3825    #[test]
3826    fn deep_merge_scalar_over_object_replaces() {
3827        let mut base = serde_json::json!({"key": "value"});
3828        let overlay = serde_json::json!(42);
3829        deep_merge_json(&mut base, overlay);
3830        assert_eq!(base, serde_json::json!(42));
3831    }
3832
3833    #[test]
3834    fn extends_non_string_non_array_fails_loud() {
3835        let dir = test_dir("extends-numeric");
3836        std::fs::write(
3837            dir.path().join(".fallowrc.json"),
3838            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3839        )
3840        .unwrap();
3841
3842        let err = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap_err();
3843        assert!(
3844            err.to_string()
3845                .contains("extends must be a string or an array of strings"),
3846            "unexpected error: {err}"
3847        );
3848    }
3849
3850    #[test]
3851    fn extends_multiple_bases_later_wins() {
3852        let dir = test_dir("extends-multi-base");
3853
3854        std::fs::write(
3855            dir.path().join("base-a.json"),
3856            r#"{"rules": {"unused-files": "warn"}}"#,
3857        )
3858        .unwrap();
3859        std::fs::write(
3860            dir.path().join("base-b.json"),
3861            r#"{"rules": {"unused-files": "off"}}"#,
3862        )
3863        .unwrap();
3864        std::fs::write(
3865            dir.path().join(".fallowrc.json"),
3866            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3867        )
3868        .unwrap();
3869
3870        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3871        assert_eq!(config.rules.unused_files, Severity::Off);
3872    }
3873
3874    #[test]
3875    fn load_rejects_empty_security_request_receivers() {
3876        let dir = test_dir("empty-security-request-receivers");
3877        std::fs::write(
3878            dir.path().join(".fallowrc.json"),
3879            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
3880        )
3881        .unwrap();
3882
3883        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3884        let err = result.expect_err("empty receiver should be rejected");
3885        assert!(
3886            err.to_string().contains("security.requestReceivers"),
3887            "error should name security.requestReceivers: {err}"
3888        );
3889    }
3890
3891    #[test]
3892    fn resolve_normalizes_security_request_receivers() {
3893        let dir = test_dir("normalize-security-request-receivers");
3894        std::fs::write(
3895            dir.path().join(".fallowrc.json"),
3896            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3897        )
3898        .unwrap();
3899
3900        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3901            .unwrap()
3902            .resolve(
3903                dir.path().to_path_buf(),
3904                OutputFormat::Human,
3905                1,
3906                true,
3907                true,
3908                None,
3909            );
3910        assert_eq!(
3911            config.security.request_receivers,
3912            vec!["httpreq".to_string(), "r".to_string()]
3913        );
3914    }
3915
3916    #[test]
3917    fn fallow_config_deserialize_production() {
3918        let json_str = r#"{"production": true}"#;
3919        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3920        assert!(config.production);
3921    }
3922
3923    #[test]
3924    fn fallow_config_production_defaults_false() {
3925        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3926        assert!(!config.production);
3927    }
3928
3929    #[test]
3930    fn package_json_optional_dependency_names() {
3931        let pkg: PackageJson = serde_json::from_str(
3932            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3933        )
3934        .unwrap();
3935        let opt = pkg.optional_dependency_names();
3936        assert_eq!(opt.len(), 2);
3937        assert!(opt.contains(&"fsevents".to_string()));
3938        assert!(opt.contains(&"chokidar".to_string()));
3939    }
3940
3941    #[test]
3942    fn package_json_optional_deps_empty_when_missing() {
3943        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3944        assert!(pkg.optional_dependency_names().is_empty());
3945    }
3946
3947    #[test]
3948    fn find_config_path_returns_fallowrc_json() {
3949        let dir = test_dir("find-path-json");
3950        std::fs::create_dir(dir.path().join(".git")).unwrap();
3951        std::fs::write(
3952            dir.path().join(".fallowrc.json"),
3953            r#"{"entry": ["src/main.ts"]}"#,
3954        )
3955        .unwrap();
3956
3957        let path = FallowConfig::find_config_path(dir.path());
3958        assert!(path.is_some());
3959        assert!(path.unwrap().ends_with(".fallowrc.json"));
3960    }
3961
3962    #[test]
3963    fn find_config_path_returns_fallow_toml() {
3964        let dir = test_dir("find-path-toml");
3965        std::fs::create_dir(dir.path().join(".git")).unwrap();
3966        std::fs::write(
3967            dir.path().join("fallow.toml"),
3968            "entry = [\"src/main.ts\"]\n",
3969        )
3970        .unwrap();
3971
3972        let path = FallowConfig::find_config_path(dir.path());
3973        assert!(path.is_some());
3974        assert!(path.unwrap().ends_with("fallow.toml"));
3975    }
3976
3977    #[test]
3978    fn find_config_path_returns_dot_fallow_toml() {
3979        let dir = test_dir("find-path-dot-toml");
3980        std::fs::create_dir(dir.path().join(".git")).unwrap();
3981        std::fs::write(
3982            dir.path().join(".fallow.toml"),
3983            "entry = [\"src/main.ts\"]\n",
3984        )
3985        .unwrap();
3986
3987        let path = FallowConfig::find_config_path(dir.path());
3988        assert!(path.is_some());
3989        assert!(path.unwrap().ends_with(".fallow.toml"));
3990    }
3991
3992    #[test]
3993    fn find_config_path_prefers_json_over_toml() {
3994        let dir = test_dir("find-path-priority");
3995        std::fs::create_dir(dir.path().join(".git")).unwrap();
3996        std::fs::write(
3997            dir.path().join(".fallowrc.json"),
3998            r#"{"entry": ["json.ts"]}"#,
3999        )
4000        .unwrap();
4001        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
4002
4003        let path = FallowConfig::find_config_path(dir.path());
4004        assert!(path.unwrap().ends_with(".fallowrc.json"));
4005    }
4006
4007    #[test]
4008    fn find_config_path_none_when_no_config() {
4009        let dir = test_dir("find-path-none");
4010        std::fs::create_dir(dir.path().join(".git")).unwrap();
4011
4012        let path = FallowConfig::find_config_path(dir.path());
4013        assert!(path.is_none());
4014    }
4015
4016    #[test]
4017    fn find_config_path_walks_past_package_json_in_monorepo() {
4018        let dir = test_dir("find-path-monorepo");
4019        std::fs::create_dir(dir.path().join(".git")).unwrap();
4020        std::fs::write(
4021            dir.path().join(".fallowrc.json"),
4022            r#"{"entry": ["src/index.ts"]}"#,
4023        )
4024        .unwrap();
4025
4026        let sub = dir.path().join("packages").join("app");
4027        std::fs::create_dir_all(&sub).unwrap();
4028        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
4029
4030        let path = FallowConfig::find_config_path(&sub).unwrap();
4031        assert_eq!(path, dir.path().join(".fallowrc.json"));
4032    }
4033
4034    #[test]
4035    fn extends_toml_base() {
4036        let dir = test_dir("extends-toml");
4037
4038        std::fs::write(
4039            dir.path().join("base.json"),
4040            r#"{"rules": {"unused-files": "warn"}}"#,
4041        )
4042        .unwrap();
4043        std::fs::write(
4044            dir.path().join("fallow.toml"),
4045            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
4046        )
4047        .unwrap();
4048
4049        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4050        assert_eq!(config.rules.unused_files, Severity::Warn);
4051        assert_eq!(config.entry, vec!["src/index.ts"]);
4052    }
4053
4054    #[test]
4055    fn deep_merge_boolean_overlay() {
4056        let mut base = serde_json::json!(true);
4057        deep_merge_json(&mut base, serde_json::json!(false));
4058        assert_eq!(base, serde_json::json!(false));
4059    }
4060
4061    #[test]
4062    fn deep_merge_number_overlay() {
4063        let mut base = serde_json::json!(42);
4064        deep_merge_json(&mut base, serde_json::json!(99));
4065        assert_eq!(base, serde_json::json!(99));
4066    }
4067
4068    #[test]
4069    fn deep_merge_disjoint_objects() {
4070        let mut base = serde_json::json!({"a": 1});
4071        let overlay = serde_json::json!({"b": 2});
4072        deep_merge_json(&mut base, overlay);
4073        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
4074    }
4075
4076    #[test]
4077    fn max_extends_depth_is_reasonable() {
4078        assert_eq!(MAX_EXTENDS_DEPTH, 10);
4079    }
4080
4081    #[test]
4082    fn config_names_has_four_entries() {
4083        assert_eq!(CONFIG_NAMES.len(), 4);
4084        for name in CONFIG_NAMES {
4085            assert!(
4086                name.starts_with('.') || name.starts_with("fallow"),
4087                "unexpected config name: {name}"
4088            );
4089        }
4090    }
4091
4092    #[test]
4093    fn package_json_peer_dependency_names() {
4094        let pkg: PackageJson = serde_json::from_str(
4095            r#"{
4096            "dependencies": {"react": "^18"},
4097            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
4098        }"#,
4099        )
4100        .unwrap();
4101        let all = pkg.all_dependency_names();
4102        assert!(all.contains(&"react".to_string()));
4103        assert!(all.contains(&"react-dom".to_string()));
4104        assert!(all.contains(&"react-native".to_string()));
4105    }
4106
4107    #[test]
4108    fn package_json_scripts_field() {
4109        let pkg: PackageJson = serde_json::from_str(
4110            r#"{
4111            "scripts": {
4112                "build": "tsc",
4113                "test": "vitest",
4114                "lint": "fallow check"
4115            }
4116        }"#,
4117        )
4118        .unwrap();
4119        let scripts = pkg.scripts.unwrap();
4120        assert_eq!(scripts.len(), 3);
4121        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
4122        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
4123    }
4124
4125    #[test]
4126    fn extends_toml_chain() {
4127        let dir = test_dir("extends-toml-chain");
4128
4129        std::fs::write(
4130            dir.path().join("base.json"),
4131            r#"{"entry": ["src/base.ts"]}"#,
4132        )
4133        .unwrap();
4134        std::fs::write(
4135            dir.path().join("middle.json"),
4136            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4137        )
4138        .unwrap();
4139        std::fs::write(
4140            dir.path().join("fallow.toml"),
4141            "extends = [\"middle.json\"]\n",
4142        )
4143        .unwrap();
4144
4145        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4146        assert_eq!(config.entry, vec!["src/base.ts"]);
4147        assert_eq!(config.rules.unused_files, Severity::Off);
4148    }
4149
4150    #[test]
4151    fn find_and_load_walks_up_directories() {
4152        let dir = test_dir("find-walk-up");
4153        let sub = dir.path().join("src").join("deep");
4154        std::fs::create_dir_all(&sub).unwrap();
4155        std::fs::write(
4156            dir.path().join(".fallowrc.json"),
4157            r#"{"entry": ["src/main.ts"]}"#,
4158        )
4159        .unwrap();
4160        std::fs::create_dir(dir.path().join(".git")).unwrap();
4161
4162        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4163        assert_eq!(config.entry, vec!["src/main.ts"]);
4164        assert!(path.ends_with(".fallowrc.json"));
4165    }
4166
4167    #[test]
4168    fn json_schema_contains_entry_field() {
4169        let schema = FallowConfig::json_schema();
4170        let obj = schema.as_object().unwrap();
4171        let props = obj.get("properties").and_then(|v| v.as_object());
4172        assert!(props.is_some(), "schema should have properties");
4173        assert!(
4174            props.unwrap().contains_key("entry"),
4175            "schema should contain entry property"
4176        );
4177    }
4178
4179    #[test]
4180    fn fallow_config_json_duplicates_all_fields() {
4181        let json = r#"{
4182            "duplicates": {
4183                "enabled": true,
4184                "mode": "semantic",
4185                "minTokens": 200,
4186                "minLines": 20,
4187                "threshold": 10.5,
4188                "ignore": ["**/*.test.ts"],
4189                "skipLocal": true,
4190                "crossLanguage": true,
4191                "normalization": {
4192                    "ignoreIdentifiers": true,
4193                    "ignoreStringValues": false
4194                }
4195            }
4196        }"#;
4197        let config: FallowConfig = serde_json::from_str(json).unwrap();
4198        assert!(config.duplicates.enabled);
4199        assert_eq!(
4200            config.duplicates.mode,
4201            crate::config::DetectionMode::Semantic
4202        );
4203        assert_eq!(config.duplicates.min_tokens, 200);
4204        assert_eq!(config.duplicates.min_lines, 20);
4205        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4206        assert!(config.duplicates.skip_local);
4207        assert!(config.duplicates.cross_language);
4208        assert_eq!(
4209            config.duplicates.normalization.ignore_identifiers,
4210            Some(true)
4211        );
4212        assert_eq!(
4213            config.duplicates.normalization.ignore_string_values,
4214            Some(false)
4215        );
4216    }
4217
4218    #[test]
4219    fn normalize_url_basic() {
4220        assert_eq!(
4221            normalize_url_for_dedup("https://example.com/config.json"),
4222            "https://example.com/config.json"
4223        );
4224    }
4225
4226    #[test]
4227    fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4228        let cases = [
4229            (
4230                "https://user:password@example.com/config.json",
4231                "https://example.com/config.json",
4232            ),
4233            (
4234                "https://example.com/config.json?token=query-secret#fragment-secret",
4235                "https://example.com/config.json",
4236            ),
4237            (
4238                "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4239                "https://[2001:db8::1]:8443/config.json",
4240            ),
4241            (
4242                "/workspace/configs/base.json?literal-query#literal-fragment",
4243                "/workspace/configs/base.json?literal-query#literal-fragment",
4244            ),
4245        ];
4246
4247        for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4248            assert!(
4249                remote_config_display(input) == expected,
4250                "remote config display case {case_index} must be sanitized"
4251            );
4252        }
4253    }
4254
4255    #[test]
4256    fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4257        let redirect_target =
4258            "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4259        let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4260
4261        let display =
4262            remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4263
4264        assert_eq!(
4265            display,
4266            "configured for https only: http://example.com/config.json"
4267        );
4268        for secret in [
4269            "redirect-user",
4270            "redirect-password",
4271            "token=secret",
4272            "anchor",
4273        ] {
4274            assert!(
4275                !display.contains(secret),
4276                "redirect secret must be redacted"
4277            );
4278        }
4279    }
4280
4281    #[test]
4282    fn normalize_url_preserves_non_root_trailing_slash() {
4283        assert_eq!(
4284            normalize_url_for_dedup("https://example.com/config/"),
4285            "https://example.com/config/"
4286        );
4287    }
4288
4289    #[test]
4290    fn normalize_url_uppercase_scheme_and_host() {
4291        assert_eq!(
4292            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4293            "https://example.com/Config.json"
4294        );
4295    }
4296
4297    #[test]
4298    fn normalize_url_root_path() {
4299        assert_eq!(
4300            normalize_url_for_dedup("https://example.com/"),
4301            "https://example.com"
4302        );
4303        assert_eq!(
4304            normalize_url_for_dedup("https://example.com"),
4305            "https://example.com"
4306        );
4307    }
4308
4309    #[test]
4310    fn normalize_url_preserves_path_case() {
4311        assert_eq!(
4312            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4313            "https://github.com/Org/Repo/Fallow.json"
4314        );
4315    }
4316
4317    #[test]
4318    fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4319        assert_eq!(
4320            normalize_url_for_dedup(
4321                "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4322            ),
4323            "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4324        );
4325    }
4326
4327    #[test]
4328    fn normalize_url_preserves_query_string() {
4329        assert_eq!(
4330            normalize_url_for_dedup("https://example.com/config.json?v=1"),
4331            "https://example.com/config.json?v=1"
4332        );
4333    }
4334
4335    #[test]
4336    fn normalize_url_strips_fragment() {
4337        assert_eq!(
4338            normalize_url_for_dedup("https://example.com/config.json#section"),
4339            "https://example.com/config.json"
4340        );
4341    }
4342
4343    #[test]
4344    fn normalize_url_preserves_query_and_strips_fragment() {
4345        assert_eq!(
4346            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4347            "https://example.com/config.json?v=1"
4348        );
4349    }
4350
4351    #[test]
4352    fn normalize_url_query_selects_resource_but_fragment_does_not() {
4353        assert_ne!(
4354            normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4355            normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4356        );
4357        assert_eq!(
4358            normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4359            normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4360        );
4361    }
4362
4363    #[test]
4364    fn normalize_url_default_https_port() {
4365        assert_eq!(
4366            normalize_url_for_dedup("https://example.com:443/config.json"),
4367            "https://example.com/config.json"
4368        );
4369        assert_eq!(
4370            normalize_url_for_dedup("https://example.com:8443/config.json"),
4371            "https://example.com:8443/config.json"
4372        );
4373    }
4374
4375    #[test]
4376    fn normalize_url_only_strips_the_scheme_default_port() {
4377        assert_eq!(
4378            normalize_url_for_dedup("http://example.com:80/config.json"),
4379            "http://example.com/config.json"
4380        );
4381        assert_eq!(
4382            normalize_url_for_dedup("http://example.com:443/config.json"),
4383            "http://example.com:443/config.json"
4384        );
4385        assert_eq!(
4386            normalize_url_for_dedup("https://example.com:80/config.json"),
4387            "https://example.com:80/config.json"
4388        );
4389    }
4390
4391    #[test]
4392    fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4393        assert_eq!(
4394            normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4395            "https://example.com?profile=one"
4396        );
4397        assert_eq!(
4398            normalize_url_for_dedup("https://example.com:080/config.json"),
4399            "https://example.com:80/config.json"
4400        );
4401    }
4402
4403    #[test]
4404    fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4405        assert_eq!(
4406            normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4407            "https://[2001:db8::1]/config.json"
4408        );
4409    }
4410
4411    #[test]
4412    fn extends_http_rejected() {
4413        let dir = test_dir("http-rejected");
4414        std::fs::write(
4415            dir.path().join(".fallowrc.json"),
4416            r#"{"extends": "http://example.com/config.json"}"#,
4417        )
4418        .unwrap();
4419
4420        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4421        assert!(result.is_err());
4422        let err_msg = format!("{}", result.unwrap_err());
4423        assert!(
4424            err_msg.contains("https://"),
4425            "Expected https hint in error, got: {err_msg}"
4426        );
4427        assert!(
4428            err_msg.contains("http://"),
4429            "Expected http:// mention in error, got: {err_msg}"
4430        );
4431    }
4432
4433    #[test]
4434    fn extends_url_circular_detection() {
4435        let mut visited = FxHashSet::default();
4436        let url = "https://example.com/config.json";
4437        let normalized = normalize_url_for_dedup(url);
4438        visited.insert(normalized.clone());
4439
4440        assert!(
4441            !visited.insert(normalized),
4442            "Same URL should be detected as duplicate"
4443        );
4444    }
4445
4446    #[test]
4447    fn extends_url_circular_case_insensitive() {
4448        let mut visited = FxHashSet::default();
4449        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4450
4451        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4452        assert!(
4453            !visited.insert(normalized),
4454            "Case-different URLs should normalize to the same key"
4455        );
4456    }
4457
4458    #[test]
4459    fn extract_extends_array() {
4460        let mut value = serde_json::json!({
4461            "extends": ["a.json", "b.json"],
4462            "entry": ["src/index.ts"]
4463        });
4464        let extends = extract_extends(&mut value, "test.json").unwrap();
4465        assert_eq!(extends, vec!["a.json", "b.json"]);
4466        assert!(value.get("extends").is_none());
4467        assert!(value.get("entry").is_some());
4468    }
4469
4470    #[test]
4471    fn extract_extends_string_sugar() {
4472        let mut value = serde_json::json!({
4473            "extends": "base.json",
4474            "entry": ["src/index.ts"]
4475        });
4476        let extends = extract_extends(&mut value, "test.json").unwrap();
4477        assert_eq!(extends, vec!["base.json"]);
4478    }
4479
4480    #[test]
4481    fn extract_extends_none() {
4482        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4483        let extends = extract_extends(&mut value, "test.json").unwrap();
4484        assert!(extends.is_empty());
4485    }
4486
4487    #[test]
4488    fn url_timeout_default() {
4489        let timeout = url_timeout();
4490        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4491    }
4492
4493    #[test]
4494    fn extends_url_mixed_with_file_and_npm() {
4495        let dir = test_dir("url-mixed");
4496        std::fs::write(
4497            dir.path().join("local.json"),
4498            r#"{"rules": {"unused-files": "warn"}}"#,
4499        )
4500        .unwrap();
4501        std::fs::write(
4502            dir.path().join(".fallowrc.json"),
4503            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4504        )
4505        .unwrap();
4506
4507        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4508        assert!(result.is_err());
4509        let err_msg = format!("{}", result.unwrap_err());
4510        assert!(
4511            err_msg.contains("unreachable.invalid"),
4512            "Expected URL in error message, got: {err_msg}"
4513        );
4514    }
4515
4516    #[test]
4517    fn extends_https_url_default_denial_has_opt_in_hint() {
4518        let dir = test_dir("url-unreachable");
4519        std::fs::write(
4520            dir.path().join(".fallowrc.json"),
4521            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4522        )
4523        .unwrap();
4524
4525        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4526        assert!(result.is_err());
4527        let err_msg = format!("{}", result.unwrap_err());
4528        assert!(
4529            err_msg.contains("unreachable.invalid"),
4530            "Expected URL in error, got: {err_msg}"
4531        );
4532        assert!(
4533            err_msg.contains("--allow-remote-extends"),
4534            "Expected remediation hint, got: {err_msg}"
4535        );
4536    }
4537
4538    #[test]
4539    fn collect_unknown_rule_keys_flags_top_level_typo() {
4540        let merged = serde_json::json!({
4541            "rules": {
4542                "unsued-files": "warn",
4543                "unused-exports": "off"
4544            }
4545        });
4546        let findings = collect_unknown_rule_keys(&merged);
4547        assert_eq!(findings.len(), 1);
4548        assert_eq!(findings[0].context, "rules");
4549        assert_eq!(findings[0].key, "unsued-files");
4550        assert_eq!(findings[0].suggestion, Some("unused-files"));
4551    }
4552
4553    #[test]
4554    fn collect_unknown_rule_keys_flags_overrides_typo() {
4555        let merged = serde_json::json!({
4556            "overrides": [
4557                {
4558                    "files": ["src/**/*.ts"],
4559                    "rules": {
4560                        "unsued-files": "warn"
4561                    }
4562                },
4563                {
4564                    "files": ["tests/**/*.ts"],
4565                    "rules": {
4566                        "circular-dependnecy": "off"
4567                    }
4568                }
4569            ]
4570        });
4571        let findings = collect_unknown_rule_keys(&merged);
4572        assert_eq!(findings.len(), 2);
4573        assert_eq!(findings[0].context, "overrides[0].rules");
4574        assert_eq!(findings[1].context, "overrides[1].rules");
4575        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4576    }
4577
4578    #[test]
4579    fn collect_unknown_rule_keys_empty_for_valid_config() {
4580        let merged = serde_json::json!({
4581            "rules": {
4582                "unused-files": "warn",
4583                "unused-file": "off",
4584                "circular-dependency": "off",
4585                "boundary-violations": "warn"
4586            },
4587            "overrides": [
4588                {
4589                    "files": ["src/**"],
4590                    "rules": {
4591                        "unused-exports": "warn"
4592                    }
4593                }
4594            ]
4595        });
4596        let findings = collect_unknown_rule_keys(&merged);
4597        assert!(
4598            findings.is_empty(),
4599            "valid rule names and aliases must not be flagged: {findings:?}"
4600        );
4601    }
4602
4603    #[test]
4604    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
4605        let merged = serde_json::json!({
4606            "entry": ["src/main.ts"]
4607        });
4608        let findings = collect_unknown_rule_keys(&merged);
4609        assert!(findings.is_empty());
4610    }
4611
4612    #[test]
4613    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
4614        let dir = test_dir("wiring");
4615        let path = dir.path().join(".fallowrc.json");
4616        let typo = format!(
4617            "wiring-probe-{}-{}",
4618            std::process::id(),
4619            std::time::SystemTime::now()
4620                .duration_since(std::time::UNIX_EPOCH)
4621                .map_or(0, |d| d.as_nanos())
4622        );
4623        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
4624
4625        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
4626
4627        assert!(
4628            config_res.is_ok(),
4629            "load should succeed in phase 1: {:?}",
4630            config_res.err()
4631        );
4632        assert_eq!(
4633            captured.len(),
4634            1,
4635            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
4636        );
4637        assert_eq!(captured[0].key, typo);
4638        assert_eq!(captured[0].context, "rules");
4639    }
4640
4641    #[test]
4642    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
4643        let dir = test_dir("misspelled-rule");
4644        std::fs::write(
4645            dir.path().join(".fallowrc.json"),
4646            r#"{"rules": {"unsued-files": "warn"}}"#,
4647        )
4648        .unwrap();
4649
4650        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4651            .expect("load should succeed in phase 1");
4652
4653        assert_eq!(config.rules.unused_files, Severity::Error);
4654    }
4655
4656    #[test]
4657    fn validate_resolved_boundaries_passes_on_valid_config() {
4658        let dir = test_dir("boundaries-valid");
4659        let config = FallowConfig {
4660            boundaries: crate::BoundaryConfig {
4661                coverage: crate::BoundaryCoverageConfig::default(),
4662                calls: crate::BoundaryCallsConfig::default(),
4663                preset: None,
4664                zones: vec![
4665                    crate::BoundaryZone {
4666                        name: "ui".to_string(),
4667                        patterns: vec!["src/components/**".to_string()],
4668                        auto_discover: vec![],
4669                        root: None,
4670                    },
4671                    crate::BoundaryZone {
4672                        name: "db".to_string(),
4673                        patterns: vec!["src/db/**".to_string()],
4674                        auto_discover: vec![],
4675                        root: None,
4676                    },
4677                ],
4678                rules: vec![crate::BoundaryRule {
4679                    from: "ui".to_string(),
4680                    allow: vec!["db".to_string()],
4681                    allow_type_only: vec![],
4682                }],
4683            },
4684            ..FallowConfig::default()
4685        };
4686        config
4687            .validate_resolved_boundaries(dir.path())
4688            .expect("valid config should pass");
4689    }
4690
4691    #[test]
4692    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
4693        let dir = test_dir("boundaries-unknown-zones");
4694        let config = FallowConfig {
4695            boundaries: crate::BoundaryConfig {
4696                coverage: crate::BoundaryCoverageConfig::default(),
4697                calls: crate::BoundaryCallsConfig::default(),
4698                preset: None,
4699                zones: vec![crate::BoundaryZone {
4700                    name: "ui".to_string(),
4701                    patterns: vec!["src/ui/**".to_string()],
4702                    auto_discover: vec![],
4703                    root: None,
4704                }],
4705                rules: vec![
4706                    crate::BoundaryRule {
4707                        from: "typo-from".to_string(),
4708                        allow: vec!["typo-allow".to_string()],
4709                        allow_type_only: vec!["typo-type-only".to_string()],
4710                    },
4711                    crate::BoundaryRule {
4712                        from: "ui".to_string(),
4713                        allow: vec!["another-typo".to_string()],
4714                        allow_type_only: vec![],
4715                    },
4716                ],
4717            },
4718            ..FallowConfig::default()
4719        };
4720
4721        let errors = config
4722            .validate_resolved_boundaries(dir.path())
4723            .expect_err("invalid zone refs should fail");
4724
4725        assert_eq!(errors.len(), 4, "got: {errors:?}");
4726
4727        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4728        assert!(
4729            rendered
4730                .iter()
4731                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
4732        );
4733        assert!(
4734            rendered
4735                .iter()
4736                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
4737        );
4738        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
4739            && m.contains("rules[0]")
4740            && m.contains("allowTypeOnly")));
4741        assert!(
4742            rendered.iter().any(|m| m.contains("another-typo")
4743                && m.contains("rules[1]")
4744                && m.contains("allow"))
4745        );
4746    }
4747
4748    #[test]
4749    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
4750        let dir = test_dir("boundaries-redundant-prefix");
4751        let config = FallowConfig {
4752            boundaries: crate::BoundaryConfig {
4753                coverage: crate::BoundaryCoverageConfig::default(),
4754                calls: crate::BoundaryCallsConfig::default(),
4755                preset: None,
4756                zones: vec![crate::BoundaryZone {
4757                    name: "ui".to_string(),
4758                    patterns: vec!["packages/app/src/**".to_string()],
4759                    auto_discover: vec![],
4760                    root: Some("packages/app/".to_string()),
4761                }],
4762                rules: vec![],
4763            },
4764            ..FallowConfig::default()
4765        };
4766
4767        let errors = config
4768            .validate_resolved_boundaries(dir.path())
4769            .expect_err("redundant root prefix should fail");
4770        assert_eq!(errors.len(), 1, "got: {errors:?}");
4771        let rendered = errors[0].to_string();
4772        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
4773        assert!(rendered.contains("zone 'ui'"));
4774    }
4775
4776    #[test]
4777    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
4778        let dir = test_dir("boundaries-mixed-errors");
4779        let config = FallowConfig {
4780            boundaries: crate::BoundaryConfig {
4781                coverage: crate::BoundaryCoverageConfig::default(),
4782                calls: crate::BoundaryCallsConfig::default(),
4783                preset: None,
4784                zones: vec![crate::BoundaryZone {
4785                    name: "ui".to_string(),
4786                    patterns: vec!["packages/app/src/**".to_string()],
4787                    auto_discover: vec![],
4788                    root: Some("packages/app/".to_string()),
4789                }],
4790                rules: vec![crate::BoundaryRule {
4791                    from: "ui".to_string(),
4792                    allow: vec!["typo-zone".to_string()],
4793                    allow_type_only: vec![],
4794                }],
4795            },
4796            ..FallowConfig::default()
4797        };
4798        let errors = config
4799            .validate_resolved_boundaries(dir.path())
4800            .expect_err("mixed errors should fail");
4801        assert_eq!(errors.len(), 2, "got: {errors:?}");
4802        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4803        assert!(
4804            rendered
4805                .iter()
4806                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
4807        );
4808        assert!(
4809            rendered
4810                .iter()
4811                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
4812        );
4813    }
4814
4815    #[test]
4816    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
4817        let dir = test_dir("boundaries-bulletproof");
4818        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4819        let config = FallowConfig {
4820            boundaries: crate::BoundaryConfig {
4821                coverage: crate::BoundaryCoverageConfig::default(),
4822                calls: crate::BoundaryCallsConfig::default(),
4823                preset: Some(crate::BoundaryPreset::Bulletproof),
4824                zones: vec![],
4825                rules: vec![],
4826            },
4827            ..FallowConfig::default()
4828        };
4829        config
4830            .validate_resolved_boundaries(dir.path())
4831            .expect("Bulletproof with discoverable features should pass");
4832    }
4833
4834    // ------------------------------------------------------------------
4835    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
4836    // ------------------------------------------------------------------
4837
4838    #[test]
4839    #[cfg_attr(miri, ignore)]
4840    fn parse_config_to_value_strips_utf8_bom() {
4841        let dir = test_dir("parse-bom");
4842        let path = dir.path().join("fallow.toml");
4843        // Write TOML with a UTF-8 BOM prefix
4844        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4845        std::fs::write(&path, content_with_bom).unwrap();
4846
4847        let value = parse_config_to_value(&path).unwrap();
4848        assert!(
4849            value.get("entry").is_some(),
4850            "BOM should be stripped before TOML parsing"
4851        );
4852    }
4853
4854    #[test]
4855    #[cfg_attr(miri, ignore)]
4856    fn parse_config_to_value_toml_parse_error() {
4857        let dir = test_dir("parse-toml-error");
4858        let path = dir.path().join("fallow.toml");
4859        std::fs::write(&path, "entry = [unquoted\n").unwrap();
4860
4861        let result = parse_config_to_value(&path);
4862        assert!(result.is_err());
4863        let err = result.unwrap_err().to_string();
4864        assert!(
4865            err.contains("Failed to parse config file"),
4866            "error should mention parse failure: {err}"
4867        );
4868    }
4869
4870    #[test]
4871    #[cfg_attr(miri, ignore)]
4872    fn parse_config_to_value_json_parse_error() {
4873        let dir = test_dir("parse-json-error");
4874        let path = dir.path().join(".fallowrc.json");
4875        std::fs::write(&path, "{ this is not json }").unwrap();
4876
4877        let result = parse_config_to_value(&path);
4878        assert!(result.is_err());
4879        let err = result.unwrap_err().to_string();
4880        assert!(
4881            err.contains("Failed to parse config file"),
4882            "error should mention parse failure: {err}"
4883        );
4884    }
4885
4886    #[test]
4887    #[cfg_attr(miri, ignore)]
4888    fn parse_config_to_value_missing_file_error() {
4889        let dir = test_dir("parse-missing");
4890        let path = dir.path().join("nonexistent.toml");
4891
4892        let result = parse_config_to_value(&path);
4893        assert!(result.is_err());
4894        let err = result.unwrap_err().to_string();
4895        assert!(
4896            err.contains("Failed to read config file"),
4897            "error should mention read failure: {err}"
4898        );
4899    }
4900
4901    // ------------------------------------------------------------------
4902    // is_repo_root: svn boundary
4903    // ------------------------------------------------------------------
4904
4905    #[test]
4906    #[cfg_attr(miri, ignore)]
4907    fn find_and_load_stops_at_svn_dir() {
4908        let dir = test_dir("find-svn-stop");
4909        let sub = dir.path().join("sub");
4910        std::fs::create_dir(&sub).unwrap();
4911        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4912
4913        let result = FallowConfig::find_and_load(&sub).unwrap();
4914        assert!(result.is_none(), "svn boundary should stop config walk");
4915    }
4916
4917    // ------------------------------------------------------------------
4918    // validate_npm_package_name: dot-segment in the package name
4919    // (path traversal but using a single dot)
4920    // ------------------------------------------------------------------
4921
4922    #[test]
4923    #[cfg_attr(miri, ignore)]
4924    fn extends_npm_single_dot_package_name_rejected() {
4925        let dir = test_dir("npm-dot-name");
4926        std::fs::write(
4927            dir.path().join(".fallowrc.json"),
4928            r#"{"extends": "npm:./relative"}"#,
4929        )
4930        .unwrap();
4931
4932        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4933        assert!(result.is_err());
4934        let err = result.unwrap_err().to_string();
4935        assert!(
4936            err.contains("path traversal"),
4937            "single-dot component should be rejected as path traversal: {err}"
4938        );
4939    }
4940
4941    // ------------------------------------------------------------------
4942    // find_config_in_npm_package: main field points to nonexistent file,
4943    // falls through to config-name scan
4944    // ------------------------------------------------------------------
4945
4946    #[test]
4947    #[cfg_attr(miri, ignore)]
4948    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4949        let dir = test_dir("npm-main-missing");
4950        let pkg_dir = dir.path().join("node_modules/my-config");
4951        std::fs::create_dir_all(&pkg_dir).unwrap();
4952        // package.json with main pointing at a file that does not exist
4953        std::fs::write(
4954            pkg_dir.join("package.json"),
4955            r#"{"name": "my-config", "main": "./missing.json"}"#,
4956        )
4957        .unwrap();
4958        // But a recognized config name is present for the fallback scan
4959        std::fs::write(
4960            pkg_dir.join(".fallowrc.json"),
4961            r#"{"rules": {"unused-files": "warn"}}"#,
4962        )
4963        .unwrap();
4964
4965        std::fs::write(
4966            dir.path().join(".fallowrc.json"),
4967            r#"{"extends": "npm:my-config"}"#,
4968        )
4969        .unwrap();
4970
4971        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4972        assert_eq!(config.rules.unused_files, Severity::Warn);
4973    }
4974
4975    // ------------------------------------------------------------------
4976    // find_config_in_npm_package: exports present but exports-pointed file
4977    // does not exist, falls through to main then config name
4978    // ------------------------------------------------------------------
4979
4980    #[test]
4981    #[cfg_attr(miri, ignore)]
4982    fn extends_npm_exports_nonexistent_falls_through_to_main() {
4983        let dir = test_dir("npm-exports-missing-file");
4984        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4985        std::fs::create_dir_all(&pkg_dir).unwrap();
4986        // exports points to a file that does not exist; main is valid
4987        std::fs::write(
4988            pkg_dir.join("package.json"),
4989            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4990        )
4991        .unwrap();
4992        std::fs::write(
4993            pkg_dir.join("real.json"),
4994            r#"{"rules": {"unused-types": "off"}}"#,
4995        )
4996        .unwrap();
4997
4998        std::fs::write(
4999            dir.path().join(".fallowrc.json"),
5000            r#"{"extends": "npm:cfg-pkg"}"#,
5001        )
5002        .unwrap();
5003
5004        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5005        assert_eq!(config.rules.unused_types, Severity::Off);
5006    }
5007
5008    // ------------------------------------------------------------------
5009    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
5010    // ------------------------------------------------------------------
5011
5012    #[test]
5013    fn normalize_url_no_scheme_returns_raw() {
5014        // A string without "://" must come back unchanged
5015        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
5016        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
5017    }
5018
5019    // ------------------------------------------------------------------
5020    // normalize_url_for_dedup: query before fragment (fragment then query)
5021    // ------------------------------------------------------------------
5022
5023    #[test]
5024    fn normalize_url_fragment_only_stripped() {
5025        // Fragment-only URL (no query)
5026        assert_eq!(
5027            normalize_url_for_dedup("https://example.com/file.json#anchor"),
5028            "https://example.com/file.json"
5029        );
5030    }
5031
5032    // ------------------------------------------------------------------
5033    // url_timeout: env var override
5034    // ------------------------------------------------------------------
5035
5036    // These exercise the pure `url_timeout_from` parser rather than mutating the
5037    // process-global env var, so they stay deterministic under parallel test
5038    // execution (an env-mutating version raced and failed on Windows CI).
5039    #[test]
5040    fn url_timeout_uses_env_var_when_set() {
5041        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
5042    }
5043
5044    #[test]
5045    fn url_timeout_zero_falls_back_to_default() {
5046        assert_eq!(
5047            url_timeout_from(Some("0")),
5048            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5049            "zero should fall back to the hardcoded default"
5050        );
5051    }
5052
5053    #[test]
5054    fn url_timeout_non_numeric_falls_back_to_default() {
5055        assert_eq!(
5056            url_timeout_from(Some("not-a-number")),
5057            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5058            "non-numeric value should fall back to the hardcoded default"
5059        );
5060    }
5061
5062    #[test]
5063    fn url_timeout_absent_uses_default() {
5064        assert_eq!(
5065            url_timeout_from(None),
5066            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
5067        );
5068    }
5069
5070    // ------------------------------------------------------------------
5071    // resolve_url_extends: depth limit reached
5072    // ------------------------------------------------------------------
5073
5074    #[test]
5075    fn resolve_url_extends_depth_limit_error() {
5076        let mut visited = FxHashSet::default();
5077        let result = resolve_url_extends(
5078            "https://example.invalid/config.json",
5079            &mut visited,
5080            MAX_EXTENDS_DEPTH, // at the limit
5081        );
5082        assert!(result.is_err());
5083        let err = result.unwrap_err().to_string();
5084        assert!(
5085            err.contains("too deep"),
5086            "error should mention depth limit: {err}"
5087        );
5088    }
5089
5090    // ------------------------------------------------------------------
5091    // resolve_extends_file: depth limit reached
5092    // ------------------------------------------------------------------
5093
5094    #[test]
5095    #[cfg_attr(miri, ignore)]
5096    fn resolve_extends_file_depth_limit_error() {
5097        let dir = test_dir("extends-file-depth");
5098        let path = dir.path().join(".fallowrc.json");
5099        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
5100
5101        let mut visited = FxHashSet::default();
5102        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
5103        assert!(result.is_err());
5104        let err = result.unwrap_err().to_string();
5105        assert!(
5106            err.contains("too deep"),
5107            "error should mention depth limit: {err}"
5108        );
5109    }
5110
5111    // ------------------------------------------------------------------
5112    // resolve_extends_file_entry: http:// in file-sourced extends
5113    // ------------------------------------------------------------------
5114
5115    #[test]
5116    #[cfg_attr(miri, ignore)]
5117    fn extends_http_url_in_file_extends_rejected() {
5118        let dir = test_dir("file-extends-http");
5119        std::fs::write(
5120            dir.path().join(".fallowrc.json"),
5121            r#"{"extends": ["http://example.com/config.json"]}"#,
5122        )
5123        .unwrap();
5124
5125        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5126        assert!(result.is_err());
5127        let err = result.unwrap_err().to_string();
5128        assert!(
5129            err.contains("https://"),
5130            "error should suggest https: {err}"
5131        );
5132    }
5133
5134    // ------------------------------------------------------------------
5135    // sealed_config_dir: when sealed = true canonicalization runs
5136    // ------------------------------------------------------------------
5137
5138    #[test]
5139    #[cfg_attr(miri, ignore)]
5140    fn sealed_config_dir_returns_some_when_sealed() {
5141        let dir = test_dir("sealed-dir");
5142        let result = sealed_config_dir(dir.path(), true);
5143        assert!(result.is_ok());
5144        assert!(
5145            result.unwrap().is_some(),
5146            "sealed=true must return Some(canonicalized path)"
5147        );
5148    }
5149
5150    #[test]
5151    fn sealed_config_dir_returns_none_when_not_sealed() {
5152        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5153        assert!(result.is_ok());
5154        assert!(result.unwrap().is_none(), "sealed=false must return None");
5155    }
5156
5157    // ------------------------------------------------------------------
5158    // collect_unknown_rule_keys: overrides entry without a rules key
5159    // (the inner `if let Some(rules)` branch is not taken)
5160    // ------------------------------------------------------------------
5161
5162    #[test]
5163    fn collect_unknown_rule_keys_override_without_rules_key() {
5164        let merged = serde_json::json!({
5165            "overrides": [
5166                {
5167                    "files": ["src/**/*.ts"]
5168                    // no "rules" key here
5169                },
5170                {
5171                    "files": ["tests/**"],
5172                    "rules": {
5173                        "unsued-exports": "off"
5174                    }
5175                }
5176            ]
5177        });
5178        let findings = collect_unknown_rule_keys(&merged);
5179        assert_eq!(
5180            findings.len(),
5181            1,
5182            "only the entry with rules should produce a finding"
5183        );
5184        assert_eq!(findings[0].context, "overrides[1].rules");
5185    }
5186
5187    // ------------------------------------------------------------------
5188    // FallowConfig::load: deserialization failure
5189    // ------------------------------------------------------------------
5190
5191    #[test]
5192    #[cfg_attr(miri, ignore)]
5193    fn load_fails_on_deserialization_error() {
5194        let dir = test_dir("deser-error");
5195        let path = dir.path().join(".fallowrc.json");
5196        // Valid JSON but contains a field value with the wrong type for the schema
5197        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5198
5199        let result = FallowConfig::load(&path);
5200        assert!(result.is_err());
5201        let err = result.unwrap_err().to_string();
5202        assert!(
5203            err.contains("invalid type") && err.contains(&path.display().to_string()),
5204            "error should lead with the reason and name the config file: {err}"
5205        );
5206    }
5207
5208    #[test]
5209    #[cfg_attr(miri, ignore)]
5210    fn unknown_override_field_error_names_path_once_and_hints_at_jsonc() {
5211        let dir = test_dir("override-unknown-field");
5212        let path = dir.path().join(".fallowrc.json");
5213        std::fs::write(
5214            &path,
5215            r#"{"overrides": [{"files": ["src/**"], "reason": "legacy"}]}"#,
5216        )
5217        .unwrap();
5218
5219        let err = FallowConfig::find_and_load(dir.path())
5220            .expect_err("unknown override field must be rejected");
5221        let path_str = path.display().to_string();
5222        assert!(
5223            err.contains("unknown field `reason`"),
5224            "error should lead with the serde reason: {err}"
5225        );
5226        assert_eq!(
5227            err.matches(&path_str).count(),
5228            1,
5229            "path must appear exactly once: {err}"
5230        );
5231        assert!(
5232            err.contains("// comment") && err.contains("JSONC"),
5233            "error should point annotations at JSONC comments: {err}"
5234        );
5235    }
5236
5237    // ------------------------------------------------------------------
5238    // FallowConfig::load: threshold override validation failure
5239    // (covers lines 921-927)
5240    // ------------------------------------------------------------------
5241
5242    #[test]
5243    #[cfg_attr(miri, ignore)]
5244    fn load_rejects_threshold_override_with_empty_files() {
5245        let dir = test_dir("threshold-empty-files");
5246        let path = dir.path().join(".fallowrc.json");
5247        std::fs::write(
5248            &path,
5249            r#"{
5250                "health": {
5251                    "thresholdOverrides": [
5252                        {"files": [], "maxCyclomatic": 30}
5253                    ]
5254                }
5255            }"#,
5256        )
5257        .unwrap();
5258
5259        let result = FallowConfig::load(&path);
5260        assert!(result.is_err());
5261        let err = result.unwrap_err().to_string();
5262        assert!(
5263            err.contains("thresholdOverrides"),
5264            "error should mention thresholdOverrides: {err}"
5265        );
5266        assert!(
5267            err.contains("files"),
5268            "error should name the files field: {err}"
5269        );
5270    }
5271
5272    #[test]
5273    #[cfg_attr(miri, ignore)]
5274    fn load_rejects_threshold_override_with_no_threshold_set() {
5275        let dir = test_dir("threshold-no-threshold");
5276        let path = dir.path().join(".fallowrc.json");
5277        std::fs::write(
5278            &path,
5279            r#"{
5280                "health": {
5281                    "thresholdOverrides": [
5282                        {"files": ["src/legacy.ts"]}
5283                    ]
5284                }
5285            }"#,
5286        )
5287        .unwrap();
5288
5289        let result = FallowConfig::load(&path);
5290        assert!(result.is_err());
5291        let err = result.unwrap_err().to_string();
5292        assert!(
5293            err.contains("maxCyclomatic")
5294                || err.contains("maxCognitive")
5295                || err.contains("maxCrap"),
5296            "error should name at least one threshold field: {err}"
5297        );
5298    }
5299
5300    // ------------------------------------------------------------------
5301    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
5302    // validation (covers lines 1026-1032)
5303    // ------------------------------------------------------------------
5304
5305    #[test]
5306    #[cfg_attr(miri, ignore)]
5307    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5308        let dir = test_dir("invalid-catalog-consumer-glob");
5309        let path = dir.path().join(".fallowrc.json");
5310        std::fs::write(
5311            &path,
5312            r#"{
5313                "ignoreCatalogReferences": [
5314                    {"package": "react", "consumer": "[invalid-glob"}
5315                ]
5316            }"#,
5317        )
5318        .unwrap();
5319
5320        let result = FallowConfig::load(&path);
5321        assert!(result.is_err());
5322        let err = result.unwrap_err().to_string();
5323        assert!(
5324            err.contains("ignoreCatalogReferences"),
5325            "error should mention the field: {err}"
5326        );
5327    }
5328
5329    #[test]
5330    #[cfg_attr(miri, ignore)]
5331    fn load_accepts_ignore_catalog_references_without_consumer() {
5332        let dir = test_dir("catalog-ref-no-consumer");
5333        let path = dir.path().join(".fallowrc.json");
5334        std::fs::write(
5335            &path,
5336            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5337        )
5338        .unwrap();
5339
5340        let config = FallowConfig::load(&path).unwrap();
5341        assert_eq!(config.ignore_catalog_references.len(), 1);
5342        assert!(config.ignore_catalog_references[0].consumer.is_none());
5343    }
5344
5345    #[test]
5346    #[cfg_attr(miri, ignore)]
5347    fn load_accepts_unused_component_props_ignore_pattern() {
5348        let dir = test_dir("unused-component-props-ignore-pattern");
5349        let path = dir.path().join(".fallowrc.json");
5350        std::fs::write(
5351            &path,
5352            r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5353        )
5354        .unwrap();
5355
5356        let config = FallowConfig::load(&path).unwrap();
5357        assert_eq!(
5358            config.unused_component_props.ignore_pattern.as_deref(),
5359            Some("^_")
5360        );
5361    }
5362
5363    #[test]
5364    #[cfg_attr(miri, ignore)]
5365    fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5366        let dir = test_dir("unused-component-props-bad-regex");
5367        let path = dir.path().join(".fallowrc.json");
5368        // `[` opens an unterminated character class: invalid regex.
5369        std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5370
5371        let result = FallowConfig::load(&path);
5372        assert!(result.is_err());
5373        let err = result.unwrap_err().to_string();
5374        assert!(
5375            err.contains("unusedComponentProps.ignorePattern"),
5376            "error should mention the field: {err}"
5377        );
5378    }
5379
5380    #[test]
5381    #[cfg_attr(miri, ignore)]
5382    fn load_rejects_unknown_unused_component_props_field() {
5383        let dir = test_dir("unused-component-props-unknown-field");
5384        let path = dir.path().join(".fallowrc.json");
5385        std::fs::write(
5386            &path,
5387            r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5388        )
5389        .unwrap();
5390
5391        // `deny_unknown_fields` rejects the plural typo.
5392        assert!(FallowConfig::load(&path).is_err());
5393    }
5394
5395    #[test]
5396    #[cfg_attr(miri, ignore)]
5397    fn load_rejects_unknown_override_entry_key() {
5398        let dir = test_dir("override-entry-unknown-key");
5399        let path = dir.path().join(".fallowrc.json");
5400        std::fs::write(
5401            &path,
5402            r#"{"overrides": [{"files": ["src/**"], "rule": {"unused-files": "off"}}]}"#,
5403        )
5404        .unwrap();
5405
5406        // `deny_unknown_fields` rejects the `rule` typo instead of silently
5407        // producing an override with empty rules.
5408        assert!(FallowConfig::load(&path).is_err());
5409    }
5410
5411    #[test]
5412    #[cfg_attr(miri, ignore)]
5413    fn load_rejects_unknown_ignore_exports_entry_key() {
5414        let dir = test_dir("ignore-exports-entry-unknown-key");
5415        let path = dir.path().join(".fallowrc.json");
5416        std::fs::write(
5417            &path,
5418            r#"{"ignoreExports": [{"file": "src/a.ts", "exports": ["*"], "reason": "legacy"}]}"#,
5419        )
5420        .unwrap();
5421
5422        assert!(FallowConfig::load(&path).is_err());
5423    }
5424
5425    // ------------------------------------------------------------------
5426    // validate_resolved_boundaries: tsconfig rootDir filtering
5427    // (covers lines 1158-1160 - rootDir value is ".", starts with "..", or
5428    // is absolute; all should fall back to "src")
5429    // ------------------------------------------------------------------
5430
5431    #[test]
5432    #[cfg_attr(miri, ignore)]
5433    fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
5434        // No tsconfig.json present; parse_tsconfig_root_dir returns None,
5435        // unwrap_or_else supplies "src". This exercises the filter + fallback branch.
5436        let dir = test_dir("boundaries-preset-no-tsconfig");
5437        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5438        let config = FallowConfig {
5439            boundaries: crate::BoundaryConfig {
5440                coverage: crate::BoundaryCoverageConfig::default(),
5441                calls: crate::BoundaryCallsConfig::default(),
5442                preset: Some(crate::BoundaryPreset::Bulletproof),
5443                zones: vec![],
5444                rules: vec![],
5445            },
5446            ..FallowConfig::default()
5447        };
5448        // Should not panic; no zone-ref errors expected since preset adds zones
5449        let _ = config.validate_resolved_boundaries(dir.path());
5450    }
5451
5452    // ------------------------------------------------------------------
5453    // validate_user_globs: framework plugin invalid glob triggers error path
5454    // (covers lines 970-974)
5455    // ------------------------------------------------------------------
5456
5457    #[test]
5458    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5459        use crate::ExternalPluginDef;
5460        use crate::external_plugin::EntryPointRole;
5461        let config = FallowConfig {
5462            framework: vec![ExternalPluginDef {
5463                schema: None,
5464                name: "test-plugin".to_owned(),
5465                detection: None,
5466                enablers: vec![],
5467                entry_points: vec!["[invalid-glob".to_owned()],
5468                entry_point_role: EntryPointRole::Support,
5469                manifest_entries: vec![],
5470                config_patterns: vec![],
5471                always_used: vec![],
5472                tooling_dependencies: vec![],
5473                used_exports: vec![],
5474                used_class_members: vec![],
5475            }],
5476            ..FallowConfig::default()
5477        };
5478
5479        let result = config.validate_user_globs();
5480        assert!(
5481            result.is_err(),
5482            "invalid entry_points glob should fail validation"
5483        );
5484        let errors = result.unwrap_err();
5485        assert!(!errors.is_empty());
5486    }
5487
5488    // ------------------------------------------------------------------
5489    // shadowed_config_names: no lower-precedence names after the last index
5490    // ------------------------------------------------------------------
5491
5492    #[test]
5493    #[cfg_attr(miri, ignore)]
5494    fn shadowed_config_names_empty_when_last_config_wins() {
5495        let dir = test_dir("shadow-last");
5496        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5497        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
5498        assert!(shadowed_config_names(dir.path(), 3).is_empty());
5499    }
5500
5501    // ------------------------------------------------------------------
5502    // warn_on_coexisting_configs: path without filename (edge branch)
5503    // shadowed is empty -> early return without recording
5504    // ------------------------------------------------------------------
5505
5506    #[test]
5507    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5508        let ((), captured) = capture_coexisting_config_warnings(|| {
5509            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5510        });
5511        assert!(
5512            captured.is_empty(),
5513            "empty shadowed list must produce no warning"
5514        );
5515    }
5516
5517    // ------------------------------------------------------------------
5518    // extract_extends: malformed values fail loud instead of silently
5519    // skipping the base config (the key is removed before deserialization,
5520    // so deny_unknown_fields cannot catch these)
5521    // ------------------------------------------------------------------
5522
5523    #[test]
5524    fn extract_extends_rejects_number_value() {
5525        let mut value = serde_json::json!({"extends": 42});
5526        let err = extract_extends(&mut value, "test.json").unwrap_err();
5527        assert!(
5528            err.to_string()
5529                .contains("extends must be a string or an array of strings, got a number"),
5530            "unexpected error: {err}"
5531        );
5532    }
5533
5534    #[test]
5535    fn extract_extends_rejects_object_value() {
5536        let mut value = serde_json::json!({"extends": {"path": "./base.json"}});
5537        let err = extract_extends(&mut value, "test.json").unwrap_err();
5538        assert!(
5539            err.to_string()
5540                .contains("extends must be a string or an array of strings, got an object"),
5541            "unexpected error: {err}"
5542        );
5543    }
5544
5545    #[test]
5546    fn extract_extends_rejects_non_string_array_entry() {
5547        let mut value = serde_json::json!({"extends": ["a.json", 42]});
5548        let err = extract_extends(&mut value, "test.json").unwrap_err();
5549        assert!(
5550            err.to_string()
5551                .contains("extends entries must be strings, got a number"),
5552            "unexpected error: {err}"
5553        );
5554    }
5555
5556    #[test]
5557    #[cfg_attr(miri, ignore)]
5558    fn load_rejects_malformed_extends_value() {
5559        let dir = test_dir("malformed-extends");
5560        let path = dir.path().join(".fallowrc.json");
5561        std::fs::write(&path, r#"{"extends": 42}"#).unwrap();
5562
5563        let err = FallowConfig::load(&path).unwrap_err();
5564        assert!(
5565            err.to_string()
5566                .contains("extends must be a string or an array of strings"),
5567            "unexpected error: {err}"
5568        );
5569    }
5570
5571    // ------------------------------------------------------------------
5572    // Typed resource identities keep local and remote namespaces disjoint.
5573    // ------------------------------------------------------------------
5574
5575    #[test]
5576    #[cfg_attr(miri, ignore)]
5577    fn config_resource_identity_cannot_collide_across_kinds() {
5578        let dir = test_dir("visit-circular");
5579        let path = dir.path().join("config.json");
5580        std::fs::write(&path, "{}").unwrap();
5581
5582        let canonical = dunce::canonicalize(path).unwrap();
5583        let local = ConfigResourceId::Local(canonical.clone());
5584        let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
5585        assert_ne!(local, remote);
5586    }
5587
5588    // ------------------------------------------------------------------
5589    // find_and_load: stops at .svn dir (is_repo_root branch)
5590    // ------------------------------------------------------------------
5591
5592    #[test]
5593    #[cfg_attr(miri, ignore)]
5594    fn find_config_path_stops_at_svn_dir() {
5595        let dir = test_dir("find-path-svn");
5596        let sub = dir.path().join("sub");
5597        std::fs::create_dir(&sub).unwrap();
5598        std::fs::create_dir(dir.path().join(".svn")).unwrap();
5599
5600        let path = FallowConfig::find_config_path(&sub);
5601        assert!(path.is_none(), "svn root should stop config search");
5602    }
5603
5604    // ------------------------------------------------------------------
5605    // deep_merge: array over object replaces
5606    // ------------------------------------------------------------------
5607
5608    #[test]
5609    fn deep_merge_array_over_object_replaces() {
5610        let mut base = serde_json::json!({"key": "value"});
5611        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
5612        assert_eq!(base, serde_json::json!(["a", "b"]));
5613    }
5614
5615    // ------------------------------------------------------------------
5616    // find_and_load: returns an error when config parses but glob validation fails
5617    // ------------------------------------------------------------------
5618
5619    #[test]
5620    #[cfg_attr(miri, ignore)]
5621    fn find_and_load_returns_error_for_invalid_glob_in_config() {
5622        let dir = test_dir("find-invalid-glob");
5623        std::fs::create_dir(dir.path().join(".git")).unwrap();
5624        std::fs::write(
5625            dir.path().join(".fallowrc.json"),
5626            r#"{"entry": ["[invalid-glob"]}"#,
5627        )
5628        .unwrap();
5629
5630        let result = FallowConfig::find_and_load(dir.path());
5631        assert!(
5632            result.is_err(),
5633            "invalid glob should surface as an error from find_and_load"
5634        );
5635    }
5636
5637    #[test]
5638    #[cfg_attr(miri, ignore)]
5639    fn find_and_load_rejects_bare_finding_ignore_negation() {
5640        let dir = test_dir("find-bare-finding-ignore-negation");
5641        std::fs::create_dir(dir.path().join(".git")).unwrap();
5642        std::fs::write(
5643            dir.path().join(".fallowrc.json"),
5644            r#"{"ignoreFindings": ["!"]}"#,
5645        )
5646        .unwrap();
5647
5648        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5649        assert!(error.contains("ignoreFindings"), "error: {error}");
5650        assert!(error.contains("requires a pattern"), "error: {error}");
5651    }
5652
5653    #[test]
5654    #[cfg_attr(miri, ignore)]
5655    fn find_and_load_rejects_finding_ignore_set_that_exceeds_matcher_limits() {
5656        let dir = test_dir("find-oversized-finding-ignore-set");
5657        std::fs::create_dir(dir.path().join(".git")).unwrap();
5658        let alternatives = (0..50_000)
5659            .map(|index| format!("name{index}"))
5660            .collect::<Vec<_>>()
5661            .join(",");
5662        let config = serde_json::json!({
5663            "ignoreFindings": [format!("**/{{{alternatives}}}.ts")]
5664        });
5665        std::fs::write(
5666            dir.path().join(".fallowrc.json"),
5667            serde_json::to_vec(&config).unwrap(),
5668        )
5669        .unwrap();
5670
5671        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5672        assert!(error.contains("ignoreFindings"), "error: {error}");
5673        assert!(
5674            error.contains("cannot be compiled together"),
5675            "error: {error}"
5676        );
5677    }
5678
5679    // ------------------------------------------------------------------
5680    // resolve_package_exports: Object map with "." key that is not a
5681    // string or object returns None (the `_ => None` arm)
5682    // ------------------------------------------------------------------
5683
5684    #[test]
5685    fn resolve_package_exports_dot_key_array_returns_none() {
5686        // "." value is an array, which is neither String nor Object
5687        let pkg = serde_json::json!({
5688            "exports": {".": ["array-value"]}
5689        });
5690        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5691        assert!(result.is_none(), "array dot-export should return None");
5692    }
5693
5694    #[test]
5695    fn resolve_package_exports_exports_is_array_returns_none() {
5696        // top-level "exports" is an array (not String or Object)
5697        let pkg = serde_json::json!({
5698            "exports": ["./index.js"]
5699        });
5700        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5701        assert!(result.is_none(), "array-form exports should return None");
5702    }
5703
5704    #[test]
5705    fn resolve_package_exports_object_no_dot_key_returns_none() {
5706        // Object exports without "." key
5707        let pkg = serde_json::json!({
5708            "exports": {"./sub": "./sub.js"}
5709        });
5710        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5711        assert!(result.is_none(), "no dot key should return None");
5712    }
5713
5714    #[test]
5715    fn resolve_package_exports_conditions_without_known_key_returns_none() {
5716        // "." is an Object but none of the known condition keys are present
5717        let pkg = serde_json::json!({
5718            "exports": {".": {"browser": "./browser.js"}}
5719        });
5720        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5721        assert!(result.is_none(), "unknown condition key should return None");
5722    }
5723
5724    // ------------------------------------------------------------------
5725    // npm package: exports condition "import" key (one of the priority keys)
5726    // ------------------------------------------------------------------
5727
5728    #[test]
5729    #[cfg_attr(miri, ignore)]
5730    fn extends_npm_exports_import_condition() {
5731        let dir = test_dir("npm-import-cond");
5732        let pkg_dir = dir.path().join("node_modules/import-config");
5733        std::fs::create_dir_all(&pkg_dir).unwrap();
5734        std::fs::write(
5735            pkg_dir.join("package.json"),
5736            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
5737        )
5738        .unwrap();
5739        std::fs::write(
5740            pkg_dir.join("esm.json"),
5741            r#"{"rules": {"unused-types": "warn"}}"#,
5742        )
5743        .unwrap();
5744
5745        std::fs::write(
5746            dir.path().join(".fallowrc.json"),
5747            r#"{"extends": "npm:import-config"}"#,
5748        )
5749        .unwrap();
5750
5751        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5752        assert_eq!(config.rules.unused_types, Severity::Warn);
5753    }
5754
5755    // ------------------------------------------------------------------
5756    // npm package: exports condition "require" key
5757    // ------------------------------------------------------------------
5758
5759    #[test]
5760    #[cfg_attr(miri, ignore)]
5761    fn extends_npm_exports_require_condition() {
5762        let dir = test_dir("npm-require-cond");
5763        let pkg_dir = dir.path().join("node_modules/require-config");
5764        std::fs::create_dir_all(&pkg_dir).unwrap();
5765        std::fs::write(
5766            pkg_dir.join("package.json"),
5767            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
5768        )
5769        .unwrap();
5770        std::fs::write(
5771            pkg_dir.join("cjs.json"),
5772            r#"{"rules": {"unused-class-members": "warn"}}"#,
5773        )
5774        .unwrap();
5775
5776        std::fs::write(
5777            dir.path().join(".fallowrc.json"),
5778            r#"{"extends": "npm:require-config"}"#,
5779        )
5780        .unwrap();
5781
5782        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5783        assert_eq!(config.rules.unused_class_members, Severity::Warn);
5784    }
5785}