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