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`, `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`, `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.health.ignore, "health.ignore", errors);
1254        for override_entry in &self.health.threshold_overrides {
1255            validate_user_globs(
1256                &override_entry.files,
1257                "health.thresholdOverrides[].files",
1258                errors,
1259            );
1260        }
1261        for override_entry in &self.overrides {
1262            validate_user_globs(&override_entry.files, "overrides[].files", errors);
1263        }
1264    }
1265
1266    /// Validate the `ignoreExports` and `ignoreCatalogReferences` rule globs.
1267    fn validate_ignore_rule_globs(
1268        &self,
1269        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1270    ) {
1271        use super::glob_validation::compile_user_glob;
1272
1273        for rule in &self.ignore_exports {
1274            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1275                errors.push(e);
1276            }
1277        }
1278
1279        for rule in &self.ignore_catalog_references {
1280            if let Some(consumer) = &rule.consumer
1281                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1282            {
1283                errors.push(e);
1284            }
1285        }
1286    }
1287
1288    /// Validate the `boundaries.zones[]` patterns/roots/autoDiscover and the
1289    /// coverage `allowUnmatched` globs.
1290    fn validate_boundary_globs(
1291        &self,
1292        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1293    ) {
1294        use super::glob_validation::{
1295            validate_user_globs, validate_user_path, validate_user_paths,
1296        };
1297
1298        for zone in &self.boundaries.zones {
1299            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1300            if let Some(root) = &zone.root
1301                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1302            {
1303                errors.push(e);
1304            }
1305            validate_user_paths(
1306                &zone.auto_discover,
1307                "boundaries.zones[].autoDiscover",
1308                errors,
1309            );
1310        }
1311        validate_user_globs(
1312            &self.boundaries.coverage.allow_unmatched,
1313            "boundaries.coverage.allowUnmatched",
1314            errors,
1315        );
1316    }
1317
1318    /// Find the config file path without loading it.
1319    /// Searches the same locations as `find_and_load`.
1320    #[must_use]
1321    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1322        let mut dir = start;
1323        loop {
1324            for name in CONFIG_NAMES {
1325                let candidate = dir.join(name);
1326                if candidate.exists() {
1327                    return Some(candidate);
1328                }
1329            }
1330            if is_repo_root(dir) {
1331                break;
1332            }
1333            dir = dir.parent()?;
1334        }
1335        None
1336    }
1337
1338    /// Find and load config, searching from `start` up to the project root.
1339    ///
1340    /// # Errors
1341    ///
1342    /// Returns an error if a config file is found but cannot be read or parsed.
1343    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1344        Self::find_and_load_with_options(start, ConfigLoadOptions::default())
1345    }
1346
1347    /// Find and load config with a host-controlled inheritance trust policy.
1348    ///
1349    /// # Errors
1350    ///
1351    /// Returns an error if a config file is found but cannot be read, parsed,
1352    /// or is not permitted by `options`.
1353    pub fn find_and_load_with_options(
1354        start: &Path,
1355        options: ConfigLoadOptions,
1356    ) -> Result<Option<(Self, PathBuf)>, String> {
1357        let mut dir = start;
1358        loop {
1359            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1360                let candidate = dir.join(name);
1361                if candidate.exists() {
1362                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1363                    match Self::load_with_options(&candidate, options) {
1364                        Ok(config) => return Ok(Some((config, candidate))),
1365                        Err(e) => {
1366                            // Most load errors already name the config file;
1367                            // add the path only when the reason lacks it, so
1368                            // it is mentioned exactly once.
1369                            let msg = e.to_string();
1370                            return Err(if msg.contains(&candidate.display().to_string()) {
1371                                msg
1372                            } else {
1373                                format!("Failed to parse {}: {msg}", candidate.display())
1374                            });
1375                        }
1376                    }
1377                }
1378            }
1379            if is_repo_root(dir) {
1380                break;
1381            }
1382            dir = match dir.parent() {
1383                Some(parent) => parent,
1384                None => break,
1385            };
1386        }
1387        Ok(None)
1388    }
1389
1390    /// Generate JSON Schema for the configuration format.
1391    #[must_use]
1392    pub fn json_schema() -> serde_json::Value {
1393        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1394    }
1395
1396    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1397    /// preset and auto-discover expansion.
1398    ///
1399    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1400    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1401    /// before invoking
1402    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1403    /// and
1404    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1405    /// so Bulletproof-style presets whose authored rule references logical
1406    /// groups (`features`) still load cleanly.
1407    ///
1408    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1409    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1410    /// surface every collected error in a single rendered diagnostic, then
1411    /// exit with code 2. Previously these failures emitted `tracing::error!`
1412    /// and continued, producing a flood of false-positive boundary violations
1413    /// at analysis time (#468).
1414    ///
1415    /// `root` is the project root used by `expand_auto_discover` to scan for
1416    /// child directories. Caller is responsible for passing the same root it
1417    /// later hands to `resolve()`.
1418    ///
1419    /// # Errors
1420    ///
1421    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1422    /// offending zone reference and redundant-root-prefix pattern; the empty
1423    /// case becomes `Ok(())`.
1424    pub fn validate_resolved_boundaries(
1425        &self,
1426        root: &Path,
1427    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1428        use super::boundaries::ZoneValidationError;
1429
1430        let mut boundaries = self.boundaries.clone();
1431        if boundaries.preset.is_some() {
1432            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1433                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1434                .unwrap_or_else(|| "src".to_owned());
1435            boundaries.expand(&source_root);
1436        }
1437        let _logical_groups = boundaries.expand_auto_discover(root);
1438
1439        let mut errors: Vec<ZoneValidationError> = boundaries
1440            .validate_zone_references()
1441            .into_iter()
1442            .map(ZoneValidationError::UnknownZoneReference)
1443            .collect();
1444        errors.extend(
1445            boundaries
1446                .validate_root_prefixes()
1447                .into_iter()
1448                .map(ZoneValidationError::RedundantRootPrefix),
1449        );
1450        errors.extend(
1451            boundaries
1452                .validate_call_rules()
1453                .into_iter()
1454                .map(ZoneValidationError::InvalidForbiddenCallee),
1455        );
1456
1457        if errors.is_empty() {
1458            Ok(())
1459        } else {
1460            Err(errors)
1461        }
1462    }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468    use crate::CacheConfig;
1469    use crate::PackageJson;
1470    use crate::config::format::OutputFormat;
1471    use crate::config::rules::Severity;
1472
1473    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1474    fn test_dir(_name: &str) -> tempfile::TempDir {
1475        tempfile::tempdir().expect("create temp dir")
1476    }
1477
1478    #[derive(Default)]
1479    struct MockRemoteFetcher {
1480        responses: rustc_hash::FxHashMap<String, serde_json::Value>,
1481        requests: Vec<String>,
1482    }
1483
1484    impl MockRemoteFetcher {
1485        fn with_response(mut self, url: &str, value: serde_json::Value) -> Self {
1486            self.responses.insert(url.to_string(), value);
1487            self
1488        }
1489    }
1490
1491    impl RemoteConfigFetcher for MockRemoteFetcher {
1492        fn fetch(&mut self, url: &str, _source: &str) -> Result<serde_json::Value, miette::Report> {
1493            self.requests.push(url.to_string());
1494            self.responses
1495                .get(url)
1496                .cloned()
1497                .ok_or_else(|| miette::miette!("missing mock response for {url}"))
1498        }
1499    }
1500
1501    #[test]
1502    fn fallow_config_deserialize_minimal() {
1503        let toml_str = r#"
1504entry = ["src/main.ts"]
1505"#;
1506        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1507        assert_eq!(config.entry, vec!["src/main.ts"]);
1508        assert!(config.ignore_patterns.is_empty());
1509    }
1510
1511    #[test]
1512    fn fallow_config_deserialize_ignore_exports() {
1513        let toml_str = r#"
1514[[ignoreExports]]
1515file = "src/types/*.ts"
1516exports = ["*"]
1517
1518[[ignoreExports]]
1519file = "src/constants.ts"
1520exports = ["FOO", "BAR"]
1521"#;
1522        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1523        assert_eq!(config.ignore_exports.len(), 2);
1524        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1525        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1526        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1527    }
1528
1529    #[test]
1530    fn fallow_config_deserialize_ignore_dependencies() {
1531        let toml_str = r#"
1532ignoreDependencies = ["autoprefixer", "postcss"]
1533"#;
1534        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1535        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1536    }
1537
1538    #[test]
1539    fn fallow_config_deserialize_ignore_unresolved_imports() {
1540        let toml_str = r#"
1541ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1542"#;
1543        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1544        assert_eq!(
1545            config.ignore_unresolved_imports,
1546            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1547        );
1548    }
1549
1550    #[test]
1551    fn fallow_config_resolve_default_ignores() {
1552        let config = FallowConfig::default();
1553        let resolved = config.resolve(
1554            PathBuf::from("/tmp/test"),
1555            OutputFormat::Human,
1556            4,
1557            true,
1558            true,
1559            None,
1560        );
1561
1562        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1563        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1564        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1565        assert!(resolved.ignore_patterns.is_match(".git/config"));
1566        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1567        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1568        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1569    }
1570
1571    #[test]
1572    fn fallow_config_resolve_custom_ignores() {
1573        let config = FallowConfig {
1574            entry: vec!["src/**/*.ts".to_string()],
1575            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1576            ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
1577            ..Default::default()
1578        };
1579        let resolved = config.resolve(
1580            PathBuf::from("/tmp/test"),
1581            OutputFormat::Json,
1582            4,
1583            false,
1584            true,
1585            None,
1586        );
1587
1588        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1589        assert!(
1590            resolved
1591                .ignore_findings
1592                .is_ignored("src/private/app.test.ts")
1593        );
1594        assert!(
1595            !resolved
1596                .ignore_findings
1597                .is_ignored("src/public/app.test.ts")
1598        );
1599        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1600        assert!(matches!(resolved.output, OutputFormat::Json));
1601        assert!(!resolved.no_cache);
1602    }
1603
1604    #[test]
1605    fn fallow_config_resolve_cache_dir() {
1606        let config = FallowConfig::default();
1607        let resolved = config.resolve(
1608            PathBuf::from("/tmp/project"),
1609            OutputFormat::Human,
1610            4,
1611            true,
1612            true,
1613            None,
1614        );
1615        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1616        assert!(resolved.no_cache);
1617    }
1618
1619    #[test]
1620    fn package_json_entry_points_main() {
1621        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1622        let entries = pkg.entry_points();
1623        assert!(entries.contains(&"dist/index.js".to_string()));
1624    }
1625
1626    #[test]
1627    fn package_json_entry_points_module() {
1628        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1629        let entries = pkg.entry_points();
1630        assert!(entries.contains(&"dist/index.mjs".to_string()));
1631    }
1632
1633    #[test]
1634    fn package_json_entry_points_types() {
1635        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
1636        let entries = pkg.entry_points();
1637        assert!(entries.contains(&"dist/index.d.ts".to_string()));
1638    }
1639
1640    #[test]
1641    fn package_json_entry_points_bin_string() {
1642        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
1643        let entries = pkg.entry_points();
1644        assert!(entries.contains(&"bin/cli.js".to_string()));
1645    }
1646
1647    #[test]
1648    fn package_json_entry_points_bin_object() {
1649        let pkg: PackageJson =
1650            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
1651                .unwrap();
1652        let entries = pkg.entry_points();
1653        assert!(entries.contains(&"bin/cli.js".to_string()));
1654        assert!(entries.contains(&"bin/serve.js".to_string()));
1655    }
1656
1657    #[test]
1658    fn package_json_entry_points_exports_string() {
1659        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
1660        let entries = pkg.entry_points();
1661        assert!(entries.contains(&"./dist/index.js".to_string()));
1662    }
1663
1664    #[test]
1665    fn package_json_entry_points_exports_object() {
1666        let pkg: PackageJson = serde_json::from_str(
1667            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
1668        )
1669        .unwrap();
1670        let entries = pkg.entry_points();
1671        assert!(entries.contains(&"./dist/index.mjs".to_string()));
1672        assert!(entries.contains(&"./dist/index.cjs".to_string()));
1673    }
1674
1675    #[test]
1676    fn package_json_dependency_names() {
1677        let pkg: PackageJson = serde_json::from_str(
1678            r#"{
1679            "dependencies": {"react": "^18", "lodash": "^4"},
1680            "devDependencies": {"typescript": "^5"},
1681            "peerDependencies": {"react-dom": "^18"}
1682        }"#,
1683        )
1684        .unwrap();
1685
1686        let all = pkg.all_dependency_names();
1687        assert!(all.contains(&"react".to_string()));
1688        assert!(all.contains(&"lodash".to_string()));
1689        assert!(all.contains(&"typescript".to_string()));
1690        assert!(all.contains(&"react-dom".to_string()));
1691
1692        let prod = pkg.production_dependency_names();
1693        assert!(prod.contains(&"react".to_string()));
1694        assert!(!prod.contains(&"typescript".to_string()));
1695
1696        let dev = pkg.dev_dependency_names();
1697        assert!(dev.contains(&"typescript".to_string()));
1698        assert!(!dev.contains(&"react".to_string()));
1699    }
1700
1701    #[test]
1702    fn package_json_no_dependencies() {
1703        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1704        assert!(pkg.all_dependency_names().is_empty());
1705        assert!(pkg.production_dependency_names().is_empty());
1706        assert!(pkg.dev_dependency_names().is_empty());
1707        assert!(pkg.entry_points().is_empty());
1708    }
1709
1710    #[test]
1711    fn rules_deserialize_toml_kebab_case() {
1712        let toml_str = r#"
1713[rules]
1714unused-files = "error"
1715unused-exports = "warn"
1716unused-types = "off"
1717"#;
1718        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1719        assert_eq!(config.rules.unused_files, Severity::Error);
1720        assert_eq!(config.rules.unused_exports, Severity::Warn);
1721        assert_eq!(config.rules.unused_types, Severity::Off);
1722        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1723    }
1724
1725    #[test]
1726    fn config_without_rules_defaults_to_error() {
1727        let toml_str = r#"
1728entry = ["src/main.ts"]
1729"#;
1730        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1731        assert_eq!(config.rules.unused_files, Severity::Error);
1732        assert_eq!(config.rules.unused_exports, Severity::Error);
1733    }
1734
1735    #[test]
1736    fn fallow_config_denies_unknown_fields() {
1737        let toml_str = r"
1738unknown_field = true
1739";
1740        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1741        assert!(result.is_err());
1742    }
1743
1744    #[test]
1745    fn fallow_config_deserialize_json() {
1746        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1747        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1748        assert_eq!(config.entry, vec!["src/main.ts"]);
1749    }
1750
1751    #[test]
1752    fn fallow_config_deserialize_jsonc() {
1753        let jsonc_str = r#"{
1754            "entry": ["src/main.ts"],
1755            "rules": {
1756                "unused-files": "warn"
1757            }
1758        }"#;
1759        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
1760        assert_eq!(config.entry, vec!["src/main.ts"]);
1761        assert_eq!(config.rules.unused_files, Severity::Warn);
1762    }
1763
1764    #[test]
1765    fn fallow_config_json_with_schema_field() {
1766        let json_str =
1767            r#"{"$schema": "./node_modules/fallow/schema.json", "entry": ["src/main.ts"]}"#;
1768        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1769        assert_eq!(config.entry, vec!["src/main.ts"]);
1770    }
1771
1772    #[test]
1773    fn fallow_config_json_schema_generation() {
1774        let schema = FallowConfig::json_schema();
1775        assert!(schema.is_object());
1776        let obj = schema.as_object().unwrap();
1777        assert!(obj.contains_key("properties"));
1778    }
1779
1780    #[test]
1781    fn config_format_detection() {
1782        assert!(matches!(
1783            ConfigFormat::from_path(Path::new("fallow.toml")),
1784            ConfigFormat::Toml
1785        ));
1786        assert!(matches!(
1787            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1788            ConfigFormat::Json
1789        ));
1790        assert!(matches!(
1791            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
1792            ConfigFormat::Json
1793        ));
1794        assert!(matches!(
1795            ConfigFormat::from_path(Path::new(".fallow.toml")),
1796            ConfigFormat::Toml
1797        ));
1798    }
1799
1800    #[test]
1801    fn config_names_priority_order() {
1802        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1803        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
1804        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
1805        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
1806    }
1807
1808    #[test]
1809    fn load_json_config_file() {
1810        let dir = test_dir("json-config");
1811        let config_path = dir.path().join(".fallowrc.json");
1812        std::fs::write(
1813            &config_path,
1814            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1815        )
1816        .unwrap();
1817
1818        let config = FallowConfig::load(&config_path).unwrap();
1819        assert_eq!(config.entry, vec!["src/index.ts"]);
1820        assert_eq!(config.rules.unused_exports, Severity::Warn);
1821    }
1822
1823    #[test]
1824    fn load_records_explicit_private_type_leaks_setting() {
1825        let dir = test_dir("explicit-private-type-leaks");
1826        let config_path = dir.path().join(".fallowrc.json");
1827        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
1828
1829        let config = FallowConfig::load(&config_path).unwrap();
1830        assert_eq!(config.rules.private_type_leaks, Severity::Off);
1831        assert!(config.rules.private_type_leaks_configured);
1832    }
1833
1834    #[test]
1835    fn load_records_explicit_private_type_leaks_singular_alias() {
1836        let dir = test_dir("explicit-private-type-leak-alias");
1837        let config_path = dir.path().join(".fallowrc.json");
1838        std::fs::write(&config_path, r#"{"rules": {"private-type-leak": "off"}}"#).unwrap();
1839
1840        let config = FallowConfig::load(&config_path).unwrap();
1841        assert!(config.rules.private_type_leaks_configured);
1842    }
1843
1844    #[test]
1845    fn load_leaves_defaulted_private_type_leaks_unmarked() {
1846        let dir = test_dir("defaulted-private-type-leaks");
1847        let config_path = dir.path().join(".fallowrc.json");
1848        std::fs::write(&config_path, r#"{"rules": {"unused-exports": "warn"}}"#).unwrap();
1849
1850        let config = FallowConfig::load(&config_path).unwrap();
1851        assert_eq!(config.rules.private_type_leaks, Severity::Off);
1852        assert!(!config.rules.private_type_leaks_configured);
1853    }
1854
1855    /// The explicit-configuration flag is `serde(skip)`, so a serde round-trip
1856    /// of a loaded config drops it and the type-aware `warn` default would
1857    /// force the rule back on. No production path round-trips a
1858    /// [`FallowConfig`] today; this test pins the hazard so introducing one
1859    /// fails loudly instead of silently re-enabling an explicitly-off rule.
1860    #[test]
1861    fn serde_round_trip_drops_explicit_private_type_leaks_flag() {
1862        let dir = test_dir("round-trip-private-type-leaks");
1863        let config_path = dir.path().join(".fallowrc.json");
1864        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
1865
1866        let config = FallowConfig::load(&config_path).unwrap();
1867        assert!(config.rules.private_type_leaks_configured);
1868
1869        let serialized = serde_json::to_value(&config).unwrap();
1870        let round_tripped: FallowConfig = serde_json::from_value(serialized).unwrap();
1871
1872        assert_eq!(round_tripped.rules.private_type_leaks, Severity::Off);
1873        assert!(
1874            !round_tripped.rules.private_type_leaks_configured,
1875            "a serde round-trip drops the serde(skip) flag; any code path that \
1876             round-trips a loaded FallowConfig must re-record it (see the field \
1877             docs on RulesConfig::private_type_leaks_configured)"
1878        );
1879    }
1880
1881    #[test]
1882    fn load_json_config_file_with_health_threshold_override() {
1883        let dir = test_dir("json-health-threshold-override");
1884        let config_path = dir.path().join(".fallowrc.json");
1885        std::fs::write(
1886            &config_path,
1887            r#"{
1888                "health": {
1889                    "thresholdOverrides": [
1890                        {
1891                            "files": ["src/legacy.ts"],
1892                            "functions": ["legacyFlow"],
1893                            "maxCyclomatic": 30,
1894                            "maxCognitive": 25,
1895                            "maxCrap": 80.5,
1896                            "reason": "legacy migration"
1897                        }
1898                    ]
1899                }
1900            }"#,
1901        )
1902        .unwrap();
1903
1904        let config = FallowConfig::load(&config_path).unwrap();
1905        let override_config = &config.health.threshold_overrides[0];
1906        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1907        assert_eq!(override_config.functions, vec!["legacyFlow"]);
1908        assert_eq!(override_config.max_cyclomatic, Some(30));
1909        assert_eq!(override_config.max_cognitive, Some(25));
1910        assert_eq!(override_config.max_crap, Some(80.5));
1911        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
1912    }
1913
1914    #[test]
1915    fn load_jsonc_config_file() {
1916        let dir = test_dir("jsonc-config");
1917        let config_path = dir.path().join(".fallowrc.json");
1918        std::fs::write(
1919            &config_path,
1920            r#"{
1921                "entry": ["src/index.ts"],
1922                /* Block comment */
1923                "rules": {
1924                    "unused-exports": "warn"
1925                }
1926            }"#,
1927        )
1928        .unwrap();
1929
1930        let config = FallowConfig::load(&config_path).unwrap();
1931        assert_eq!(config.entry, vec!["src/index.ts"]);
1932        assert_eq!(config.rules.unused_exports, Severity::Warn);
1933    }
1934
1935    #[test]
1936    fn load_jsonc_config_file_with_health_threshold_override() {
1937        let dir = test_dir("jsonc-health-threshold-override");
1938        let config_path = dir.path().join(".fallowrc.jsonc");
1939        std::fs::write(
1940            &config_path,
1941            r#"{
1942                "health": {
1943                    // Empty functions means every function in matching files.
1944                    "thresholdOverrides": [
1945                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
1946                    ]
1947                }
1948            }"#,
1949        )
1950        .unwrap();
1951
1952        let config = FallowConfig::load(&config_path).unwrap();
1953        let override_config = &config.health.threshold_overrides[0];
1954        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
1955        assert!(override_config.functions.is_empty());
1956        assert_eq!(override_config.max_cognitive, Some(25));
1957    }
1958
1959    #[test]
1960    fn load_fallowrc_jsonc_extension() {
1961        let dir = test_dir("jsonc-extension");
1962        let config_path = dir.path().join(".fallowrc.jsonc");
1963        std::fs::write(
1964            &config_path,
1965            r#"{
1966                "ignoreDependencies": ["tailwindcss-react-aria-components"],
1967                "entry": ["src/index.ts"]
1968            }"#,
1969        )
1970        .unwrap();
1971
1972        let config = FallowConfig::load(&config_path).unwrap();
1973        assert_eq!(config.entry, vec!["src/index.ts"]);
1974        assert_eq!(
1975            config.ignore_dependencies,
1976            vec!["tailwindcss-react-aria-components"]
1977        );
1978    }
1979
1980    #[test]
1981    fn json_config_ignore_dependencies_camel_case() {
1982        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1983        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1984        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1985    }
1986
1987    #[test]
1988    fn json_config_ignore_unresolved_imports_camel_case() {
1989        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
1990        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1991        assert_eq!(
1992            config.ignore_unresolved_imports,
1993            vec!["@example/icons", "@example/icons/**"]
1994        );
1995    }
1996
1997    #[test]
1998    fn json_config_all_fields() {
1999        let json_str = r#"{
2000            "ignoreDependencies": ["lodash"],
2001            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
2002            "rules": {
2003                "unused-files": "off",
2004                "unused-exports": "warn",
2005                "unused-dependencies": "error",
2006                "unused-dev-dependencies": "off",
2007                "unused-types": "warn",
2008                "unused-enum-members": "error",
2009                "unused-class-members": "off",
2010                "unresolved-imports": "warn",
2011                "unlisted-dependencies": "error",
2012                "duplicate-exports": "off"
2013            },
2014            "duplicates": {
2015                "minTokens": 100,
2016                "minLines": 10,
2017                "skipLocal": true
2018            }
2019        }"#;
2020        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2021        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
2022        assert_eq!(config.rules.unused_files, Severity::Off);
2023        assert_eq!(config.rules.unused_exports, Severity::Warn);
2024        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2025        assert_eq!(config.duplicates.min_tokens, 100);
2026        assert_eq!(config.duplicates.min_lines, 10);
2027        assert!(config.duplicates.skip_local);
2028    }
2029
2030    #[test]
2031    fn extends_single_base() {
2032        let dir = test_dir("extends-single");
2033
2034        std::fs::write(
2035            dir.path().join("base.json"),
2036            r#"{"rules": {"unused-files": "warn"}}"#,
2037        )
2038        .unwrap();
2039        std::fs::write(
2040            dir.path().join(".fallowrc.json"),
2041            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
2042        )
2043        .unwrap();
2044
2045        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2046        assert_eq!(config.rules.unused_files, Severity::Warn);
2047        assert_eq!(config.entry, vec!["src/index.ts"]);
2048        assert_eq!(config.rules.unused_exports, Severity::Error);
2049    }
2050
2051    #[test]
2052    fn extends_overlay_overrides_base() {
2053        let dir = test_dir("extends-overlay");
2054
2055        std::fs::write(
2056            dir.path().join("base.json"),
2057            r#"{"ignoreFindings": ["generated/**"], "rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
2058        )
2059        .unwrap();
2060        std::fs::write(
2061            dir.path().join(".fallowrc.json"),
2062            r#"{"extends": ["base.json"], "ignoreFindings": ["**/*.test.ts"], "rules": {"unused-files": "error"}}"#,
2063        )
2064        .unwrap();
2065
2066        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2067        assert_eq!(config.ignore_findings, vec!["**/*.test.ts"]);
2068        assert_eq!(config.rules.unused_files, Severity::Error);
2069        assert_eq!(config.rules.unused_exports, Severity::Off);
2070    }
2071
2072    #[test]
2073    fn extends_chained() {
2074        let dir = test_dir("extends-chained");
2075
2076        std::fs::write(
2077            dir.path().join("grandparent.json"),
2078            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
2079        )
2080        .unwrap();
2081        std::fs::write(
2082            dir.path().join("parent.json"),
2083            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
2084        )
2085        .unwrap();
2086        std::fs::write(
2087            dir.path().join(".fallowrc.json"),
2088            r#"{"extends": ["parent.json"]}"#,
2089        )
2090        .unwrap();
2091
2092        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2093        assert_eq!(config.rules.unused_files, Severity::Warn);
2094        assert_eq!(config.rules.unused_exports, Severity::Warn);
2095    }
2096
2097    #[test]
2098    fn extends_local_diamond_reuses_resolved_base() {
2099        let dir = test_dir("extends-local-diamond");
2100        std::fs::write(
2101            dir.path().join("base.json"),
2102            r#"{"ignorePatterns": ["generated/**"]}"#,
2103        )
2104        .unwrap();
2105        std::fs::write(
2106            dir.path().join("left.json"),
2107            r#"{"extends": ["base.json"], "rules": {"unused-files": "warn"}}"#,
2108        )
2109        .unwrap();
2110        std::fs::write(
2111            dir.path().join("right.json"),
2112            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2113        )
2114        .unwrap();
2115        std::fs::write(
2116            dir.path().join(".fallowrc.json"),
2117            r#"{"extends": ["left.json", "right.json"]}"#,
2118        )
2119        .unwrap();
2120
2121        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2122        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2123        assert_eq!(config.rules.unused_files, Severity::Warn);
2124        assert_eq!(config.rules.unused_exports, Severity::Off);
2125    }
2126
2127    #[test]
2128    fn extends_circular_detected() {
2129        let dir = test_dir("extends-circular");
2130
2131        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
2132        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
2133
2134        let result = FallowConfig::load(&dir.path().join("a.json"));
2135        assert!(result.is_err());
2136        let err_msg = format!("{}", result.unwrap_err());
2137        assert!(
2138            err_msg.contains("Circular extends"),
2139            "Expected circular error, got: {err_msg}"
2140        );
2141    }
2142
2143    #[test]
2144    fn remote_extends_are_denied_before_fetch_by_default() {
2145        let dir = test_dir("remote-default-denied");
2146        let url = "https://config-user:config-password@config.example:8443/base.json?token=config-token#config-fragment";
2147        std::fs::write(
2148            dir.path().join(".fallowrc.json"),
2149            format!(r#"{{"extends": "{url}"}}"#),
2150        )
2151        .unwrap();
2152        let mut fetcher = MockRemoteFetcher::default().with_response(url, serde_json::json!({}));
2153
2154        let error = load_with_fetcher(
2155            &dir.path().join(".fallowrc.json"),
2156            ConfigLoadOptions::default(),
2157            &mut fetcher,
2158        )
2159        .unwrap_err()
2160        .to_string();
2161
2162        assert!(
2163            error.contains("https://config.example:8443/base.json"),
2164            "denial must name the URL without secrets"
2165        );
2166        for secret in [
2167            "config-user",
2168            "config-password",
2169            "config-token",
2170            "config-fragment",
2171        ] {
2172            assert!(
2173                !error.contains(secret),
2174                "denial must not expose a secret value"
2175            );
2176        }
2177        assert!(
2178            error.contains("--allow-remote-extends"),
2179            "denial must name the CLI opt-in"
2180        );
2181        assert!(
2182            error.contains("ConfigLoadOptions"),
2183            "denial must name the library opt-in"
2184        );
2185        assert!(
2186            fetcher.requests.is_empty(),
2187            "denial must happen before fetch"
2188        );
2189    }
2190
2191    #[test]
2192    fn remote_parent_and_requested_url_secrets_are_redacted_in_errors() {
2193        let dir = test_dir("remote-error-redaction");
2194        let parent = "https://parent-user:parent-password@[2001:db8::1]:8443/base.json?token=parent-token#parent-fragment";
2195        let requested = "http://child-user:child-password@child.example/child.json?token=child-token#child-fragment";
2196        std::fs::write(
2197            dir.path().join(".fallowrc.json"),
2198            format!(r#"{{"extends": "{parent}"}}"#),
2199        )
2200        .unwrap();
2201        let mut fetcher = MockRemoteFetcher::default()
2202            .with_response(parent, serde_json::json!({"extends": requested}));
2203
2204        let error = load_with_fetcher(
2205            &dir.path().join(".fallowrc.json"),
2206            ConfigLoadOptions {
2207                allow_remote_extends: true,
2208            },
2209            &mut fetcher,
2210        )
2211        .unwrap_err()
2212        .to_string();
2213
2214        assert!(
2215            error.contains("http://child.example/child.json"),
2216            "error must preserve the requested host and path"
2217        );
2218        assert!(
2219            error.contains("https://[2001:db8::1]:8443/base.json"),
2220            "error must preserve the remote parent host, port, and path"
2221        );
2222        for secret in [
2223            "parent-user",
2224            "parent-password",
2225            "parent-token",
2226            "parent-fragment",
2227            "child-user",
2228            "child-password",
2229            "child-token",
2230            "child-fragment",
2231        ] {
2232            assert!(
2233                !error.contains(secret),
2234                "remote error must not expose a secret value"
2235            );
2236        }
2237    }
2238
2239    #[test]
2240    fn remote_fetch_network_error_redacts_requested_url_and_source() {
2241        let url = "https://request-user:request-password@127.0.0.1:0/config.json?token=request-token#request-fragment";
2242        let source = "https://source-user:source-password@source.example/parent.json?token=source-token#source-fragment";
2243
2244        let error = fetch_url_config(url, source)
2245            .expect_err("the reserved local port must reject the request")
2246            .to_string();
2247
2248        assert!(
2249            error.contains("https://127.0.0.1:0/config.json"),
2250            "network error must preserve the requested host, port, and path"
2251        );
2252        assert!(
2253            error.contains("https://source.example/parent.json"),
2254            "network error must preserve the source host and path"
2255        );
2256        for secret in [
2257            "request-user",
2258            "request-password",
2259            "request-token",
2260            "request-fragment",
2261            "source-user",
2262            "source-password",
2263            "source-token",
2264            "source-fragment",
2265        ] {
2266            assert!(
2267                !error.contains(secret),
2268                "network error must not expose a secret value"
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn remote_fetch_error_detail_does_not_trust_normalized_urls() {
2275        let url = "https://request-user:request-password@config.example/config.json?token=request-token#request-fragment";
2276        let normalized_error = ureq::Error::BadUri(
2277            "https://request-user:request-password@config.example/config.json?token=request-token"
2278                .to_string(),
2279        );
2280
2281        assert!(
2282            remote_fetch_error_display(&normalized_error, url) == "request failed",
2283            "normalized network errors must not bypass URL redaction"
2284        );
2285    }
2286
2287    #[test]
2288    fn remote_fetch_error_detail_does_not_trust_redirect_like_payloads() {
2289        let original = "https://config.example/config.json";
2290        let error = ureq::Error::BadUri(
2291            "http://redirect-user:redirect-password@example.com/next.json?token=secret#anchor"
2292                .to_string(),
2293        );
2294
2295        let display = remote_fetch_error_display(&error, original);
2296        assert_eq!(display, "request failed");
2297        for secret in [
2298            "redirect-user",
2299            "redirect-password",
2300            "token=secret",
2301            "anchor",
2302        ] {
2303            assert!(
2304                !display.contains(secret),
2305                "untrusted error detail must be hidden"
2306            );
2307        }
2308    }
2309
2310    #[test]
2311    fn remote_extends_dispatch_when_explicitly_allowed() {
2312        let dir = test_dir("remote-explicitly-allowed");
2313        let url = "https://config.example/base.json";
2314        std::fs::write(
2315            dir.path().join(".fallowrc.json"),
2316            format!(r#"{{"extends": "{url}"}}"#),
2317        )
2318        .unwrap();
2319        let mut fetcher = MockRemoteFetcher::default()
2320            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2321
2322        let config = load_with_fetcher(
2323            &dir.path().join(".fallowrc.json"),
2324            ConfigLoadOptions {
2325                allow_remote_extends: true,
2326            },
2327            &mut fetcher,
2328        )
2329        .unwrap();
2330
2331        assert_eq!(config.rules.unused_files, Severity::Warn);
2332        assert_eq!(fetcher.requests, vec![url]);
2333    }
2334
2335    #[test]
2336    fn uppercase_https_remote_extends_dispatch_when_allowed() {
2337        let dir = test_dir("remote-uppercase-scheme");
2338        let url = "HTTPS://config.example/base.json";
2339        std::fs::write(
2340            dir.path().join(".fallowrc.json"),
2341            format!(r#"{{"extends": "{url}"}}"#),
2342        )
2343        .unwrap();
2344        let mut fetcher = MockRemoteFetcher::default()
2345            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2346
2347        let config = load_with_fetcher(
2348            &dir.path().join(".fallowrc.json"),
2349            ConfigLoadOptions {
2350                allow_remote_extends: true,
2351            },
2352            &mut fetcher,
2353        )
2354        .unwrap();
2355
2356        assert_eq!(config.rules.unused_files, Severity::Warn);
2357        assert_eq!(fetcher.requests, vec![url]);
2358    }
2359
2360    #[test]
2361    fn remote_extends_diamond_reuses_mocked_base() {
2362        let dir = test_dir("remote-diamond");
2363        let left = "https://config.example/left.json";
2364        let right = "https://config.example/right.json";
2365        let base = "https://config.example/base.json";
2366        std::fs::write(
2367            dir.path().join(".fallowrc.json"),
2368            format!(r#"{{"extends": ["{left}", "{right}"]}}"#),
2369        )
2370        .unwrap();
2371        let mut fetcher = MockRemoteFetcher::default()
2372            .with_response(
2373                left,
2374                serde_json::json!({
2375                    "extends": base,
2376                    "rules": {"unused-files": "warn"}
2377                }),
2378            )
2379            .with_response(
2380                right,
2381                serde_json::json!({
2382                    "extends": base,
2383                    "rules": {"unused-exports": "off"}
2384                }),
2385            )
2386            .with_response(
2387                base,
2388                serde_json::json!({"ignorePatterns": ["generated/**"]}),
2389            );
2390
2391        let config = load_with_fetcher(
2392            &dir.path().join(".fallowrc.json"),
2393            ConfigLoadOptions {
2394                allow_remote_extends: true,
2395            },
2396            &mut fetcher,
2397        )
2398        .unwrap();
2399
2400        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2401        assert_eq!(config.rules.unused_files, Severity::Warn);
2402        assert_eq!(config.rules.unused_exports, Severity::Off);
2403        assert_eq!(
2404            fetcher
2405                .requests
2406                .iter()
2407                .filter(|request| *request == base)
2408                .count(),
2409            1,
2410            "the shared remote base should be fetched once"
2411        );
2412    }
2413
2414    #[test]
2415    fn remote_extends_active_cycle_still_fails() {
2416        let dir = test_dir("remote-cycle");
2417        let first = "https://config.example/first.json";
2418        let second = "https://config.example/second.json";
2419        std::fs::write(
2420            dir.path().join(".fallowrc.json"),
2421            format!(r#"{{"extends": "{first}"}}"#),
2422        )
2423        .unwrap();
2424        let mut fetcher = MockRemoteFetcher::default()
2425            .with_response(first, serde_json::json!({"extends": second}))
2426            .with_response(second, serde_json::json!({"extends": first}));
2427
2428        let error = load_with_fetcher(
2429            &dir.path().join(".fallowrc.json"),
2430            ConfigLoadOptions {
2431                allow_remote_extends: true,
2432            },
2433            &mut fetcher,
2434        )
2435        .unwrap_err()
2436        .to_string();
2437
2438        assert!(
2439            error.contains("Circular extends"),
2440            "unexpected error: {error}"
2441        );
2442    }
2443
2444    #[test]
2445    fn query_distinct_remote_extends_remain_distinct() {
2446        let dir = test_dir("remote-query-distinct");
2447        let first = "https://config.example/base.json?profile=one";
2448        let second = "https://config.example/base.json?profile=two";
2449        std::fs::write(
2450            dir.path().join(".fallowrc.json"),
2451            format!(r#"{{"extends": ["{first}", "{second}"]}}"#),
2452        )
2453        .unwrap();
2454        let mut fetcher = MockRemoteFetcher::default()
2455            .with_response(
2456                first,
2457                serde_json::json!({"rules": {"unused-files": "warn"}}),
2458            )
2459            .with_response(
2460                second,
2461                serde_json::json!({"rules": {"unused-exports": "off"}}),
2462            );
2463
2464        let config = load_with_fetcher(
2465            &dir.path().join(".fallowrc.json"),
2466            ConfigLoadOptions {
2467                allow_remote_extends: true,
2468            },
2469            &mut fetcher,
2470        )
2471        .unwrap();
2472
2473        assert_eq!(config.rules.unused_files, Severity::Warn);
2474        assert_eq!(config.rules.unused_exports, Severity::Off);
2475        assert_eq!(fetcher.requests, vec![first, second]);
2476    }
2477
2478    #[test]
2479    fn extends_missing_file_errors() {
2480        let dir = test_dir("extends-missing");
2481
2482        std::fs::write(
2483            dir.path().join(".fallowrc.json"),
2484            r#"{"extends": ["nonexistent.json"]}"#,
2485        )
2486        .unwrap();
2487
2488        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2489        assert!(result.is_err());
2490        let err_msg = format!("{}", result.unwrap_err());
2491        assert!(
2492            err_msg.contains("not found"),
2493            "Expected not found error, got: {err_msg}"
2494        );
2495    }
2496
2497    #[test]
2498    fn sealed_allows_in_directory_extends() {
2499        let dir = test_dir("sealed-allows-local");
2500        std::fs::write(
2501            dir.path().join("base.json"),
2502            r#"{"ignorePatterns": ["gen/**"]}"#,
2503        )
2504        .unwrap();
2505        std::fs::write(
2506            dir.path().join(".fallowrc.json"),
2507            r#"{"sealed": true, "extends": ["./base.json"]}"#,
2508        )
2509        .unwrap();
2510
2511        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2512        assert!(config.sealed);
2513        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2514    }
2515
2516    #[test]
2517    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
2518        let dir = test_dir("boundary-coverage-invalid-glob");
2519        std::fs::write(
2520            dir.path().join(".fallowrc.json"),
2521            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
2522        )
2523        .unwrap();
2524
2525        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2526        assert!(result.is_err());
2527        let err_msg = format!("{}", result.unwrap_err());
2528        assert!(
2529            err_msg.contains("boundaries.coverage.allowUnmatched"),
2530            "expected coverage field in error, got: {err_msg}"
2531        );
2532    }
2533
2534    #[test]
2535    fn sealed_rejects_extends_escaping_directory() {
2536        let dir = test_dir("sealed-rejects-escape");
2537        let sub = dir.path().join("packages").join("app");
2538        std::fs::create_dir_all(&sub).unwrap();
2539
2540        std::fs::write(
2541            dir.path().join("base.json"),
2542            r#"{"ignorePatterns": ["dist/**"]}"#,
2543        )
2544        .unwrap();
2545        std::fs::write(
2546            sub.join(".fallowrc.json"),
2547            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
2548        )
2549        .unwrap();
2550
2551        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
2552        assert!(
2553            result.is_err(),
2554            "Expected sealed config to reject escaping extends"
2555        );
2556        let err_msg = format!("{}", result.unwrap_err());
2557        assert!(
2558            err_msg.contains("sealed"),
2559            "Error must mention sealed: {err_msg}"
2560        );
2561        assert!(
2562            err_msg.contains("outside the config's directory"),
2563            "Error must explain the constraint: {err_msg}"
2564        );
2565    }
2566
2567    #[test]
2568    fn sealed_rejects_https_extends() {
2569        let dir = test_dir("sealed-rejects-https");
2570        std::fs::write(
2571            dir.path().join(".fallowrc.json"),
2572            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
2573        )
2574        .unwrap();
2575
2576        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2577        assert!(result.is_err());
2578        let err_msg = format!("{}", result.unwrap_err());
2579        assert!(
2580            err_msg.contains("sealed"),
2581            "Error must mention sealed: {err_msg}"
2582        );
2583        assert!(
2584            err_msg.contains("URL extends"),
2585            "Error must mention URL: {err_msg}"
2586        );
2587    }
2588
2589    #[test]
2590    fn sealed_rejects_npm_extends() {
2591        let dir = test_dir("sealed-rejects-npm");
2592        std::fs::write(
2593            dir.path().join(".fallowrc.json"),
2594            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
2595        )
2596        .unwrap();
2597
2598        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2599        assert!(result.is_err());
2600        let err_msg = format!("{}", result.unwrap_err());
2601        assert!(
2602            err_msg.contains("sealed"),
2603            "Error must mention sealed: {err_msg}"
2604        );
2605        assert!(
2606            err_msg.contains("npm extends"),
2607            "Error must mention npm: {err_msg}"
2608        );
2609    }
2610
2611    #[test]
2612    fn sealed_default_is_false() {
2613        let dir = test_dir("sealed-default");
2614        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
2615        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2616        assert!(!config.sealed);
2617    }
2618
2619    #[test]
2620    fn sealed_false_allows_escaping_extends() {
2621        let dir = test_dir("sealed-false-allows");
2622        let sub = dir.path().join("packages").join("app");
2623        std::fs::create_dir_all(&sub).unwrap();
2624
2625        std::fs::write(
2626            dir.path().join("base.json"),
2627            r#"{"ignorePatterns": ["dist/**"]}"#,
2628        )
2629        .unwrap();
2630        std::fs::write(
2631            sub.join(".fallowrc.json"),
2632            r#"{"extends": ["../../base.json"]}"#,
2633        )
2634        .unwrap();
2635
2636        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2637        assert!(!config.sealed);
2638        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2639    }
2640
2641    #[test]
2642    fn extends_string_sugar() {
2643        let dir = test_dir("extends-string");
2644
2645        std::fs::write(
2646            dir.path().join("base.json"),
2647            r#"{"ignorePatterns": ["gen/**"]}"#,
2648        )
2649        .unwrap();
2650        std::fs::write(
2651            dir.path().join(".fallowrc.json"),
2652            r#"{"extends": "base.json"}"#,
2653        )
2654        .unwrap();
2655
2656        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2657        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2658    }
2659
2660    #[test]
2661    fn extends_deep_merge_preserves_arrays() {
2662        let dir = test_dir("extends-array");
2663
2664        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
2665        std::fs::write(
2666            dir.path().join(".fallowrc.json"),
2667            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
2668        )
2669        .unwrap();
2670
2671        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2672        assert_eq!(config.entry, vec!["src/b.ts"]);
2673    }
2674
2675    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
2676        let pkg_dir = root.join("node_modules").join(name);
2677        std::fs::create_dir_all(&pkg_dir).unwrap();
2678        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
2679    }
2680
2681    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
2682        let pkg_dir = root.join("node_modules").join(name);
2683        std::fs::create_dir_all(&pkg_dir).unwrap();
2684        std::fs::write(
2685            pkg_dir.join("package.json"),
2686            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
2687        )
2688        .unwrap();
2689        std::fs::write(pkg_dir.join(main), config_json).unwrap();
2690    }
2691
2692    #[test]
2693    fn extends_npm_basic_unscoped() {
2694        let dir = test_dir("npm-basic");
2695        create_npm_package(
2696            dir.path(),
2697            "fallow-config-acme",
2698            r#"{"rules": {"unused-files": "warn"}}"#,
2699        );
2700        std::fs::write(
2701            dir.path().join(".fallowrc.json"),
2702            r#"{"extends": "npm:fallow-config-acme"}"#,
2703        )
2704        .unwrap();
2705
2706        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2707        assert_eq!(config.rules.unused_files, Severity::Warn);
2708    }
2709
2710    #[test]
2711    fn extends_npm_scoped_package() {
2712        let dir = test_dir("npm-scoped");
2713        create_npm_package(
2714            dir.path(),
2715            "@company/fallow-config",
2716            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
2717        );
2718        std::fs::write(
2719            dir.path().join(".fallowrc.json"),
2720            r#"{"extends": "npm:@company/fallow-config"}"#,
2721        )
2722        .unwrap();
2723
2724        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2725        assert_eq!(config.rules.unused_exports, Severity::Off);
2726        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2727    }
2728
2729    #[test]
2730    fn extends_npm_with_subpath() {
2731        let dir = test_dir("npm-subpath");
2732        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
2733        std::fs::create_dir_all(&pkg_dir).unwrap();
2734        std::fs::write(
2735            pkg_dir.join("strict.json"),
2736            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
2737        )
2738        .unwrap();
2739
2740        std::fs::write(
2741            dir.path().join(".fallowrc.json"),
2742            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
2743        )
2744        .unwrap();
2745
2746        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2747        assert_eq!(config.rules.unused_files, Severity::Error);
2748        assert_eq!(config.rules.unused_exports, Severity::Error);
2749    }
2750
2751    #[test]
2752    fn extends_npm_package_json_main() {
2753        let dir = test_dir("npm-main");
2754        create_npm_package_with_main(
2755            dir.path(),
2756            "fallow-config-acme",
2757            "config.json",
2758            r#"{"rules": {"unused-types": "off"}}"#,
2759        );
2760        std::fs::write(
2761            dir.path().join(".fallowrc.json"),
2762            r#"{"extends": "npm:fallow-config-acme"}"#,
2763        )
2764        .unwrap();
2765
2766        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2767        assert_eq!(config.rules.unused_types, Severity::Off);
2768    }
2769
2770    #[test]
2771    fn extends_npm_package_json_exports_string() {
2772        let dir = test_dir("npm-exports-str");
2773        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
2774        std::fs::create_dir_all(&pkg_dir).unwrap();
2775        std::fs::write(
2776            pkg_dir.join("package.json"),
2777            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
2778        )
2779        .unwrap();
2780        std::fs::write(
2781            pkg_dir.join("base.json"),
2782            r#"{"rules": {"circular-dependencies": "warn"}}"#,
2783        )
2784        .unwrap();
2785
2786        std::fs::write(
2787            dir.path().join(".fallowrc.json"),
2788            r#"{"extends": "npm:fallow-config-co"}"#,
2789        )
2790        .unwrap();
2791
2792        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2793        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
2794    }
2795
2796    #[test]
2797    fn extends_npm_package_json_exports_object() {
2798        let dir = test_dir("npm-exports-obj");
2799        let pkg_dir = dir.path().join("node_modules/@co/cfg");
2800        std::fs::create_dir_all(&pkg_dir).unwrap();
2801        std::fs::write(
2802            pkg_dir.join("package.json"),
2803            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
2804        )
2805        .unwrap();
2806        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
2807
2808        std::fs::write(
2809            dir.path().join(".fallowrc.json"),
2810            r#"{"extends": "npm:@co/cfg"}"#,
2811        )
2812        .unwrap();
2813
2814        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2815        assert_eq!(config.entry, vec!["src/app.ts"]);
2816    }
2817
2818    #[test]
2819    fn extends_npm_exports_takes_priority_over_main() {
2820        let dir = test_dir("npm-exports-prio");
2821        let pkg_dir = dir.path().join("node_modules/my-config");
2822        std::fs::create_dir_all(&pkg_dir).unwrap();
2823        std::fs::write(
2824            pkg_dir.join("package.json"),
2825            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
2826        )
2827        .unwrap();
2828        std::fs::write(
2829            pkg_dir.join("old.json"),
2830            r#"{"rules": {"unused-files": "off"}}"#,
2831        )
2832        .unwrap();
2833        std::fs::write(
2834            pkg_dir.join("new.json"),
2835            r#"{"rules": {"unused-files": "warn"}}"#,
2836        )
2837        .unwrap();
2838
2839        std::fs::write(
2840            dir.path().join(".fallowrc.json"),
2841            r#"{"extends": "npm:my-config"}"#,
2842        )
2843        .unwrap();
2844
2845        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2846        assert_eq!(config.rules.unused_files, Severity::Warn);
2847    }
2848
2849    #[test]
2850    fn extends_npm_walk_up_directories() {
2851        let dir = test_dir("npm-walkup");
2852        create_npm_package(
2853            dir.path(),
2854            "shared-config",
2855            r#"{"rules": {"unused-files": "warn"}}"#,
2856        );
2857        let sub = dir.path().join("packages/app");
2858        std::fs::create_dir_all(&sub).unwrap();
2859        std::fs::write(
2860            sub.join(".fallowrc.json"),
2861            r#"{"extends": "npm:shared-config"}"#,
2862        )
2863        .unwrap();
2864
2865        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
2866        assert_eq!(config.rules.unused_files, Severity::Warn);
2867    }
2868
2869    #[test]
2870    fn extends_npm_overlay_overrides_base() {
2871        let dir = test_dir("npm-overlay");
2872        create_npm_package(
2873            dir.path(),
2874            "@company/base",
2875            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
2876        );
2877        std::fs::write(
2878            dir.path().join(".fallowrc.json"),
2879            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
2880        )
2881        .unwrap();
2882
2883        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2884        assert_eq!(config.rules.unused_files, Severity::Error);
2885        assert_eq!(config.rules.unused_exports, Severity::Off);
2886        assert_eq!(config.entry, vec!["src/app.ts"]);
2887    }
2888
2889    #[test]
2890    fn extends_npm_chained_with_relative() {
2891        let dir = test_dir("npm-chained");
2892        let pkg_dir = dir.path().join("node_modules/my-config");
2893        std::fs::create_dir_all(&pkg_dir).unwrap();
2894        std::fs::write(
2895            pkg_dir.join("base.json"),
2896            r#"{"rules": {"unused-files": "warn"}}"#,
2897        )
2898        .unwrap();
2899        std::fs::write(
2900            pkg_dir.join(".fallowrc.json"),
2901            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2902        )
2903        .unwrap();
2904
2905        std::fs::write(
2906            dir.path().join(".fallowrc.json"),
2907            r#"{"extends": "npm:my-config"}"#,
2908        )
2909        .unwrap();
2910
2911        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2912        assert_eq!(config.rules.unused_files, Severity::Warn);
2913        assert_eq!(config.rules.unused_exports, Severity::Off);
2914    }
2915
2916    #[test]
2917    fn extends_npm_mixed_with_relative_paths() {
2918        let dir = test_dir("npm-mixed");
2919        create_npm_package(
2920            dir.path(),
2921            "shared-base",
2922            r#"{"rules": {"unused-files": "off"}}"#,
2923        );
2924        std::fs::write(
2925            dir.path().join("local-overrides.json"),
2926            r#"{"rules": {"unused-files": "warn"}}"#,
2927        )
2928        .unwrap();
2929        std::fs::write(
2930            dir.path().join(".fallowrc.json"),
2931            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
2932        )
2933        .unwrap();
2934
2935        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2936        assert_eq!(config.rules.unused_files, Severity::Warn);
2937    }
2938
2939    #[test]
2940    fn extends_npm_missing_package_errors() {
2941        let dir = test_dir("npm-missing");
2942        std::fs::write(
2943            dir.path().join(".fallowrc.json"),
2944            r#"{"extends": "npm:nonexistent-package"}"#,
2945        )
2946        .unwrap();
2947
2948        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2949        assert!(result.is_err());
2950        let err_msg = format!("{}", result.unwrap_err());
2951        assert!(
2952            err_msg.contains("not found"),
2953            "Expected 'not found' error, got: {err_msg}"
2954        );
2955        assert!(
2956            err_msg.contains("nonexistent-package"),
2957            "Expected package name in error, got: {err_msg}"
2958        );
2959        assert!(
2960            err_msg.contains("install it"),
2961            "Expected install hint in error, got: {err_msg}"
2962        );
2963    }
2964
2965    #[test]
2966    fn extends_npm_no_config_in_package_errors() {
2967        let dir = test_dir("npm-no-config");
2968        let pkg_dir = dir.path().join("node_modules/empty-pkg");
2969        std::fs::create_dir_all(&pkg_dir).unwrap();
2970        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
2971
2972        std::fs::write(
2973            dir.path().join(".fallowrc.json"),
2974            r#"{"extends": "npm:empty-pkg"}"#,
2975        )
2976        .unwrap();
2977
2978        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2979        assert!(result.is_err());
2980        let err_msg = format!("{}", result.unwrap_err());
2981        assert!(
2982            err_msg.contains("No fallow config found"),
2983            "Expected 'No fallow config found' error, got: {err_msg}"
2984        );
2985    }
2986
2987    #[test]
2988    fn extends_npm_missing_subpath_errors() {
2989        let dir = test_dir("npm-missing-sub");
2990        let pkg_dir = dir.path().join("node_modules/@co/config");
2991        std::fs::create_dir_all(&pkg_dir).unwrap();
2992
2993        std::fs::write(
2994            dir.path().join(".fallowrc.json"),
2995            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
2996        )
2997        .unwrap();
2998
2999        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3000        assert!(result.is_err());
3001        let err_msg = format!("{}", result.unwrap_err());
3002        assert!(
3003            err_msg.contains("nonexistent.json"),
3004            "Expected subpath in error, got: {err_msg}"
3005        );
3006    }
3007
3008    #[test]
3009    fn extends_npm_empty_specifier_errors() {
3010        let dir = test_dir("npm-empty");
3011        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
3012
3013        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3014        assert!(result.is_err());
3015        let err_msg = format!("{}", result.unwrap_err());
3016        assert!(
3017            err_msg.contains("Empty npm specifier"),
3018            "Expected 'Empty npm specifier' error, got: {err_msg}"
3019        );
3020    }
3021
3022    #[test]
3023    fn extends_npm_space_after_colon_trimmed() {
3024        let dir = test_dir("npm-space");
3025        create_npm_package(
3026            dir.path(),
3027            "fallow-config-acme",
3028            r#"{"rules": {"unused-files": "warn"}}"#,
3029        );
3030        std::fs::write(
3031            dir.path().join(".fallowrc.json"),
3032            r#"{"extends": "npm: fallow-config-acme"}"#,
3033        )
3034        .unwrap();
3035
3036        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3037        assert_eq!(config.rules.unused_files, Severity::Warn);
3038    }
3039
3040    #[test]
3041    fn extends_npm_exports_node_condition() {
3042        let dir = test_dir("npm-node-cond");
3043        let pkg_dir = dir.path().join("node_modules/node-config");
3044        std::fs::create_dir_all(&pkg_dir).unwrap();
3045        std::fs::write(
3046            pkg_dir.join("package.json"),
3047            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
3048        )
3049        .unwrap();
3050        std::fs::write(
3051            pkg_dir.join("node.json"),
3052            r#"{"rules": {"unused-files": "off"}}"#,
3053        )
3054        .unwrap();
3055
3056        std::fs::write(
3057            dir.path().join(".fallowrc.json"),
3058            r#"{"extends": "npm:node-config"}"#,
3059        )
3060        .unwrap();
3061
3062        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3063        assert_eq!(config.rules.unused_files, Severity::Off);
3064    }
3065
3066    #[test]
3067    fn parse_npm_specifier_unscoped() {
3068        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
3069    }
3070
3071    #[test]
3072    fn parse_npm_specifier_unscoped_with_subpath() {
3073        assert_eq!(
3074            parse_npm_specifier("my-config/strict.json"),
3075            ("my-config", Some("strict.json"))
3076        );
3077    }
3078
3079    #[test]
3080    fn parse_npm_specifier_scoped() {
3081        assert_eq!(
3082            parse_npm_specifier("@company/fallow-config"),
3083            ("@company/fallow-config", None)
3084        );
3085    }
3086
3087    #[test]
3088    fn parse_npm_specifier_scoped_with_subpath() {
3089        assert_eq!(
3090            parse_npm_specifier("@company/fallow-config/strict.json"),
3091            ("@company/fallow-config", Some("strict.json"))
3092        );
3093    }
3094
3095    #[test]
3096    fn parse_npm_specifier_scoped_with_nested_subpath() {
3097        assert_eq!(
3098            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
3099            ("@company/fallow-config", Some("presets/strict.json"))
3100        );
3101    }
3102
3103    #[test]
3104    fn extends_npm_subpath_traversal_rejected() {
3105        let dir = test_dir("npm-traversal-sub");
3106        let pkg_dir = dir.path().join("node_modules/evil-pkg");
3107        std::fs::create_dir_all(&pkg_dir).unwrap();
3108        std::fs::write(
3109            dir.path().join("secret.json"),
3110            r#"{"entry": ["stolen.ts"]}"#,
3111        )
3112        .unwrap();
3113
3114        std::fs::write(
3115            dir.path().join(".fallowrc.json"),
3116            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
3117        )
3118        .unwrap();
3119
3120        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3121        assert!(result.is_err());
3122        let err_msg = format!("{}", result.unwrap_err());
3123        assert!(
3124            err_msg.contains("traversal") || err_msg.contains("not found"),
3125            "Expected traversal or not-found error, got: {err_msg}"
3126        );
3127    }
3128
3129    #[test]
3130    fn extends_npm_dotdot_package_name_rejected() {
3131        let dir = test_dir("npm-dotdot-name");
3132        std::fs::write(
3133            dir.path().join(".fallowrc.json"),
3134            r#"{"extends": "npm:../relative"}"#,
3135        )
3136        .unwrap();
3137
3138        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3139        assert!(result.is_err());
3140        let err_msg = format!("{}", result.unwrap_err());
3141        assert!(
3142            err_msg.contains("path traversal"),
3143            "Expected 'path traversal' error, got: {err_msg}"
3144        );
3145    }
3146
3147    #[test]
3148    fn extends_npm_scoped_without_name_rejected() {
3149        let dir = test_dir("npm-scope-only");
3150        std::fs::write(
3151            dir.path().join(".fallowrc.json"),
3152            r#"{"extends": "npm:@scope"}"#,
3153        )
3154        .unwrap();
3155
3156        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3157        assert!(result.is_err());
3158        let err_msg = format!("{}", result.unwrap_err());
3159        assert!(
3160            err_msg.contains("@scope/name"),
3161            "Expected scoped name format error, got: {err_msg}"
3162        );
3163    }
3164
3165    #[test]
3166    fn extends_npm_malformed_package_json_errors() {
3167        let dir = test_dir("npm-bad-pkgjson");
3168        let pkg_dir = dir.path().join("node_modules/bad-pkg");
3169        std::fs::create_dir_all(&pkg_dir).unwrap();
3170        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
3171
3172        std::fs::write(
3173            dir.path().join(".fallowrc.json"),
3174            r#"{"extends": "npm:bad-pkg"}"#,
3175        )
3176        .unwrap();
3177
3178        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3179        assert!(result.is_err());
3180        let err_msg = format!("{}", result.unwrap_err());
3181        assert!(
3182            err_msg.contains("Failed to parse"),
3183            "Expected parse error, got: {err_msg}"
3184        );
3185    }
3186
3187    #[test]
3188    fn extends_npm_exports_traversal_rejected() {
3189        let dir = test_dir("npm-exports-escape");
3190        let pkg_dir = dir.path().join("node_modules/evil-exports");
3191        std::fs::create_dir_all(&pkg_dir).unwrap();
3192        std::fs::write(
3193            pkg_dir.join("package.json"),
3194            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
3195        )
3196        .unwrap();
3197        std::fs::write(
3198            dir.path().join("secret.json"),
3199            r#"{"entry": ["stolen.ts"]}"#,
3200        )
3201        .unwrap();
3202
3203        std::fs::write(
3204            dir.path().join(".fallowrc.json"),
3205            r#"{"extends": "npm:evil-exports"}"#,
3206        )
3207        .unwrap();
3208
3209        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3210        assert!(result.is_err());
3211        let err_msg = format!("{}", result.unwrap_err());
3212        assert!(
3213            err_msg.contains("traversal"),
3214            "Expected traversal error, got: {err_msg}"
3215        );
3216    }
3217
3218    #[test]
3219    fn deep_merge_scalar_overlay_replaces_base() {
3220        let mut base = serde_json::json!("hello");
3221        deep_merge_json(&mut base, serde_json::json!("world"));
3222        assert_eq!(base, serde_json::json!("world"));
3223    }
3224
3225    #[test]
3226    fn deep_merge_array_overlay_replaces_base() {
3227        let mut base = serde_json::json!(["a", "b"]);
3228        deep_merge_json(&mut base, serde_json::json!(["c"]));
3229        assert_eq!(base, serde_json::json!(["c"]));
3230    }
3231
3232    #[test]
3233    fn deep_merge_nested_object_merge() {
3234        let mut base = serde_json::json!({
3235            "level1": {
3236                "level2": {
3237                    "a": 1,
3238                    "b": 2
3239                }
3240            }
3241        });
3242        let overlay = serde_json::json!({
3243            "level1": {
3244                "level2": {
3245                    "b": 99,
3246                    "c": 3
3247                }
3248            }
3249        });
3250        deep_merge_json(&mut base, overlay);
3251        assert_eq!(base["level1"]["level2"]["a"], 1);
3252        assert_eq!(base["level1"]["level2"]["b"], 99);
3253        assert_eq!(base["level1"]["level2"]["c"], 3);
3254    }
3255
3256    #[test]
3257    fn deep_merge_overlay_adds_new_fields() {
3258        let mut base = serde_json::json!({"existing": true});
3259        let overlay = serde_json::json!({"new_field": "added", "another": 42});
3260        deep_merge_json(&mut base, overlay);
3261        assert_eq!(base["existing"], true);
3262        assert_eq!(base["new_field"], "added");
3263        assert_eq!(base["another"], 42);
3264    }
3265
3266    #[test]
3267    fn deep_merge_null_overlay_replaces_object() {
3268        let mut base = serde_json::json!({"key": "value"});
3269        deep_merge_json(&mut base, serde_json::json!(null));
3270        assert_eq!(base, serde_json::json!(null));
3271    }
3272
3273    #[test]
3274    fn deep_merge_empty_object_overlay_preserves_base() {
3275        let mut base = serde_json::json!({"a": 1, "b": 2});
3276        deep_merge_json(&mut base, serde_json::json!({}));
3277        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3278    }
3279
3280    #[test]
3281    fn rules_severity_error_warn_off_from_json() {
3282        let json_str = r#"{
3283            "rules": {
3284                "unused-files": "error",
3285                "unused-exports": "warn",
3286                "unused-types": "off"
3287            }
3288        }"#;
3289        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3290        assert_eq!(config.rules.unused_files, Severity::Error);
3291        assert_eq!(config.rules.unused_exports, Severity::Warn);
3292        assert_eq!(config.rules.unused_types, Severity::Off);
3293    }
3294
3295    #[test]
3296    fn rules_omitted_default_to_error() {
3297        let json_str = r#"{
3298            "rules": {
3299                "unused-files": "warn"
3300            }
3301        }"#;
3302        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3303        assert_eq!(config.rules.unused_files, Severity::Warn);
3304        assert_eq!(config.rules.unused_exports, Severity::Error);
3305        assert_eq!(config.rules.unused_types, Severity::Error);
3306        assert_eq!(config.rules.unused_dependencies, Severity::Error);
3307        assert_eq!(config.rules.unresolved_imports, Severity::Error);
3308        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
3309        assert_eq!(config.rules.duplicate_exports, Severity::Error);
3310        assert_eq!(config.rules.circular_dependencies, Severity::Error);
3311        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
3312    }
3313
3314    #[test]
3315    fn find_and_load_returns_none_when_no_config() {
3316        let dir = test_dir("find-none");
3317        std::fs::create_dir(dir.path().join(".git")).unwrap();
3318
3319        let result = FallowConfig::find_and_load(dir.path()).unwrap();
3320        assert!(result.is_none());
3321    }
3322
3323    #[test]
3324    fn find_and_load_finds_fallowrc_json() {
3325        let dir = test_dir("find-json");
3326        std::fs::create_dir(dir.path().join(".git")).unwrap();
3327        std::fs::write(
3328            dir.path().join(".fallowrc.json"),
3329            r#"{"entry": ["src/main.ts"]}"#,
3330        )
3331        .unwrap();
3332
3333        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3334        assert_eq!(config.entry, vec!["src/main.ts"]);
3335        assert!(path.ends_with(".fallowrc.json"));
3336    }
3337
3338    #[test]
3339    fn find_and_load_finds_fallowrc_jsonc() {
3340        let dir = test_dir("find-jsonc");
3341        std::fs::create_dir(dir.path().join(".git")).unwrap();
3342        std::fs::write(
3343            dir.path().join(".fallowrc.jsonc"),
3344            r#"{
3345                "entry": ["src/main.ts"]
3346            }"#,
3347        )
3348        .unwrap();
3349
3350        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3351        assert_eq!(config.entry, vec!["src/main.ts"]);
3352        assert!(path.ends_with(".fallowrc.jsonc"));
3353    }
3354
3355    #[test]
3356    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
3357        let dir = test_dir("find-json-vs-jsonc");
3358        std::fs::create_dir(dir.path().join(".git")).unwrap();
3359        std::fs::write(
3360            dir.path().join(".fallowrc.json"),
3361            r#"{"entry": ["from-json.ts"]}"#,
3362        )
3363        .unwrap();
3364        std::fs::write(
3365            dir.path().join(".fallowrc.jsonc"),
3366            r#"{"entry": ["from-jsonc.ts"]}"#,
3367        )
3368        .unwrap();
3369
3370        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3371        assert_eq!(config.entry, vec!["from-json.ts"]);
3372        assert!(path.ends_with(".fallowrc.json"));
3373    }
3374
3375    #[test]
3376    fn find_and_load_prefers_fallowrc_json_over_toml() {
3377        let dir = test_dir("find-priority");
3378        std::fs::create_dir(dir.path().join(".git")).unwrap();
3379        std::fs::write(
3380            dir.path().join(".fallowrc.json"),
3381            r#"{"entry": ["from-json.ts"]}"#,
3382        )
3383        .unwrap();
3384        std::fs::write(
3385            dir.path().join("fallow.toml"),
3386            "entry = [\"from-toml.ts\"]\n",
3387        )
3388        .unwrap();
3389
3390        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3391        assert_eq!(config.entry, vec!["from-json.ts"]);
3392        assert!(path.ends_with(".fallowrc.json"));
3393    }
3394
3395    #[test]
3396    fn shadowed_config_names_empty_when_single_config() {
3397        let dir = test_dir("shadow-single");
3398        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3399        assert!(shadowed_config_names(dir.path(), 0).is_empty());
3400    }
3401
3402    #[test]
3403    fn shadowed_config_names_reports_lower_precedence_toml() {
3404        let dir = test_dir("shadow-json-toml");
3405        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3406        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3407        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
3408    }
3409
3410    #[test]
3411    fn shadowed_config_names_reports_jsonc_sibling() {
3412        let dir = test_dir("shadow-json-jsonc");
3413        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3414        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
3415        assert_eq!(
3416            shadowed_config_names(dir.path(), 0),
3417            vec![".fallowrc.jsonc"]
3418        );
3419    }
3420
3421    #[test]
3422    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
3423        let dir = test_dir("shadow-all-four");
3424        for name in CONFIG_NAMES {
3425            std::fs::write(dir.path().join(name), "").unwrap();
3426        }
3427        assert_eq!(
3428            shadowed_config_names(dir.path(), 0),
3429            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
3430        );
3431    }
3432
3433    #[test]
3434    fn shadowed_config_names_scoped_to_indices_after_winner() {
3435        let dir = test_dir("shadow-toml-dottoml");
3436        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3437        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
3438        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
3439    }
3440
3441    #[test]
3442    fn find_and_load_warns_when_configs_coexist() {
3443        let dir = test_dir("coexist-warn");
3444        std::fs::create_dir(dir.path().join(".git")).unwrap();
3445        std::fs::write(
3446            dir.path().join(".fallowrc.json"),
3447            r#"{"entry": ["from-json.ts"]}"#,
3448        )
3449        .unwrap();
3450        std::fs::write(
3451            dir.path().join("fallow.toml"),
3452            "entry = [\"from-toml.ts\"]\n",
3453        )
3454        .unwrap();
3455
3456        let (result, captured) =
3457            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3458
3459        let (config, path) = result.unwrap().unwrap();
3460        assert_eq!(config.entry, vec!["from-json.ts"]);
3461        assert!(path.ends_with(".fallowrc.json"));
3462
3463        assert_eq!(captured.len(), 1);
3464        let (chosen, shadowed) = &captured[0];
3465        assert_eq!(chosen, ".fallowrc.json");
3466        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
3467    }
3468
3469    #[test]
3470    fn find_and_load_does_not_warn_for_single_config() {
3471        let dir = test_dir("coexist-none");
3472        std::fs::create_dir(dir.path().join(".git")).unwrap();
3473        std::fs::write(
3474            dir.path().join(".fallowrc.json"),
3475            r#"{"entry": ["only.ts"]}"#,
3476        )
3477        .unwrap();
3478
3479        let (result, captured) =
3480            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3481        assert!(result.unwrap().is_some());
3482        assert!(captured.is_empty());
3483    }
3484
3485    #[test]
3486    fn find_and_load_warns_per_directory_independently() {
3487        let make = |name: &str| {
3488            let dir = test_dir(name);
3489            std::fs::create_dir(dir.path().join(".git")).unwrap();
3490            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
3491            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
3492            dir
3493        };
3494        let first = make("coexist-dir-a");
3495        let second = make("coexist-dir-b");
3496
3497        let ((), captured) = capture_coexisting_config_warnings(|| {
3498            FallowConfig::find_and_load(first.path()).unwrap();
3499            FallowConfig::find_and_load(second.path()).unwrap();
3500        });
3501
3502        assert_eq!(captured.len(), 2);
3503        assert!(captured.iter().all(|(chosen, shadowed)| {
3504            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
3505        }));
3506    }
3507
3508    #[test]
3509    fn explicit_load_does_not_warn_about_coexisting_configs() {
3510        let dir = test_dir("coexist-explicit");
3511        std::fs::write(
3512            dir.path().join(".fallowrc.json"),
3513            r#"{"entry": ["chosen.ts"]}"#,
3514        )
3515        .unwrap();
3516        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
3517
3518        let chosen = dir.path().join("fallow.toml");
3519        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
3520        assert!(result.is_ok());
3521        assert!(captured.is_empty());
3522    }
3523
3524    #[test]
3525    fn find_and_load_finds_fallow_toml() {
3526        let dir = test_dir("find-toml");
3527        std::fs::create_dir(dir.path().join(".git")).unwrap();
3528        std::fs::write(
3529            dir.path().join("fallow.toml"),
3530            "entry = [\"src/index.ts\"]\n",
3531        )
3532        .unwrap();
3533
3534        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3535        assert_eq!(config.entry, vec!["src/index.ts"]);
3536    }
3537
3538    #[test]
3539    fn find_and_load_stops_at_git_dir() {
3540        let dir = test_dir("find-git-stop");
3541        let sub = dir.path().join("sub");
3542        std::fs::create_dir(&sub).unwrap();
3543        std::fs::create_dir(dir.path().join(".git")).unwrap();
3544        let result = FallowConfig::find_and_load(&sub).unwrap();
3545        assert!(result.is_none());
3546    }
3547
3548    #[test]
3549    fn find_and_load_walks_past_package_json_in_monorepo() {
3550        let dir = test_dir("find-monorepo");
3551        std::fs::create_dir(dir.path().join(".git")).unwrap();
3552        std::fs::write(
3553            dir.path().join(".fallowrc.json"),
3554            r#"{"entry": ["src/index.ts"]}"#,
3555        )
3556        .unwrap();
3557
3558        let sub = dir.path().join("packages").join("app");
3559        std::fs::create_dir_all(&sub).unwrap();
3560        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3561
3562        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3563        assert_eq!(config.entry, vec!["src/index.ts"]);
3564        assert_eq!(path, dir.path().join(".fallowrc.json"));
3565    }
3566
3567    #[test]
3568    fn find_and_load_sub_package_config_wins_over_root() {
3569        let dir = test_dir("find-monorepo-override");
3570        std::fs::create_dir(dir.path().join(".git")).unwrap();
3571        std::fs::write(
3572            dir.path().join(".fallowrc.json"),
3573            r#"{"entry": ["src/root.ts"]}"#,
3574        )
3575        .unwrap();
3576
3577        let sub = dir.path().join("packages").join("app");
3578        std::fs::create_dir_all(&sub).unwrap();
3579        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3580        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
3581
3582        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3583        assert_eq!(config.entry, vec!["src/sub.ts"]);
3584        assert_eq!(path, sub.join(".fallowrc.json"));
3585    }
3586
3587    #[test]
3588    fn find_and_load_stops_at_git_file_submodule() {
3589        let dir = test_dir("find-git-file");
3590        std::fs::create_dir(dir.path().join(".git")).unwrap();
3591        std::fs::write(
3592            dir.path().join(".fallowrc.json"),
3593            r#"{"entry": ["src/parent.ts"]}"#,
3594        )
3595        .unwrap();
3596
3597        let submodule = dir.path().join("vendor").join("lib");
3598        std::fs::create_dir_all(&submodule).unwrap();
3599        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
3600
3601        let result = FallowConfig::find_and_load(&submodule).unwrap();
3602        assert!(
3603            result.is_none(),
3604            "submodule boundary should stop config walk",
3605        );
3606    }
3607
3608    #[test]
3609    fn find_and_load_stops_at_hg_dir() {
3610        let dir = test_dir("find-hg-stop");
3611        let sub = dir.path().join("sub");
3612        std::fs::create_dir(&sub).unwrap();
3613        std::fs::create_dir(dir.path().join(".hg")).unwrap();
3614
3615        let result = FallowConfig::find_and_load(&sub).unwrap();
3616        assert!(result.is_none());
3617    }
3618
3619    #[test]
3620    fn find_and_load_returns_error_for_invalid_config() {
3621        let dir = test_dir("find-invalid");
3622        std::fs::create_dir(dir.path().join(".git")).unwrap();
3623        std::fs::write(
3624            dir.path().join(".fallowrc.json"),
3625            r"{ this is not valid json }",
3626        )
3627        .unwrap();
3628
3629        let result = FallowConfig::find_and_load(dir.path());
3630        assert!(result.is_err());
3631    }
3632
3633    #[test]
3634    fn load_toml_config_file() {
3635        let dir = test_dir("toml-config");
3636        let config_path = dir.path().join("fallow.toml");
3637        std::fs::write(
3638            &config_path,
3639            r#"
3640entry = ["src/index.ts"]
3641ignorePatterns = ["dist/**"]
3642
3643[rules]
3644unused-files = "warn"
3645
3646[duplicates]
3647minTokens = 100
3648"#,
3649        )
3650        .unwrap();
3651
3652        let config = FallowConfig::load(&config_path).unwrap();
3653        assert_eq!(config.entry, vec!["src/index.ts"]);
3654        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
3655        assert_eq!(config.rules.unused_files, Severity::Warn);
3656        assert_eq!(config.duplicates.min_tokens, 100);
3657    }
3658
3659    #[test]
3660    fn load_toml_config_file_with_health_threshold_override() {
3661        let dir = test_dir("toml-health-threshold-override");
3662        let config_path = dir.path().join("fallow.toml");
3663        std::fs::write(
3664            &config_path,
3665            r#"
3666[health]
3667thresholdOverrides = [
3668  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
3669]
3670"#,
3671        )
3672        .unwrap();
3673
3674        let config = FallowConfig::load(&config_path).unwrap();
3675        let override_config = &config.health.threshold_overrides[0];
3676        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
3677        assert_eq!(override_config.functions, vec!["legacyFlow"]);
3678        assert_eq!(override_config.max_cyclomatic, Some(30));
3679        assert_eq!(override_config.max_cognitive, Some(25));
3680        assert_eq!(override_config.max_crap, Some(80.5));
3681        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
3682    }
3683
3684    #[test]
3685    fn extends_absolute_path_rejected() {
3686        let dir = test_dir("extends-absolute");
3687
3688        #[cfg(unix)]
3689        let abs_path = "/absolute/path/config.json";
3690        #[cfg(windows)]
3691        let abs_path = "C:\\absolute\\path\\config.json";
3692
3693        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
3694        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
3695
3696        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3697        assert!(result.is_err());
3698        let err_msg = format!("{}", result.unwrap_err());
3699        assert!(
3700            err_msg.contains("must be relative"),
3701            "Expected 'must be relative' error, got: {err_msg}"
3702        );
3703    }
3704
3705    #[test]
3706    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
3707        let dir = test_dir("extends-windows-absolute");
3708
3709        std::fs::write(
3710            dir.path().join(".fallowrc.json"),
3711            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
3712        )
3713        .unwrap();
3714
3715        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3716        assert!(result.is_err());
3717        let err_msg = format!("{}", result.unwrap_err());
3718        assert!(
3719            err_msg.contains("must be relative"),
3720            "Expected 'must be relative' error, got: {err_msg}"
3721        );
3722    }
3723
3724    #[cfg(windows)]
3725    #[test]
3726    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
3727        let dir = test_dir("extends-posix-rooted-absolute");
3728
3729        std::fs::write(
3730            dir.path().join(".fallowrc.json"),
3731            r#"{"extends": ["/absolute/path/config.json"]}"#,
3732        )
3733        .unwrap();
3734
3735        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3736        assert!(result.is_err());
3737        let err_msg = format!("{}", result.unwrap_err());
3738        assert!(
3739            err_msg.contains("must be relative"),
3740            "Expected 'must be relative' error, got: {err_msg}"
3741        );
3742    }
3743
3744    #[test]
3745    fn resolve_production_mode_disables_dev_deps() {
3746        let config = FallowConfig {
3747            production: true.into(),
3748            ..Default::default()
3749        };
3750        let resolved = config.resolve(
3751            PathBuf::from("/tmp/test"),
3752            OutputFormat::Human,
3753            4,
3754            false,
3755            true,
3756            None,
3757        );
3758        assert!(resolved.production);
3759        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
3760        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
3761        assert_eq!(resolved.rules.unused_files, Severity::Error);
3762        assert_eq!(resolved.rules.unused_exports, Severity::Error);
3763    }
3764
3765    #[test]
3766    fn include_entry_exports_deserializes_from_camelcase_json() {
3767        let json = r#"{ "includeEntryExports": true }"#;
3768        let config: FallowConfig = serde_json::from_str(json).unwrap();
3769        assert!(config.include_entry_exports);
3770    }
3771
3772    #[test]
3773    fn include_entry_exports_deserializes_from_camelcase_toml() {
3774        let toml_str = "includeEntryExports = true\n";
3775        let config: FallowConfig = toml::from_str(toml_str).unwrap();
3776        assert!(config.include_entry_exports);
3777    }
3778
3779    #[test]
3780    fn include_entry_exports_default_is_false() {
3781        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3782        assert!(!config.include_entry_exports);
3783    }
3784
3785    #[test]
3786    fn include_entry_exports_propagates_through_resolve() {
3787        let config = FallowConfig {
3788            include_entry_exports: true,
3789            auto_imports: false,
3790            cache: CacheConfig::default(),
3791            ..Default::default()
3792        };
3793        let resolved = config.resolve(
3794            PathBuf::from("/tmp/test"),
3795            OutputFormat::Human,
3796            1,
3797            true,
3798            true,
3799            None,
3800        );
3801        assert!(resolved.include_entry_exports);
3802    }
3803
3804    #[test]
3805    fn config_format_defaults_to_toml_for_unknown() {
3806        assert!(matches!(
3807            ConfigFormat::from_path(Path::new("config.yaml")),
3808            ConfigFormat::Toml
3809        ));
3810        assert!(matches!(
3811            ConfigFormat::from_path(Path::new("config")),
3812            ConfigFormat::Toml
3813        ));
3814    }
3815
3816    #[test]
3817    fn deep_merge_object_over_scalar_replaces() {
3818        let mut base = serde_json::json!("just a string");
3819        let overlay = serde_json::json!({"key": "value"});
3820        deep_merge_json(&mut base, overlay);
3821        assert_eq!(base, serde_json::json!({"key": "value"}));
3822    }
3823
3824    #[test]
3825    fn deep_merge_scalar_over_object_replaces() {
3826        let mut base = serde_json::json!({"key": "value"});
3827        let overlay = serde_json::json!(42);
3828        deep_merge_json(&mut base, overlay);
3829        assert_eq!(base, serde_json::json!(42));
3830    }
3831
3832    #[test]
3833    fn extends_non_string_non_array_fails_loud() {
3834        let dir = test_dir("extends-numeric");
3835        std::fs::write(
3836            dir.path().join(".fallowrc.json"),
3837            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
3838        )
3839        .unwrap();
3840
3841        let err = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap_err();
3842        assert!(
3843            err.to_string()
3844                .contains("extends must be a string or an array of strings"),
3845            "unexpected error: {err}"
3846        );
3847    }
3848
3849    #[test]
3850    fn extends_multiple_bases_later_wins() {
3851        let dir = test_dir("extends-multi-base");
3852
3853        std::fs::write(
3854            dir.path().join("base-a.json"),
3855            r#"{"rules": {"unused-files": "warn"}}"#,
3856        )
3857        .unwrap();
3858        std::fs::write(
3859            dir.path().join("base-b.json"),
3860            r#"{"rules": {"unused-files": "off"}}"#,
3861        )
3862        .unwrap();
3863        std::fs::write(
3864            dir.path().join(".fallowrc.json"),
3865            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
3866        )
3867        .unwrap();
3868
3869        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3870        assert_eq!(config.rules.unused_files, Severity::Off);
3871    }
3872
3873    #[test]
3874    fn load_rejects_empty_security_request_receivers() {
3875        let dir = test_dir("empty-security-request-receivers");
3876        std::fs::write(
3877            dir.path().join(".fallowrc.json"),
3878            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
3879        )
3880        .unwrap();
3881
3882        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3883        let err = result.expect_err("empty receiver should be rejected");
3884        assert!(
3885            err.to_string().contains("security.requestReceivers"),
3886            "error should name security.requestReceivers: {err}"
3887        );
3888    }
3889
3890    #[test]
3891    fn resolve_normalizes_security_request_receivers() {
3892        let dir = test_dir("normalize-security-request-receivers");
3893        std::fs::write(
3894            dir.path().join(".fallowrc.json"),
3895            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
3896        )
3897        .unwrap();
3898
3899        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
3900            .unwrap()
3901            .resolve(
3902                dir.path().to_path_buf(),
3903                OutputFormat::Human,
3904                1,
3905                true,
3906                true,
3907                None,
3908            );
3909        assert_eq!(
3910            config.security.request_receivers,
3911            vec!["httpreq".to_string(), "r".to_string()]
3912        );
3913    }
3914
3915    #[test]
3916    fn fallow_config_deserialize_production() {
3917        let json_str = r#"{"production": true}"#;
3918        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3919        assert!(config.production);
3920    }
3921
3922    #[test]
3923    fn fallow_config_production_defaults_false() {
3924        let config: FallowConfig = serde_json::from_str("{}").unwrap();
3925        assert!(!config.production);
3926    }
3927
3928    #[test]
3929    fn package_json_optional_dependency_names() {
3930        let pkg: PackageJson = serde_json::from_str(
3931            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
3932        )
3933        .unwrap();
3934        let opt = pkg.optional_dependency_names();
3935        assert_eq!(opt.len(), 2);
3936        assert!(opt.contains(&"fsevents".to_string()));
3937        assert!(opt.contains(&"chokidar".to_string()));
3938    }
3939
3940    #[test]
3941    fn package_json_optional_deps_empty_when_missing() {
3942        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
3943        assert!(pkg.optional_dependency_names().is_empty());
3944    }
3945
3946    #[test]
3947    fn find_config_path_returns_fallowrc_json() {
3948        let dir = test_dir("find-path-json");
3949        std::fs::create_dir(dir.path().join(".git")).unwrap();
3950        std::fs::write(
3951            dir.path().join(".fallowrc.json"),
3952            r#"{"entry": ["src/main.ts"]}"#,
3953        )
3954        .unwrap();
3955
3956        let path = FallowConfig::find_config_path(dir.path());
3957        assert!(path.is_some());
3958        assert!(path.unwrap().ends_with(".fallowrc.json"));
3959    }
3960
3961    #[test]
3962    fn find_config_path_returns_fallow_toml() {
3963        let dir = test_dir("find-path-toml");
3964        std::fs::create_dir(dir.path().join(".git")).unwrap();
3965        std::fs::write(
3966            dir.path().join("fallow.toml"),
3967            "entry = [\"src/main.ts\"]\n",
3968        )
3969        .unwrap();
3970
3971        let path = FallowConfig::find_config_path(dir.path());
3972        assert!(path.is_some());
3973        assert!(path.unwrap().ends_with("fallow.toml"));
3974    }
3975
3976    #[test]
3977    fn find_config_path_returns_dot_fallow_toml() {
3978        let dir = test_dir("find-path-dot-toml");
3979        std::fs::create_dir(dir.path().join(".git")).unwrap();
3980        std::fs::write(
3981            dir.path().join(".fallow.toml"),
3982            "entry = [\"src/main.ts\"]\n",
3983        )
3984        .unwrap();
3985
3986        let path = FallowConfig::find_config_path(dir.path());
3987        assert!(path.is_some());
3988        assert!(path.unwrap().ends_with(".fallow.toml"));
3989    }
3990
3991    #[test]
3992    fn find_config_path_prefers_json_over_toml() {
3993        let dir = test_dir("find-path-priority");
3994        std::fs::create_dir(dir.path().join(".git")).unwrap();
3995        std::fs::write(
3996            dir.path().join(".fallowrc.json"),
3997            r#"{"entry": ["json.ts"]}"#,
3998        )
3999        .unwrap();
4000        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
4001
4002        let path = FallowConfig::find_config_path(dir.path());
4003        assert!(path.unwrap().ends_with(".fallowrc.json"));
4004    }
4005
4006    #[test]
4007    fn find_config_path_none_when_no_config() {
4008        let dir = test_dir("find-path-none");
4009        std::fs::create_dir(dir.path().join(".git")).unwrap();
4010
4011        let path = FallowConfig::find_config_path(dir.path());
4012        assert!(path.is_none());
4013    }
4014
4015    #[test]
4016    fn find_config_path_walks_past_package_json_in_monorepo() {
4017        let dir = test_dir("find-path-monorepo");
4018        std::fs::create_dir(dir.path().join(".git")).unwrap();
4019        std::fs::write(
4020            dir.path().join(".fallowrc.json"),
4021            r#"{"entry": ["src/index.ts"]}"#,
4022        )
4023        .unwrap();
4024
4025        let sub = dir.path().join("packages").join("app");
4026        std::fs::create_dir_all(&sub).unwrap();
4027        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
4028
4029        let path = FallowConfig::find_config_path(&sub).unwrap();
4030        assert_eq!(path, dir.path().join(".fallowrc.json"));
4031    }
4032
4033    #[test]
4034    fn extends_toml_base() {
4035        let dir = test_dir("extends-toml");
4036
4037        std::fs::write(
4038            dir.path().join("base.json"),
4039            r#"{"rules": {"unused-files": "warn"}}"#,
4040        )
4041        .unwrap();
4042        std::fs::write(
4043            dir.path().join("fallow.toml"),
4044            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
4045        )
4046        .unwrap();
4047
4048        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4049        assert_eq!(config.rules.unused_files, Severity::Warn);
4050        assert_eq!(config.entry, vec!["src/index.ts"]);
4051    }
4052
4053    #[test]
4054    fn deep_merge_boolean_overlay() {
4055        let mut base = serde_json::json!(true);
4056        deep_merge_json(&mut base, serde_json::json!(false));
4057        assert_eq!(base, serde_json::json!(false));
4058    }
4059
4060    #[test]
4061    fn deep_merge_number_overlay() {
4062        let mut base = serde_json::json!(42);
4063        deep_merge_json(&mut base, serde_json::json!(99));
4064        assert_eq!(base, serde_json::json!(99));
4065    }
4066
4067    #[test]
4068    fn deep_merge_disjoint_objects() {
4069        let mut base = serde_json::json!({"a": 1});
4070        let overlay = serde_json::json!({"b": 2});
4071        deep_merge_json(&mut base, overlay);
4072        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
4073    }
4074
4075    #[test]
4076    fn max_extends_depth_is_reasonable() {
4077        assert_eq!(MAX_EXTENDS_DEPTH, 10);
4078    }
4079
4080    #[test]
4081    fn config_names_has_four_entries() {
4082        assert_eq!(CONFIG_NAMES.len(), 4);
4083        for name in CONFIG_NAMES {
4084            assert!(
4085                name.starts_with('.') || name.starts_with("fallow"),
4086                "unexpected config name: {name}"
4087            );
4088        }
4089    }
4090
4091    #[test]
4092    fn package_json_peer_dependency_names() {
4093        let pkg: PackageJson = serde_json::from_str(
4094            r#"{
4095            "dependencies": {"react": "^18"},
4096            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
4097        }"#,
4098        )
4099        .unwrap();
4100        let all = pkg.all_dependency_names();
4101        assert!(all.contains(&"react".to_string()));
4102        assert!(all.contains(&"react-dom".to_string()));
4103        assert!(all.contains(&"react-native".to_string()));
4104    }
4105
4106    #[test]
4107    fn package_json_scripts_field() {
4108        let pkg: PackageJson = serde_json::from_str(
4109            r#"{
4110            "scripts": {
4111                "build": "tsc",
4112                "test": "vitest",
4113                "lint": "fallow check"
4114            }
4115        }"#,
4116        )
4117        .unwrap();
4118        let scripts = pkg.scripts.unwrap();
4119        assert_eq!(scripts.len(), 3);
4120        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
4121        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
4122    }
4123
4124    #[test]
4125    fn extends_toml_chain() {
4126        let dir = test_dir("extends-toml-chain");
4127
4128        std::fs::write(
4129            dir.path().join("base.json"),
4130            r#"{"entry": ["src/base.ts"]}"#,
4131        )
4132        .unwrap();
4133        std::fs::write(
4134            dir.path().join("middle.json"),
4135            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4136        )
4137        .unwrap();
4138        std::fs::write(
4139            dir.path().join("fallow.toml"),
4140            "extends = [\"middle.json\"]\n",
4141        )
4142        .unwrap();
4143
4144        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4145        assert_eq!(config.entry, vec!["src/base.ts"]);
4146        assert_eq!(config.rules.unused_files, Severity::Off);
4147    }
4148
4149    #[test]
4150    fn find_and_load_walks_up_directories() {
4151        let dir = test_dir("find-walk-up");
4152        let sub = dir.path().join("src").join("deep");
4153        std::fs::create_dir_all(&sub).unwrap();
4154        std::fs::write(
4155            dir.path().join(".fallowrc.json"),
4156            r#"{"entry": ["src/main.ts"]}"#,
4157        )
4158        .unwrap();
4159        std::fs::create_dir(dir.path().join(".git")).unwrap();
4160
4161        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4162        assert_eq!(config.entry, vec!["src/main.ts"]);
4163        assert!(path.ends_with(".fallowrc.json"));
4164    }
4165
4166    #[test]
4167    fn json_schema_contains_entry_field() {
4168        let schema = FallowConfig::json_schema();
4169        let obj = schema.as_object().unwrap();
4170        let props = obj.get("properties").and_then(|v| v.as_object());
4171        assert!(props.is_some(), "schema should have properties");
4172        assert!(
4173            props.unwrap().contains_key("entry"),
4174            "schema should contain entry property"
4175        );
4176    }
4177
4178    #[test]
4179    fn fallow_config_json_duplicates_all_fields() {
4180        let json = r#"{
4181            "duplicates": {
4182                "enabled": true,
4183                "mode": "semantic",
4184                "minTokens": 200,
4185                "minLines": 20,
4186                "threshold": 10.5,
4187                "ignore": ["**/*.test.ts"],
4188                "skipLocal": true,
4189                "crossLanguage": true,
4190                "normalization": {
4191                    "ignoreIdentifiers": true,
4192                    "ignoreStringValues": false
4193                }
4194            }
4195        }"#;
4196        let config: FallowConfig = serde_json::from_str(json).unwrap();
4197        assert!(config.duplicates.enabled);
4198        assert_eq!(
4199            config.duplicates.mode,
4200            crate::config::DetectionMode::Semantic
4201        );
4202        assert_eq!(config.duplicates.min_tokens, 200);
4203        assert_eq!(config.duplicates.min_lines, 20);
4204        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4205        assert!(config.duplicates.skip_local);
4206        assert!(config.duplicates.cross_language);
4207        assert_eq!(
4208            config.duplicates.normalization.ignore_identifiers,
4209            Some(true)
4210        );
4211        assert_eq!(
4212            config.duplicates.normalization.ignore_string_values,
4213            Some(false)
4214        );
4215    }
4216
4217    #[test]
4218    fn normalize_url_basic() {
4219        assert_eq!(
4220            normalize_url_for_dedup("https://example.com/config.json"),
4221            "https://example.com/config.json"
4222        );
4223    }
4224
4225    #[test]
4226    fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4227        let cases = [
4228            (
4229                "https://user:password@example.com/config.json",
4230                "https://example.com/config.json",
4231            ),
4232            (
4233                "https://example.com/config.json?token=query-secret#fragment-secret",
4234                "https://example.com/config.json",
4235            ),
4236            (
4237                "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4238                "https://[2001:db8::1]:8443/config.json",
4239            ),
4240            (
4241                "/workspace/configs/base.json?literal-query#literal-fragment",
4242                "/workspace/configs/base.json?literal-query#literal-fragment",
4243            ),
4244        ];
4245
4246        for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4247            assert!(
4248                remote_config_display(input) == expected,
4249                "remote config display case {case_index} must be sanitized"
4250            );
4251        }
4252    }
4253
4254    #[test]
4255    fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4256        let redirect_target =
4257            "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4258        let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4259
4260        let display =
4261            remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4262
4263        assert_eq!(
4264            display,
4265            "configured for https only: http://example.com/config.json"
4266        );
4267        for secret in [
4268            "redirect-user",
4269            "redirect-password",
4270            "token=secret",
4271            "anchor",
4272        ] {
4273            assert!(
4274                !display.contains(secret),
4275                "redirect secret must be redacted"
4276            );
4277        }
4278    }
4279
4280    #[test]
4281    fn normalize_url_preserves_non_root_trailing_slash() {
4282        assert_eq!(
4283            normalize_url_for_dedup("https://example.com/config/"),
4284            "https://example.com/config/"
4285        );
4286    }
4287
4288    #[test]
4289    fn normalize_url_uppercase_scheme_and_host() {
4290        assert_eq!(
4291            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4292            "https://example.com/Config.json"
4293        );
4294    }
4295
4296    #[test]
4297    fn normalize_url_root_path() {
4298        assert_eq!(
4299            normalize_url_for_dedup("https://example.com/"),
4300            "https://example.com"
4301        );
4302        assert_eq!(
4303            normalize_url_for_dedup("https://example.com"),
4304            "https://example.com"
4305        );
4306    }
4307
4308    #[test]
4309    fn normalize_url_preserves_path_case() {
4310        assert_eq!(
4311            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4312            "https://github.com/Org/Repo/Fallow.json"
4313        );
4314    }
4315
4316    #[test]
4317    fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4318        assert_eq!(
4319            normalize_url_for_dedup(
4320                "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4321            ),
4322            "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4323        );
4324    }
4325
4326    #[test]
4327    fn normalize_url_preserves_query_string() {
4328        assert_eq!(
4329            normalize_url_for_dedup("https://example.com/config.json?v=1"),
4330            "https://example.com/config.json?v=1"
4331        );
4332    }
4333
4334    #[test]
4335    fn normalize_url_strips_fragment() {
4336        assert_eq!(
4337            normalize_url_for_dedup("https://example.com/config.json#section"),
4338            "https://example.com/config.json"
4339        );
4340    }
4341
4342    #[test]
4343    fn normalize_url_preserves_query_and_strips_fragment() {
4344        assert_eq!(
4345            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4346            "https://example.com/config.json?v=1"
4347        );
4348    }
4349
4350    #[test]
4351    fn normalize_url_query_selects_resource_but_fragment_does_not() {
4352        assert_ne!(
4353            normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4354            normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4355        );
4356        assert_eq!(
4357            normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4358            normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4359        );
4360    }
4361
4362    #[test]
4363    fn normalize_url_default_https_port() {
4364        assert_eq!(
4365            normalize_url_for_dedup("https://example.com:443/config.json"),
4366            "https://example.com/config.json"
4367        );
4368        assert_eq!(
4369            normalize_url_for_dedup("https://example.com:8443/config.json"),
4370            "https://example.com:8443/config.json"
4371        );
4372    }
4373
4374    #[test]
4375    fn normalize_url_only_strips_the_scheme_default_port() {
4376        assert_eq!(
4377            normalize_url_for_dedup("http://example.com:80/config.json"),
4378            "http://example.com/config.json"
4379        );
4380        assert_eq!(
4381            normalize_url_for_dedup("http://example.com:443/config.json"),
4382            "http://example.com:443/config.json"
4383        );
4384        assert_eq!(
4385            normalize_url_for_dedup("https://example.com:80/config.json"),
4386            "https://example.com:80/config.json"
4387        );
4388    }
4389
4390    #[test]
4391    fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4392        assert_eq!(
4393            normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4394            "https://example.com?profile=one"
4395        );
4396        assert_eq!(
4397            normalize_url_for_dedup("https://example.com:080/config.json"),
4398            "https://example.com:80/config.json"
4399        );
4400    }
4401
4402    #[test]
4403    fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4404        assert_eq!(
4405            normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4406            "https://[2001:db8::1]/config.json"
4407        );
4408    }
4409
4410    #[test]
4411    fn extends_http_rejected() {
4412        let dir = test_dir("http-rejected");
4413        std::fs::write(
4414            dir.path().join(".fallowrc.json"),
4415            r#"{"extends": "http://example.com/config.json"}"#,
4416        )
4417        .unwrap();
4418
4419        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4420        assert!(result.is_err());
4421        let err_msg = format!("{}", result.unwrap_err());
4422        assert!(
4423            err_msg.contains("https://"),
4424            "Expected https hint in error, got: {err_msg}"
4425        );
4426        assert!(
4427            err_msg.contains("http://"),
4428            "Expected http:// mention in error, got: {err_msg}"
4429        );
4430    }
4431
4432    #[test]
4433    fn extends_url_circular_detection() {
4434        let mut visited = FxHashSet::default();
4435        let url = "https://example.com/config.json";
4436        let normalized = normalize_url_for_dedup(url);
4437        visited.insert(normalized.clone());
4438
4439        assert!(
4440            !visited.insert(normalized),
4441            "Same URL should be detected as duplicate"
4442        );
4443    }
4444
4445    #[test]
4446    fn extends_url_circular_case_insensitive() {
4447        let mut visited = FxHashSet::default();
4448        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4449
4450        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4451        assert!(
4452            !visited.insert(normalized),
4453            "Case-different URLs should normalize to the same key"
4454        );
4455    }
4456
4457    #[test]
4458    fn extract_extends_array() {
4459        let mut value = serde_json::json!({
4460            "extends": ["a.json", "b.json"],
4461            "entry": ["src/index.ts"]
4462        });
4463        let extends = extract_extends(&mut value, "test.json").unwrap();
4464        assert_eq!(extends, vec!["a.json", "b.json"]);
4465        assert!(value.get("extends").is_none());
4466        assert!(value.get("entry").is_some());
4467    }
4468
4469    #[test]
4470    fn extract_extends_string_sugar() {
4471        let mut value = serde_json::json!({
4472            "extends": "base.json",
4473            "entry": ["src/index.ts"]
4474        });
4475        let extends = extract_extends(&mut value, "test.json").unwrap();
4476        assert_eq!(extends, vec!["base.json"]);
4477    }
4478
4479    #[test]
4480    fn extract_extends_none() {
4481        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4482        let extends = extract_extends(&mut value, "test.json").unwrap();
4483        assert!(extends.is_empty());
4484    }
4485
4486    #[test]
4487    fn url_timeout_default() {
4488        let timeout = url_timeout();
4489        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4490    }
4491
4492    #[test]
4493    fn extends_url_mixed_with_file_and_npm() {
4494        let dir = test_dir("url-mixed");
4495        std::fs::write(
4496            dir.path().join("local.json"),
4497            r#"{"rules": {"unused-files": "warn"}}"#,
4498        )
4499        .unwrap();
4500        std::fs::write(
4501            dir.path().join(".fallowrc.json"),
4502            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4503        )
4504        .unwrap();
4505
4506        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4507        assert!(result.is_err());
4508        let err_msg = format!("{}", result.unwrap_err());
4509        assert!(
4510            err_msg.contains("unreachable.invalid"),
4511            "Expected URL in error message, got: {err_msg}"
4512        );
4513    }
4514
4515    #[test]
4516    fn extends_https_url_default_denial_has_opt_in_hint() {
4517        let dir = test_dir("url-unreachable");
4518        std::fs::write(
4519            dir.path().join(".fallowrc.json"),
4520            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4521        )
4522        .unwrap();
4523
4524        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4525        assert!(result.is_err());
4526        let err_msg = format!("{}", result.unwrap_err());
4527        assert!(
4528            err_msg.contains("unreachable.invalid"),
4529            "Expected URL in error, got: {err_msg}"
4530        );
4531        assert!(
4532            err_msg.contains("--allow-remote-extends"),
4533            "Expected remediation hint, got: {err_msg}"
4534        );
4535    }
4536
4537    #[test]
4538    fn collect_unknown_rule_keys_flags_top_level_typo() {
4539        let merged = serde_json::json!({
4540            "rules": {
4541                "unsued-files": "warn",
4542                "unused-exports": "off"
4543            }
4544        });
4545        let findings = collect_unknown_rule_keys(&merged);
4546        assert_eq!(findings.len(), 1);
4547        assert_eq!(findings[0].context, "rules");
4548        assert_eq!(findings[0].key, "unsued-files");
4549        assert_eq!(findings[0].suggestion, Some("unused-files"));
4550    }
4551
4552    #[test]
4553    fn collect_unknown_rule_keys_flags_overrides_typo() {
4554        let merged = serde_json::json!({
4555            "overrides": [
4556                {
4557                    "files": ["src/**/*.ts"],
4558                    "rules": {
4559                        "unsued-files": "warn"
4560                    }
4561                },
4562                {
4563                    "files": ["tests/**/*.ts"],
4564                    "rules": {
4565                        "circular-dependnecy": "off"
4566                    }
4567                }
4568            ]
4569        });
4570        let findings = collect_unknown_rule_keys(&merged);
4571        assert_eq!(findings.len(), 2);
4572        assert_eq!(findings[0].context, "overrides[0].rules");
4573        assert_eq!(findings[1].context, "overrides[1].rules");
4574        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4575    }
4576
4577    #[test]
4578    fn collect_unknown_rule_keys_empty_for_valid_config() {
4579        let merged = serde_json::json!({
4580            "rules": {
4581                "unused-files": "warn",
4582                "unused-file": "off",
4583                "circular-dependency": "off",
4584                "boundary-violations": "warn"
4585            },
4586            "overrides": [
4587                {
4588                    "files": ["src/**"],
4589                    "rules": {
4590                        "unused-exports": "warn"
4591                    }
4592                }
4593            ]
4594        });
4595        let findings = collect_unknown_rule_keys(&merged);
4596        assert!(
4597            findings.is_empty(),
4598            "valid rule names and aliases must not be flagged: {findings:?}"
4599        );
4600    }
4601
4602    #[test]
4603    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
4604        let merged = serde_json::json!({
4605            "entry": ["src/main.ts"]
4606        });
4607        let findings = collect_unknown_rule_keys(&merged);
4608        assert!(findings.is_empty());
4609    }
4610
4611    #[test]
4612    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
4613        let dir = test_dir("wiring");
4614        let path = dir.path().join(".fallowrc.json");
4615        let typo = format!(
4616            "wiring-probe-{}-{}",
4617            std::process::id(),
4618            std::time::SystemTime::now()
4619                .duration_since(std::time::UNIX_EPOCH)
4620                .map_or(0, |d| d.as_nanos())
4621        );
4622        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
4623
4624        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
4625
4626        assert!(
4627            config_res.is_ok(),
4628            "load should succeed in phase 1: {:?}",
4629            config_res.err()
4630        );
4631        assert_eq!(
4632            captured.len(),
4633            1,
4634            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
4635        );
4636        assert_eq!(captured[0].key, typo);
4637        assert_eq!(captured[0].context, "rules");
4638    }
4639
4640    #[test]
4641    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
4642        let dir = test_dir("misspelled-rule");
4643        std::fs::write(
4644            dir.path().join(".fallowrc.json"),
4645            r#"{"rules": {"unsued-files": "warn"}}"#,
4646        )
4647        .unwrap();
4648
4649        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4650            .expect("load should succeed in phase 1");
4651
4652        assert_eq!(config.rules.unused_files, Severity::Error);
4653    }
4654
4655    #[test]
4656    fn validate_resolved_boundaries_passes_on_valid_config() {
4657        let dir = test_dir("boundaries-valid");
4658        let config = FallowConfig {
4659            boundaries: crate::BoundaryConfig {
4660                coverage: crate::BoundaryCoverageConfig::default(),
4661                calls: crate::BoundaryCallsConfig::default(),
4662                preset: None,
4663                zones: vec![
4664                    crate::BoundaryZone {
4665                        name: "ui".to_string(),
4666                        patterns: vec!["src/components/**".to_string()],
4667                        auto_discover: vec![],
4668                        root: None,
4669                    },
4670                    crate::BoundaryZone {
4671                        name: "db".to_string(),
4672                        patterns: vec!["src/db/**".to_string()],
4673                        auto_discover: vec![],
4674                        root: None,
4675                    },
4676                ],
4677                rules: vec![crate::BoundaryRule {
4678                    from: "ui".to_string(),
4679                    allow: vec!["db".to_string()],
4680                    allow_type_only: vec![],
4681                }],
4682            },
4683            ..FallowConfig::default()
4684        };
4685        config
4686            .validate_resolved_boundaries(dir.path())
4687            .expect("valid config should pass");
4688    }
4689
4690    #[test]
4691    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
4692        let dir = test_dir("boundaries-unknown-zones");
4693        let config = FallowConfig {
4694            boundaries: crate::BoundaryConfig {
4695                coverage: crate::BoundaryCoverageConfig::default(),
4696                calls: crate::BoundaryCallsConfig::default(),
4697                preset: None,
4698                zones: vec![crate::BoundaryZone {
4699                    name: "ui".to_string(),
4700                    patterns: vec!["src/ui/**".to_string()],
4701                    auto_discover: vec![],
4702                    root: None,
4703                }],
4704                rules: vec![
4705                    crate::BoundaryRule {
4706                        from: "typo-from".to_string(),
4707                        allow: vec!["typo-allow".to_string()],
4708                        allow_type_only: vec!["typo-type-only".to_string()],
4709                    },
4710                    crate::BoundaryRule {
4711                        from: "ui".to_string(),
4712                        allow: vec!["another-typo".to_string()],
4713                        allow_type_only: vec![],
4714                    },
4715                ],
4716            },
4717            ..FallowConfig::default()
4718        };
4719
4720        let errors = config
4721            .validate_resolved_boundaries(dir.path())
4722            .expect_err("invalid zone refs should fail");
4723
4724        assert_eq!(errors.len(), 4, "got: {errors:?}");
4725
4726        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4727        assert!(
4728            rendered
4729                .iter()
4730                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
4731        );
4732        assert!(
4733            rendered
4734                .iter()
4735                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
4736        );
4737        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
4738            && m.contains("rules[0]")
4739            && m.contains("allowTypeOnly")));
4740        assert!(
4741            rendered.iter().any(|m| m.contains("another-typo")
4742                && m.contains("rules[1]")
4743                && m.contains("allow"))
4744        );
4745    }
4746
4747    #[test]
4748    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
4749        let dir = test_dir("boundaries-redundant-prefix");
4750        let config = FallowConfig {
4751            boundaries: crate::BoundaryConfig {
4752                coverage: crate::BoundaryCoverageConfig::default(),
4753                calls: crate::BoundaryCallsConfig::default(),
4754                preset: None,
4755                zones: vec![crate::BoundaryZone {
4756                    name: "ui".to_string(),
4757                    patterns: vec!["packages/app/src/**".to_string()],
4758                    auto_discover: vec![],
4759                    root: Some("packages/app/".to_string()),
4760                }],
4761                rules: vec![],
4762            },
4763            ..FallowConfig::default()
4764        };
4765
4766        let errors = config
4767            .validate_resolved_boundaries(dir.path())
4768            .expect_err("redundant root prefix should fail");
4769        assert_eq!(errors.len(), 1, "got: {errors:?}");
4770        let rendered = errors[0].to_string();
4771        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
4772        assert!(rendered.contains("zone 'ui'"));
4773    }
4774
4775    #[test]
4776    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
4777        let dir = test_dir("boundaries-mixed-errors");
4778        let config = FallowConfig {
4779            boundaries: crate::BoundaryConfig {
4780                coverage: crate::BoundaryCoverageConfig::default(),
4781                calls: crate::BoundaryCallsConfig::default(),
4782                preset: None,
4783                zones: vec![crate::BoundaryZone {
4784                    name: "ui".to_string(),
4785                    patterns: vec!["packages/app/src/**".to_string()],
4786                    auto_discover: vec![],
4787                    root: Some("packages/app/".to_string()),
4788                }],
4789                rules: vec![crate::BoundaryRule {
4790                    from: "ui".to_string(),
4791                    allow: vec!["typo-zone".to_string()],
4792                    allow_type_only: vec![],
4793                }],
4794            },
4795            ..FallowConfig::default()
4796        };
4797        let errors = config
4798            .validate_resolved_boundaries(dir.path())
4799            .expect_err("mixed errors should fail");
4800        assert_eq!(errors.len(), 2, "got: {errors:?}");
4801        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4802        assert!(
4803            rendered
4804                .iter()
4805                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
4806        );
4807        assert!(
4808            rendered
4809                .iter()
4810                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
4811        );
4812    }
4813
4814    #[test]
4815    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
4816        let dir = test_dir("boundaries-bulletproof");
4817        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4818        let config = FallowConfig {
4819            boundaries: crate::BoundaryConfig {
4820                coverage: crate::BoundaryCoverageConfig::default(),
4821                calls: crate::BoundaryCallsConfig::default(),
4822                preset: Some(crate::BoundaryPreset::Bulletproof),
4823                zones: vec![],
4824                rules: vec![],
4825            },
4826            ..FallowConfig::default()
4827        };
4828        config
4829            .validate_resolved_boundaries(dir.path())
4830            .expect("Bulletproof with discoverable features should pass");
4831    }
4832
4833    // ------------------------------------------------------------------
4834    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
4835    // ------------------------------------------------------------------
4836
4837    #[test]
4838    #[cfg_attr(miri, ignore)]
4839    fn parse_config_to_value_strips_utf8_bom() {
4840        let dir = test_dir("parse-bom");
4841        let path = dir.path().join("fallow.toml");
4842        // Write TOML with a UTF-8 BOM prefix
4843        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4844        std::fs::write(&path, content_with_bom).unwrap();
4845
4846        let value = parse_config_to_value(&path).unwrap();
4847        assert!(
4848            value.get("entry").is_some(),
4849            "BOM should be stripped before TOML parsing"
4850        );
4851    }
4852
4853    #[test]
4854    #[cfg_attr(miri, ignore)]
4855    fn parse_config_to_value_toml_parse_error() {
4856        let dir = test_dir("parse-toml-error");
4857        let path = dir.path().join("fallow.toml");
4858        std::fs::write(&path, "entry = [unquoted\n").unwrap();
4859
4860        let result = parse_config_to_value(&path);
4861        assert!(result.is_err());
4862        let err = result.unwrap_err().to_string();
4863        assert!(
4864            err.contains("Failed to parse config file"),
4865            "error should mention parse failure: {err}"
4866        );
4867    }
4868
4869    #[test]
4870    #[cfg_attr(miri, ignore)]
4871    fn parse_config_to_value_json_parse_error() {
4872        let dir = test_dir("parse-json-error");
4873        let path = dir.path().join(".fallowrc.json");
4874        std::fs::write(&path, "{ this is not json }").unwrap();
4875
4876        let result = parse_config_to_value(&path);
4877        assert!(result.is_err());
4878        let err = result.unwrap_err().to_string();
4879        assert!(
4880            err.contains("Failed to parse config file"),
4881            "error should mention parse failure: {err}"
4882        );
4883    }
4884
4885    #[test]
4886    #[cfg_attr(miri, ignore)]
4887    fn parse_config_to_value_missing_file_error() {
4888        let dir = test_dir("parse-missing");
4889        let path = dir.path().join("nonexistent.toml");
4890
4891        let result = parse_config_to_value(&path);
4892        assert!(result.is_err());
4893        let err = result.unwrap_err().to_string();
4894        assert!(
4895            err.contains("Failed to read config file"),
4896            "error should mention read failure: {err}"
4897        );
4898    }
4899
4900    // ------------------------------------------------------------------
4901    // is_repo_root: svn boundary
4902    // ------------------------------------------------------------------
4903
4904    #[test]
4905    #[cfg_attr(miri, ignore)]
4906    fn find_and_load_stops_at_svn_dir() {
4907        let dir = test_dir("find-svn-stop");
4908        let sub = dir.path().join("sub");
4909        std::fs::create_dir(&sub).unwrap();
4910        std::fs::create_dir(dir.path().join(".svn")).unwrap();
4911
4912        let result = FallowConfig::find_and_load(&sub).unwrap();
4913        assert!(result.is_none(), "svn boundary should stop config walk");
4914    }
4915
4916    // ------------------------------------------------------------------
4917    // validate_npm_package_name: dot-segment in the package name
4918    // (path traversal but using a single dot)
4919    // ------------------------------------------------------------------
4920
4921    #[test]
4922    #[cfg_attr(miri, ignore)]
4923    fn extends_npm_single_dot_package_name_rejected() {
4924        let dir = test_dir("npm-dot-name");
4925        std::fs::write(
4926            dir.path().join(".fallowrc.json"),
4927            r#"{"extends": "npm:./relative"}"#,
4928        )
4929        .unwrap();
4930
4931        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4932        assert!(result.is_err());
4933        let err = result.unwrap_err().to_string();
4934        assert!(
4935            err.contains("path traversal"),
4936            "single-dot component should be rejected as path traversal: {err}"
4937        );
4938    }
4939
4940    // ------------------------------------------------------------------
4941    // find_config_in_npm_package: main field points to nonexistent file,
4942    // falls through to config-name scan
4943    // ------------------------------------------------------------------
4944
4945    #[test]
4946    #[cfg_attr(miri, ignore)]
4947    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
4948        let dir = test_dir("npm-main-missing");
4949        let pkg_dir = dir.path().join("node_modules/my-config");
4950        std::fs::create_dir_all(&pkg_dir).unwrap();
4951        // package.json with main pointing at a file that does not exist
4952        std::fs::write(
4953            pkg_dir.join("package.json"),
4954            r#"{"name": "my-config", "main": "./missing.json"}"#,
4955        )
4956        .unwrap();
4957        // But a recognized config name is present for the fallback scan
4958        std::fs::write(
4959            pkg_dir.join(".fallowrc.json"),
4960            r#"{"rules": {"unused-files": "warn"}}"#,
4961        )
4962        .unwrap();
4963
4964        std::fs::write(
4965            dir.path().join(".fallowrc.json"),
4966            r#"{"extends": "npm:my-config"}"#,
4967        )
4968        .unwrap();
4969
4970        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4971        assert_eq!(config.rules.unused_files, Severity::Warn);
4972    }
4973
4974    // ------------------------------------------------------------------
4975    // find_config_in_npm_package: exports present but exports-pointed file
4976    // does not exist, falls through to main then config name
4977    // ------------------------------------------------------------------
4978
4979    #[test]
4980    #[cfg_attr(miri, ignore)]
4981    fn extends_npm_exports_nonexistent_falls_through_to_main() {
4982        let dir = test_dir("npm-exports-missing-file");
4983        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
4984        std::fs::create_dir_all(&pkg_dir).unwrap();
4985        // exports points to a file that does not exist; main is valid
4986        std::fs::write(
4987            pkg_dir.join("package.json"),
4988            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
4989        )
4990        .unwrap();
4991        std::fs::write(
4992            pkg_dir.join("real.json"),
4993            r#"{"rules": {"unused-types": "off"}}"#,
4994        )
4995        .unwrap();
4996
4997        std::fs::write(
4998            dir.path().join(".fallowrc.json"),
4999            r#"{"extends": "npm:cfg-pkg"}"#,
5000        )
5001        .unwrap();
5002
5003        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5004        assert_eq!(config.rules.unused_types, Severity::Off);
5005    }
5006
5007    // ------------------------------------------------------------------
5008    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
5009    // ------------------------------------------------------------------
5010
5011    #[test]
5012    fn normalize_url_no_scheme_returns_raw() {
5013        // A string without "://" must come back unchanged
5014        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
5015        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
5016    }
5017
5018    // ------------------------------------------------------------------
5019    // normalize_url_for_dedup: query before fragment (fragment then query)
5020    // ------------------------------------------------------------------
5021
5022    #[test]
5023    fn normalize_url_fragment_only_stripped() {
5024        // Fragment-only URL (no query)
5025        assert_eq!(
5026            normalize_url_for_dedup("https://example.com/file.json#anchor"),
5027            "https://example.com/file.json"
5028        );
5029    }
5030
5031    // ------------------------------------------------------------------
5032    // url_timeout: env var override
5033    // ------------------------------------------------------------------
5034
5035    // These exercise the pure `url_timeout_from` parser rather than mutating the
5036    // process-global env var, so they stay deterministic under parallel test
5037    // execution (an env-mutating version raced and failed on Windows CI).
5038    #[test]
5039    fn url_timeout_uses_env_var_when_set() {
5040        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
5041    }
5042
5043    #[test]
5044    fn url_timeout_zero_falls_back_to_default() {
5045        assert_eq!(
5046            url_timeout_from(Some("0")),
5047            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5048            "zero should fall back to the hardcoded default"
5049        );
5050    }
5051
5052    #[test]
5053    fn url_timeout_non_numeric_falls_back_to_default() {
5054        assert_eq!(
5055            url_timeout_from(Some("not-a-number")),
5056            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5057            "non-numeric value should fall back to the hardcoded default"
5058        );
5059    }
5060
5061    #[test]
5062    fn url_timeout_absent_uses_default() {
5063        assert_eq!(
5064            url_timeout_from(None),
5065            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
5066        );
5067    }
5068
5069    // ------------------------------------------------------------------
5070    // resolve_url_extends: depth limit reached
5071    // ------------------------------------------------------------------
5072
5073    #[test]
5074    fn resolve_url_extends_depth_limit_error() {
5075        let mut visited = FxHashSet::default();
5076        let result = resolve_url_extends(
5077            "https://example.invalid/config.json",
5078            &mut visited,
5079            MAX_EXTENDS_DEPTH, // at the limit
5080        );
5081        assert!(result.is_err());
5082        let err = result.unwrap_err().to_string();
5083        assert!(
5084            err.contains("too deep"),
5085            "error should mention depth limit: {err}"
5086        );
5087    }
5088
5089    // ------------------------------------------------------------------
5090    // resolve_extends_file: depth limit reached
5091    // ------------------------------------------------------------------
5092
5093    #[test]
5094    #[cfg_attr(miri, ignore)]
5095    fn resolve_extends_file_depth_limit_error() {
5096        let dir = test_dir("extends-file-depth");
5097        let path = dir.path().join(".fallowrc.json");
5098        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
5099
5100        let mut visited = FxHashSet::default();
5101        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
5102        assert!(result.is_err());
5103        let err = result.unwrap_err().to_string();
5104        assert!(
5105            err.contains("too deep"),
5106            "error should mention depth limit: {err}"
5107        );
5108    }
5109
5110    // ------------------------------------------------------------------
5111    // resolve_extends_file_entry: http:// in file-sourced extends
5112    // ------------------------------------------------------------------
5113
5114    #[test]
5115    #[cfg_attr(miri, ignore)]
5116    fn extends_http_url_in_file_extends_rejected() {
5117        let dir = test_dir("file-extends-http");
5118        std::fs::write(
5119            dir.path().join(".fallowrc.json"),
5120            r#"{"extends": ["http://example.com/config.json"]}"#,
5121        )
5122        .unwrap();
5123
5124        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5125        assert!(result.is_err());
5126        let err = result.unwrap_err().to_string();
5127        assert!(
5128            err.contains("https://"),
5129            "error should suggest https: {err}"
5130        );
5131    }
5132
5133    // ------------------------------------------------------------------
5134    // sealed_config_dir: when sealed = true canonicalization runs
5135    // ------------------------------------------------------------------
5136
5137    #[test]
5138    #[cfg_attr(miri, ignore)]
5139    fn sealed_config_dir_returns_some_when_sealed() {
5140        let dir = test_dir("sealed-dir");
5141        let result = sealed_config_dir(dir.path(), true);
5142        assert!(result.is_ok());
5143        assert!(
5144            result.unwrap().is_some(),
5145            "sealed=true must return Some(canonicalized path)"
5146        );
5147    }
5148
5149    #[test]
5150    fn sealed_config_dir_returns_none_when_not_sealed() {
5151        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5152        assert!(result.is_ok());
5153        assert!(result.unwrap().is_none(), "sealed=false must return None");
5154    }
5155
5156    // ------------------------------------------------------------------
5157    // collect_unknown_rule_keys: overrides entry without a rules key
5158    // (the inner `if let Some(rules)` branch is not taken)
5159    // ------------------------------------------------------------------
5160
5161    #[test]
5162    fn collect_unknown_rule_keys_override_without_rules_key() {
5163        let merged = serde_json::json!({
5164            "overrides": [
5165                {
5166                    "files": ["src/**/*.ts"]
5167                    // no "rules" key here
5168                },
5169                {
5170                    "files": ["tests/**"],
5171                    "rules": {
5172                        "unsued-exports": "off"
5173                    }
5174                }
5175            ]
5176        });
5177        let findings = collect_unknown_rule_keys(&merged);
5178        assert_eq!(
5179            findings.len(),
5180            1,
5181            "only the entry with rules should produce a finding"
5182        );
5183        assert_eq!(findings[0].context, "overrides[1].rules");
5184    }
5185
5186    // ------------------------------------------------------------------
5187    // FallowConfig::load: deserialization failure
5188    // ------------------------------------------------------------------
5189
5190    #[test]
5191    #[cfg_attr(miri, ignore)]
5192    fn load_fails_on_deserialization_error() {
5193        let dir = test_dir("deser-error");
5194        let path = dir.path().join(".fallowrc.json");
5195        // Valid JSON but contains a field value with the wrong type for the schema
5196        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5197
5198        let result = FallowConfig::load(&path);
5199        assert!(result.is_err());
5200        let err = result.unwrap_err().to_string();
5201        assert!(
5202            err.contains("invalid type") && err.contains(&path.display().to_string()),
5203            "error should lead with the reason and name the config file: {err}"
5204        );
5205    }
5206
5207    #[test]
5208    #[cfg_attr(miri, ignore)]
5209    fn unknown_override_field_error_names_path_once_and_hints_at_jsonc() {
5210        let dir = test_dir("override-unknown-field");
5211        let path = dir.path().join(".fallowrc.json");
5212        std::fs::write(
5213            &path,
5214            r#"{"overrides": [{"files": ["src/**"], "reason": "legacy"}]}"#,
5215        )
5216        .unwrap();
5217
5218        let err = FallowConfig::find_and_load(dir.path())
5219            .expect_err("unknown override field must be rejected");
5220        let path_str = path.display().to_string();
5221        assert!(
5222            err.contains("unknown field `reason`"),
5223            "error should lead with the serde reason: {err}"
5224        );
5225        assert_eq!(
5226            err.matches(&path_str).count(),
5227            1,
5228            "path must appear exactly once: {err}"
5229        );
5230        assert!(
5231            err.contains("// comment") && err.contains("JSONC"),
5232            "error should point annotations at JSONC comments: {err}"
5233        );
5234    }
5235
5236    // ------------------------------------------------------------------
5237    // FallowConfig::load: threshold override validation failure
5238    // (covers lines 921-927)
5239    // ------------------------------------------------------------------
5240
5241    #[test]
5242    #[cfg_attr(miri, ignore)]
5243    fn load_rejects_threshold_override_with_empty_files() {
5244        let dir = test_dir("threshold-empty-files");
5245        let path = dir.path().join(".fallowrc.json");
5246        std::fs::write(
5247            &path,
5248            r#"{
5249                "health": {
5250                    "thresholdOverrides": [
5251                        {"files": [], "maxCyclomatic": 30}
5252                    ]
5253                }
5254            }"#,
5255        )
5256        .unwrap();
5257
5258        let result = FallowConfig::load(&path);
5259        assert!(result.is_err());
5260        let err = result.unwrap_err().to_string();
5261        assert!(
5262            err.contains("thresholdOverrides"),
5263            "error should mention thresholdOverrides: {err}"
5264        );
5265        assert!(
5266            err.contains("files"),
5267            "error should name the files field: {err}"
5268        );
5269    }
5270
5271    #[test]
5272    #[cfg_attr(miri, ignore)]
5273    fn load_rejects_threshold_override_with_no_threshold_set() {
5274        let dir = test_dir("threshold-no-threshold");
5275        let path = dir.path().join(".fallowrc.json");
5276        std::fs::write(
5277            &path,
5278            r#"{
5279                "health": {
5280                    "thresholdOverrides": [
5281                        {"files": ["src/legacy.ts"]}
5282                    ]
5283                }
5284            }"#,
5285        )
5286        .unwrap();
5287
5288        let result = FallowConfig::load(&path);
5289        assert!(result.is_err());
5290        let err = result.unwrap_err().to_string();
5291        assert!(
5292            err.contains("maxCyclomatic")
5293                || err.contains("maxCognitive")
5294                || err.contains("maxCrap"),
5295            "error should name at least one threshold field: {err}"
5296        );
5297    }
5298
5299    // ------------------------------------------------------------------
5300    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
5301    // validation (covers lines 1026-1032)
5302    // ------------------------------------------------------------------
5303
5304    #[test]
5305    #[cfg_attr(miri, ignore)]
5306    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5307        let dir = test_dir("invalid-catalog-consumer-glob");
5308        let path = dir.path().join(".fallowrc.json");
5309        std::fs::write(
5310            &path,
5311            r#"{
5312                "ignoreCatalogReferences": [
5313                    {"package": "react", "consumer": "[invalid-glob"}
5314                ]
5315            }"#,
5316        )
5317        .unwrap();
5318
5319        let result = FallowConfig::load(&path);
5320        assert!(result.is_err());
5321        let err = result.unwrap_err().to_string();
5322        assert!(
5323            err.contains("ignoreCatalogReferences"),
5324            "error should mention the field: {err}"
5325        );
5326    }
5327
5328    #[test]
5329    #[cfg_attr(miri, ignore)]
5330    fn load_accepts_ignore_catalog_references_without_consumer() {
5331        let dir = test_dir("catalog-ref-no-consumer");
5332        let path = dir.path().join(".fallowrc.json");
5333        std::fs::write(
5334            &path,
5335            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5336        )
5337        .unwrap();
5338
5339        let config = FallowConfig::load(&path).unwrap();
5340        assert_eq!(config.ignore_catalog_references.len(), 1);
5341        assert!(config.ignore_catalog_references[0].consumer.is_none());
5342    }
5343
5344    #[test]
5345    #[cfg_attr(miri, ignore)]
5346    fn load_accepts_unused_component_props_ignore_pattern() {
5347        let dir = test_dir("unused-component-props-ignore-pattern");
5348        let path = dir.path().join(".fallowrc.json");
5349        std::fs::write(
5350            &path,
5351            r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5352        )
5353        .unwrap();
5354
5355        let config = FallowConfig::load(&path).unwrap();
5356        assert_eq!(
5357            config.unused_component_props.ignore_pattern.as_deref(),
5358            Some("^_")
5359        );
5360    }
5361
5362    #[test]
5363    #[cfg_attr(miri, ignore)]
5364    fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5365        let dir = test_dir("unused-component-props-bad-regex");
5366        let path = dir.path().join(".fallowrc.json");
5367        // `[` opens an unterminated character class: invalid regex.
5368        std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5369
5370        let result = FallowConfig::load(&path);
5371        assert!(result.is_err());
5372        let err = result.unwrap_err().to_string();
5373        assert!(
5374            err.contains("unusedComponentProps.ignorePattern"),
5375            "error should mention the field: {err}"
5376        );
5377    }
5378
5379    #[test]
5380    #[cfg_attr(miri, ignore)]
5381    fn load_rejects_unknown_unused_component_props_field() {
5382        let dir = test_dir("unused-component-props-unknown-field");
5383        let path = dir.path().join(".fallowrc.json");
5384        std::fs::write(
5385            &path,
5386            r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5387        )
5388        .unwrap();
5389
5390        // `deny_unknown_fields` rejects the plural typo.
5391        assert!(FallowConfig::load(&path).is_err());
5392    }
5393
5394    #[test]
5395    #[cfg_attr(miri, ignore)]
5396    fn load_rejects_unknown_override_entry_key() {
5397        let dir = test_dir("override-entry-unknown-key");
5398        let path = dir.path().join(".fallowrc.json");
5399        std::fs::write(
5400            &path,
5401            r#"{"overrides": [{"files": ["src/**"], "rule": {"unused-files": "off"}}]}"#,
5402        )
5403        .unwrap();
5404
5405        // `deny_unknown_fields` rejects the `rule` typo instead of silently
5406        // producing an override with empty rules.
5407        assert!(FallowConfig::load(&path).is_err());
5408    }
5409
5410    #[test]
5411    #[cfg_attr(miri, ignore)]
5412    fn load_rejects_unknown_ignore_exports_entry_key() {
5413        let dir = test_dir("ignore-exports-entry-unknown-key");
5414        let path = dir.path().join(".fallowrc.json");
5415        std::fs::write(
5416            &path,
5417            r#"{"ignoreExports": [{"file": "src/a.ts", "exports": ["*"], "reason": "legacy"}]}"#,
5418        )
5419        .unwrap();
5420
5421        assert!(FallowConfig::load(&path).is_err());
5422    }
5423
5424    // ------------------------------------------------------------------
5425    // validate_resolved_boundaries: tsconfig rootDir filtering
5426    // (covers lines 1158-1160 - rootDir value is ".", starts with "..", or
5427    // is absolute; all should fall back to "src")
5428    // ------------------------------------------------------------------
5429
5430    #[test]
5431    #[cfg_attr(miri, ignore)]
5432    fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
5433        // No tsconfig.json present; parse_tsconfig_root_dir returns None,
5434        // unwrap_or_else supplies "src". This exercises the filter + fallback branch.
5435        let dir = test_dir("boundaries-preset-no-tsconfig");
5436        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5437        let config = FallowConfig {
5438            boundaries: crate::BoundaryConfig {
5439                coverage: crate::BoundaryCoverageConfig::default(),
5440                calls: crate::BoundaryCallsConfig::default(),
5441                preset: Some(crate::BoundaryPreset::Bulletproof),
5442                zones: vec![],
5443                rules: vec![],
5444            },
5445            ..FallowConfig::default()
5446        };
5447        // Should not panic; no zone-ref errors expected since preset adds zones
5448        let _ = config.validate_resolved_boundaries(dir.path());
5449    }
5450
5451    // ------------------------------------------------------------------
5452    // validate_user_globs: framework plugin invalid glob triggers error path
5453    // (covers lines 970-974)
5454    // ------------------------------------------------------------------
5455
5456    #[test]
5457    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5458        use crate::ExternalPluginDef;
5459        use crate::external_plugin::EntryPointRole;
5460        let config = FallowConfig {
5461            framework: vec![ExternalPluginDef {
5462                schema: None,
5463                name: "test-plugin".to_owned(),
5464                detection: None,
5465                enablers: vec![],
5466                entry_points: vec!["[invalid-glob".to_owned()],
5467                entry_point_role: EntryPointRole::Support,
5468                manifest_entries: vec![],
5469                config_patterns: vec![],
5470                always_used: vec![],
5471                tooling_dependencies: vec![],
5472                used_exports: vec![],
5473                used_class_members: vec![],
5474            }],
5475            ..FallowConfig::default()
5476        };
5477
5478        let result = config.validate_user_globs();
5479        assert!(
5480            result.is_err(),
5481            "invalid entry_points glob should fail validation"
5482        );
5483        let errors = result.unwrap_err();
5484        assert!(!errors.is_empty());
5485    }
5486
5487    // ------------------------------------------------------------------
5488    // shadowed_config_names: no lower-precedence names after the last index
5489    // ------------------------------------------------------------------
5490
5491    #[test]
5492    #[cfg_attr(miri, ignore)]
5493    fn shadowed_config_names_empty_when_last_config_wins() {
5494        let dir = test_dir("shadow-last");
5495        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5496        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
5497        assert!(shadowed_config_names(dir.path(), 3).is_empty());
5498    }
5499
5500    // ------------------------------------------------------------------
5501    // warn_on_coexisting_configs: path without filename (edge branch)
5502    // shadowed is empty -> early return without recording
5503    // ------------------------------------------------------------------
5504
5505    #[test]
5506    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5507        let ((), captured) = capture_coexisting_config_warnings(|| {
5508            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5509        });
5510        assert!(
5511            captured.is_empty(),
5512            "empty shadowed list must produce no warning"
5513        );
5514    }
5515
5516    // ------------------------------------------------------------------
5517    // extract_extends: malformed values fail loud instead of silently
5518    // skipping the base config (the key is removed before deserialization,
5519    // so deny_unknown_fields cannot catch these)
5520    // ------------------------------------------------------------------
5521
5522    #[test]
5523    fn extract_extends_rejects_number_value() {
5524        let mut value = serde_json::json!({"extends": 42});
5525        let err = extract_extends(&mut value, "test.json").unwrap_err();
5526        assert!(
5527            err.to_string()
5528                .contains("extends must be a string or an array of strings, got a number"),
5529            "unexpected error: {err}"
5530        );
5531    }
5532
5533    #[test]
5534    fn extract_extends_rejects_object_value() {
5535        let mut value = serde_json::json!({"extends": {"path": "./base.json"}});
5536        let err = extract_extends(&mut value, "test.json").unwrap_err();
5537        assert!(
5538            err.to_string()
5539                .contains("extends must be a string or an array of strings, got an object"),
5540            "unexpected error: {err}"
5541        );
5542    }
5543
5544    #[test]
5545    fn extract_extends_rejects_non_string_array_entry() {
5546        let mut value = serde_json::json!({"extends": ["a.json", 42]});
5547        let err = extract_extends(&mut value, "test.json").unwrap_err();
5548        assert!(
5549            err.to_string()
5550                .contains("extends entries must be strings, got a number"),
5551            "unexpected error: {err}"
5552        );
5553    }
5554
5555    #[test]
5556    #[cfg_attr(miri, ignore)]
5557    fn load_rejects_malformed_extends_value() {
5558        let dir = test_dir("malformed-extends");
5559        let path = dir.path().join(".fallowrc.json");
5560        std::fs::write(&path, r#"{"extends": 42}"#).unwrap();
5561
5562        let err = FallowConfig::load(&path).unwrap_err();
5563        assert!(
5564            err.to_string()
5565                .contains("extends must be a string or an array of strings"),
5566            "unexpected error: {err}"
5567        );
5568    }
5569
5570    // ------------------------------------------------------------------
5571    // Typed resource identities keep local and remote namespaces disjoint.
5572    // ------------------------------------------------------------------
5573
5574    #[test]
5575    #[cfg_attr(miri, ignore)]
5576    fn config_resource_identity_cannot_collide_across_kinds() {
5577        let dir = test_dir("visit-circular");
5578        let path = dir.path().join("config.json");
5579        std::fs::write(&path, "{}").unwrap();
5580
5581        let canonical = dunce::canonicalize(path).unwrap();
5582        let local = ConfigResourceId::Local(canonical.clone());
5583        let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
5584        assert_ne!(local, remote);
5585    }
5586
5587    // ------------------------------------------------------------------
5588    // find_and_load: stops at .svn dir (is_repo_root branch)
5589    // ------------------------------------------------------------------
5590
5591    #[test]
5592    #[cfg_attr(miri, ignore)]
5593    fn find_config_path_stops_at_svn_dir() {
5594        let dir = test_dir("find-path-svn");
5595        let sub = dir.path().join("sub");
5596        std::fs::create_dir(&sub).unwrap();
5597        std::fs::create_dir(dir.path().join(".svn")).unwrap();
5598
5599        let path = FallowConfig::find_config_path(&sub);
5600        assert!(path.is_none(), "svn root should stop config search");
5601    }
5602
5603    // ------------------------------------------------------------------
5604    // deep_merge: array over object replaces
5605    // ------------------------------------------------------------------
5606
5607    #[test]
5608    fn deep_merge_array_over_object_replaces() {
5609        let mut base = serde_json::json!({"key": "value"});
5610        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
5611        assert_eq!(base, serde_json::json!(["a", "b"]));
5612    }
5613
5614    // ------------------------------------------------------------------
5615    // find_and_load: returns an error when config parses but glob validation fails
5616    // ------------------------------------------------------------------
5617
5618    #[test]
5619    #[cfg_attr(miri, ignore)]
5620    fn find_and_load_returns_error_for_invalid_glob_in_config() {
5621        let dir = test_dir("find-invalid-glob");
5622        std::fs::create_dir(dir.path().join(".git")).unwrap();
5623        std::fs::write(
5624            dir.path().join(".fallowrc.json"),
5625            r#"{"entry": ["[invalid-glob"]}"#,
5626        )
5627        .unwrap();
5628
5629        let result = FallowConfig::find_and_load(dir.path());
5630        assert!(
5631            result.is_err(),
5632            "invalid glob should surface as an error from find_and_load"
5633        );
5634    }
5635
5636    #[test]
5637    #[cfg_attr(miri, ignore)]
5638    fn find_and_load_rejects_bare_finding_ignore_negation() {
5639        let dir = test_dir("find-bare-finding-ignore-negation");
5640        std::fs::create_dir(dir.path().join(".git")).unwrap();
5641        std::fs::write(
5642            dir.path().join(".fallowrc.json"),
5643            r#"{"ignoreFindings": ["!"]}"#,
5644        )
5645        .unwrap();
5646
5647        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5648        assert!(error.contains("ignoreFindings"), "error: {error}");
5649        assert!(error.contains("requires a pattern"), "error: {error}");
5650    }
5651
5652    #[test]
5653    #[cfg_attr(miri, ignore)]
5654    fn find_and_load_rejects_finding_ignore_set_that_exceeds_matcher_limits() {
5655        let dir = test_dir("find-oversized-finding-ignore-set");
5656        std::fs::create_dir(dir.path().join(".git")).unwrap();
5657        let alternatives = (0..50_000)
5658            .map(|index| format!("name{index}"))
5659            .collect::<Vec<_>>()
5660            .join(",");
5661        let config = serde_json::json!({
5662            "ignoreFindings": [format!("**/{{{alternatives}}}.ts")]
5663        });
5664        std::fs::write(
5665            dir.path().join(".fallowrc.json"),
5666            serde_json::to_vec(&config).unwrap(),
5667        )
5668        .unwrap();
5669
5670        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5671        assert!(error.contains("ignoreFindings"), "error: {error}");
5672        assert!(
5673            error.contains("cannot be compiled together"),
5674            "error: {error}"
5675        );
5676    }
5677
5678    // ------------------------------------------------------------------
5679    // resolve_package_exports: Object map with "." key that is not a
5680    // string or object returns None (the `_ => None` arm)
5681    // ------------------------------------------------------------------
5682
5683    #[test]
5684    fn resolve_package_exports_dot_key_array_returns_none() {
5685        // "." value is an array, which is neither String nor Object
5686        let pkg = serde_json::json!({
5687            "exports": {".": ["array-value"]}
5688        });
5689        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5690        assert!(result.is_none(), "array dot-export should return None");
5691    }
5692
5693    #[test]
5694    fn resolve_package_exports_exports_is_array_returns_none() {
5695        // top-level "exports" is an array (not String or Object)
5696        let pkg = serde_json::json!({
5697            "exports": ["./index.js"]
5698        });
5699        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5700        assert!(result.is_none(), "array-form exports should return None");
5701    }
5702
5703    #[test]
5704    fn resolve_package_exports_object_no_dot_key_returns_none() {
5705        // Object exports without "." key
5706        let pkg = serde_json::json!({
5707            "exports": {"./sub": "./sub.js"}
5708        });
5709        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5710        assert!(result.is_none(), "no dot key should return None");
5711    }
5712
5713    #[test]
5714    fn resolve_package_exports_conditions_without_known_key_returns_none() {
5715        // "." is an Object but none of the known condition keys are present
5716        let pkg = serde_json::json!({
5717            "exports": {".": {"browser": "./browser.js"}}
5718        });
5719        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5720        assert!(result.is_none(), "unknown condition key should return None");
5721    }
5722
5723    // ------------------------------------------------------------------
5724    // npm package: exports condition "import" key (one of the priority keys)
5725    // ------------------------------------------------------------------
5726
5727    #[test]
5728    #[cfg_attr(miri, ignore)]
5729    fn extends_npm_exports_import_condition() {
5730        let dir = test_dir("npm-import-cond");
5731        let pkg_dir = dir.path().join("node_modules/import-config");
5732        std::fs::create_dir_all(&pkg_dir).unwrap();
5733        std::fs::write(
5734            pkg_dir.join("package.json"),
5735            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
5736        )
5737        .unwrap();
5738        std::fs::write(
5739            pkg_dir.join("esm.json"),
5740            r#"{"rules": {"unused-types": "warn"}}"#,
5741        )
5742        .unwrap();
5743
5744        std::fs::write(
5745            dir.path().join(".fallowrc.json"),
5746            r#"{"extends": "npm:import-config"}"#,
5747        )
5748        .unwrap();
5749
5750        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5751        assert_eq!(config.rules.unused_types, Severity::Warn);
5752    }
5753
5754    // ------------------------------------------------------------------
5755    // npm package: exports condition "require" key
5756    // ------------------------------------------------------------------
5757
5758    #[test]
5759    #[cfg_attr(miri, ignore)]
5760    fn extends_npm_exports_require_condition() {
5761        let dir = test_dir("npm-require-cond");
5762        let pkg_dir = dir.path().join("node_modules/require-config");
5763        std::fs::create_dir_all(&pkg_dir).unwrap();
5764        std::fs::write(
5765            pkg_dir.join("package.json"),
5766            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
5767        )
5768        .unwrap();
5769        std::fs::write(
5770            pkg_dir.join("cjs.json"),
5771            r#"{"rules": {"unused-class-members": "warn"}}"#,
5772        )
5773        .unwrap();
5774
5775        std::fs::write(
5776            dir.path().join(".fallowrc.json"),
5777            r#"{"extends": "npm:require-config"}"#,
5778        )
5779        .unwrap();
5780
5781        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5782        assert_eq!(config.rules.unused_class_members, Severity::Warn);
5783    }
5784}