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 fallow_config_production_defaults_false() {
4070        let config: FallowConfig = serde_json::from_str("{}").unwrap();
4071        assert!(!config.production);
4072    }
4073
4074    #[test]
4075    fn package_json_optional_dependency_names() {
4076        let pkg: PackageJson = serde_json::from_str(
4077            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
4078        )
4079        .unwrap();
4080        let opt = pkg.optional_dependency_names();
4081        assert_eq!(opt.len(), 2);
4082        assert!(opt.contains(&"fsevents".to_string()));
4083        assert!(opt.contains(&"chokidar".to_string()));
4084    }
4085
4086    #[test]
4087    fn package_json_optional_deps_empty_when_missing() {
4088        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
4089        assert!(pkg.optional_dependency_names().is_empty());
4090    }
4091
4092    #[test]
4093    fn find_config_path_returns_fallowrc_json() {
4094        let dir = test_dir("find-path-json");
4095        std::fs::create_dir(dir.path().join(".git")).unwrap();
4096        std::fs::write(
4097            dir.path().join(".fallowrc.json"),
4098            r#"{"entry": ["src/main.ts"]}"#,
4099        )
4100        .unwrap();
4101
4102        let path = FallowConfig::find_config_path(dir.path());
4103        assert!(path.is_some());
4104        assert!(path.unwrap().ends_with(".fallowrc.json"));
4105    }
4106
4107    #[test]
4108    fn find_config_path_returns_fallow_toml() {
4109        let dir = test_dir("find-path-toml");
4110        std::fs::create_dir(dir.path().join(".git")).unwrap();
4111        std::fs::write(
4112            dir.path().join("fallow.toml"),
4113            "entry = [\"src/main.ts\"]\n",
4114        )
4115        .unwrap();
4116
4117        let path = FallowConfig::find_config_path(dir.path());
4118        assert!(path.is_some());
4119        assert!(path.unwrap().ends_with("fallow.toml"));
4120    }
4121
4122    #[test]
4123    fn find_config_path_returns_dot_fallow_toml() {
4124        let dir = test_dir("find-path-dot-toml");
4125        std::fs::create_dir(dir.path().join(".git")).unwrap();
4126        std::fs::write(
4127            dir.path().join(".fallow.toml"),
4128            "entry = [\"src/main.ts\"]\n",
4129        )
4130        .unwrap();
4131
4132        let path = FallowConfig::find_config_path(dir.path());
4133        assert!(path.is_some());
4134        assert!(path.unwrap().ends_with(".fallow.toml"));
4135    }
4136
4137    #[test]
4138    fn find_config_path_prefers_json_over_toml() {
4139        let dir = test_dir("find-path-priority");
4140        std::fs::create_dir(dir.path().join(".git")).unwrap();
4141        std::fs::write(
4142            dir.path().join(".fallowrc.json"),
4143            r#"{"entry": ["json.ts"]}"#,
4144        )
4145        .unwrap();
4146        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
4147
4148        let path = FallowConfig::find_config_path(dir.path());
4149        assert!(path.unwrap().ends_with(".fallowrc.json"));
4150    }
4151
4152    #[test]
4153    fn find_config_path_none_when_no_config() {
4154        let dir = test_dir("find-path-none");
4155        std::fs::create_dir(dir.path().join(".git")).unwrap();
4156
4157        let path = FallowConfig::find_config_path(dir.path());
4158        assert!(path.is_none());
4159    }
4160
4161    #[test]
4162    fn find_config_path_walks_past_package_json_in_monorepo() {
4163        let dir = test_dir("find-path-monorepo");
4164        std::fs::create_dir(dir.path().join(".git")).unwrap();
4165        std::fs::write(
4166            dir.path().join(".fallowrc.json"),
4167            r#"{"entry": ["src/index.ts"]}"#,
4168        )
4169        .unwrap();
4170
4171        let sub = dir.path().join("packages").join("app");
4172        std::fs::create_dir_all(&sub).unwrap();
4173        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
4174
4175        let path = FallowConfig::find_config_path(&sub).unwrap();
4176        assert_eq!(path, dir.path().join(".fallowrc.json"));
4177    }
4178
4179    #[test]
4180    fn extends_toml_base() {
4181        let dir = test_dir("extends-toml");
4182
4183        std::fs::write(
4184            dir.path().join("base.json"),
4185            r#"{"rules": {"unused-files": "warn"}}"#,
4186        )
4187        .unwrap();
4188        std::fs::write(
4189            dir.path().join("fallow.toml"),
4190            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
4191        )
4192        .unwrap();
4193
4194        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4195        assert_eq!(config.rules.unused_files, Severity::Warn);
4196        assert_eq!(config.entry, vec!["src/index.ts"]);
4197    }
4198
4199    #[test]
4200    fn deep_merge_boolean_overlay() {
4201        let mut base = serde_json::json!(true);
4202        deep_merge_json(&mut base, serde_json::json!(false));
4203        assert_eq!(base, serde_json::json!(false));
4204    }
4205
4206    #[test]
4207    fn deep_merge_number_overlay() {
4208        let mut base = serde_json::json!(42);
4209        deep_merge_json(&mut base, serde_json::json!(99));
4210        assert_eq!(base, serde_json::json!(99));
4211    }
4212
4213    #[test]
4214    fn deep_merge_disjoint_objects() {
4215        let mut base = serde_json::json!({"a": 1});
4216        let overlay = serde_json::json!({"b": 2});
4217        deep_merge_json(&mut base, overlay);
4218        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
4219    }
4220
4221    #[test]
4222    fn max_extends_depth_is_reasonable() {
4223        assert_eq!(MAX_EXTENDS_DEPTH, 10);
4224    }
4225
4226    #[test]
4227    fn config_names_has_four_entries() {
4228        assert_eq!(CONFIG_NAMES.len(), 4);
4229        for name in CONFIG_NAMES {
4230            assert!(
4231                name.starts_with('.') || name.starts_with("fallow"),
4232                "unexpected config name: {name}"
4233            );
4234        }
4235    }
4236
4237    #[test]
4238    fn package_json_peer_dependency_names() {
4239        let pkg: PackageJson = serde_json::from_str(
4240            r#"{
4241            "dependencies": {"react": "^18"},
4242            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
4243        }"#,
4244        )
4245        .unwrap();
4246        let all = pkg.all_dependency_names();
4247        assert!(all.contains(&"react".to_string()));
4248        assert!(all.contains(&"react-dom".to_string()));
4249        assert!(all.contains(&"react-native".to_string()));
4250    }
4251
4252    #[test]
4253    fn package_json_scripts_field() {
4254        let pkg: PackageJson = serde_json::from_str(
4255            r#"{
4256            "scripts": {
4257                "build": "tsc",
4258                "test": "vitest",
4259                "lint": "fallow check"
4260            }
4261        }"#,
4262        )
4263        .unwrap();
4264        let scripts = pkg.scripts.unwrap();
4265        assert_eq!(scripts.len(), 3);
4266        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
4267        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
4268    }
4269
4270    #[test]
4271    fn extends_toml_chain() {
4272        let dir = test_dir("extends-toml-chain");
4273
4274        std::fs::write(
4275            dir.path().join("base.json"),
4276            r#"{"entry": ["src/base.ts"]}"#,
4277        )
4278        .unwrap();
4279        std::fs::write(
4280            dir.path().join("middle.json"),
4281            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4282        )
4283        .unwrap();
4284        std::fs::write(
4285            dir.path().join("fallow.toml"),
4286            "extends = [\"middle.json\"]\n",
4287        )
4288        .unwrap();
4289
4290        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4291        assert_eq!(config.entry, vec!["src/base.ts"]);
4292        assert_eq!(config.rules.unused_files, Severity::Off);
4293    }
4294
4295    #[test]
4296    fn find_and_load_walks_up_directories() {
4297        let dir = test_dir("find-walk-up");
4298        let sub = dir.path().join("src").join("deep");
4299        std::fs::create_dir_all(&sub).unwrap();
4300        std::fs::write(
4301            dir.path().join(".fallowrc.json"),
4302            r#"{"entry": ["src/main.ts"]}"#,
4303        )
4304        .unwrap();
4305        std::fs::create_dir(dir.path().join(".git")).unwrap();
4306
4307        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4308        assert_eq!(config.entry, vec!["src/main.ts"]);
4309        assert!(path.ends_with(".fallowrc.json"));
4310    }
4311
4312    #[test]
4313    fn json_schema_contains_entry_field() {
4314        let schema = FallowConfig::json_schema();
4315        let obj = schema.as_object().unwrap();
4316        let props = obj.get("properties").and_then(|v| v.as_object());
4317        assert!(props.is_some(), "schema should have properties");
4318        assert!(
4319            props.unwrap().contains_key("entry"),
4320            "schema should contain entry property"
4321        );
4322    }
4323
4324    #[test]
4325    fn fallow_config_json_duplicates_all_fields() {
4326        let json = r#"{
4327            "duplicates": {
4328                "enabled": true,
4329                "mode": "semantic",
4330                "minTokens": 200,
4331                "minLines": 20,
4332                "threshold": 10.5,
4333                "ignore": ["**/*.test.ts"],
4334                "skipLocal": true,
4335                "crossLanguage": true,
4336                "normalization": {
4337                    "ignoreIdentifiers": true,
4338                    "ignoreStringValues": false
4339                }
4340            }
4341        }"#;
4342        let config: FallowConfig = serde_json::from_str(json).unwrap();
4343        assert!(config.duplicates.enabled);
4344        assert_eq!(
4345            config.duplicates.mode,
4346            crate::config::DetectionMode::Semantic
4347        );
4348        assert_eq!(config.duplicates.min_tokens, 200);
4349        assert_eq!(config.duplicates.min_lines, 20);
4350        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4351        assert!(config.duplicates.skip_local);
4352        assert!(config.duplicates.cross_language);
4353        assert_eq!(
4354            config.duplicates.normalization.ignore_identifiers,
4355            Some(true)
4356        );
4357        assert_eq!(
4358            config.duplicates.normalization.ignore_string_values,
4359            Some(false)
4360        );
4361    }
4362
4363    #[test]
4364    fn normalize_url_basic() {
4365        assert_eq!(
4366            normalize_url_for_dedup("https://example.com/config.json"),
4367            "https://example.com/config.json"
4368        );
4369    }
4370
4371    #[test]
4372    fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4373        let cases = [
4374            (
4375                "https://user:password@example.com/config.json",
4376                "https://example.com/config.json",
4377            ),
4378            (
4379                "https://example.com/config.json?token=query-secret#fragment-secret",
4380                "https://example.com/config.json",
4381            ),
4382            (
4383                "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4384                "https://[2001:db8::1]:8443/config.json",
4385            ),
4386            (
4387                "/workspace/configs/base.json?literal-query#literal-fragment",
4388                "/workspace/configs/base.json?literal-query#literal-fragment",
4389            ),
4390        ];
4391
4392        for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4393            assert!(
4394                remote_config_display(input) == expected,
4395                "remote config display case {case_index} must be sanitized"
4396            );
4397        }
4398    }
4399
4400    #[test]
4401    fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4402        let redirect_target =
4403            "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4404        let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4405
4406        let display =
4407            remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4408
4409        assert_eq!(
4410            display,
4411            "configured for https only: http://example.com/config.json"
4412        );
4413        for secret in [
4414            "redirect-user",
4415            "redirect-password",
4416            "token=secret",
4417            "anchor",
4418        ] {
4419            assert!(
4420                !display.contains(secret),
4421                "redirect secret must be redacted"
4422            );
4423        }
4424    }
4425
4426    #[test]
4427    fn normalize_url_preserves_non_root_trailing_slash() {
4428        assert_eq!(
4429            normalize_url_for_dedup("https://example.com/config/"),
4430            "https://example.com/config/"
4431        );
4432    }
4433
4434    #[test]
4435    fn normalize_url_uppercase_scheme_and_host() {
4436        assert_eq!(
4437            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4438            "https://example.com/Config.json"
4439        );
4440    }
4441
4442    #[test]
4443    fn normalize_url_root_path() {
4444        assert_eq!(
4445            normalize_url_for_dedup("https://example.com/"),
4446            "https://example.com"
4447        );
4448        assert_eq!(
4449            normalize_url_for_dedup("https://example.com"),
4450            "https://example.com"
4451        );
4452    }
4453
4454    #[test]
4455    fn normalize_url_preserves_path_case() {
4456        assert_eq!(
4457            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4458            "https://github.com/Org/Repo/Fallow.json"
4459        );
4460    }
4461
4462    #[test]
4463    fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4464        assert_eq!(
4465            normalize_url_for_dedup(
4466                "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4467            ),
4468            "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4469        );
4470    }
4471
4472    #[test]
4473    fn normalize_url_preserves_query_string() {
4474        assert_eq!(
4475            normalize_url_for_dedup("https://example.com/config.json?v=1"),
4476            "https://example.com/config.json?v=1"
4477        );
4478    }
4479
4480    #[test]
4481    fn normalize_url_strips_fragment() {
4482        assert_eq!(
4483            normalize_url_for_dedup("https://example.com/config.json#section"),
4484            "https://example.com/config.json"
4485        );
4486    }
4487
4488    #[test]
4489    fn normalize_url_preserves_query_and_strips_fragment() {
4490        assert_eq!(
4491            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4492            "https://example.com/config.json?v=1"
4493        );
4494    }
4495
4496    #[test]
4497    fn normalize_url_query_selects_resource_but_fragment_does_not() {
4498        assert_ne!(
4499            normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4500            normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4501        );
4502        assert_eq!(
4503            normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4504            normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4505        );
4506    }
4507
4508    #[test]
4509    fn normalize_url_default_https_port() {
4510        assert_eq!(
4511            normalize_url_for_dedup("https://example.com:443/config.json"),
4512            "https://example.com/config.json"
4513        );
4514        assert_eq!(
4515            normalize_url_for_dedup("https://example.com:8443/config.json"),
4516            "https://example.com:8443/config.json"
4517        );
4518    }
4519
4520    #[test]
4521    fn normalize_url_only_strips_the_scheme_default_port() {
4522        assert_eq!(
4523            normalize_url_for_dedup("http://example.com:80/config.json"),
4524            "http://example.com/config.json"
4525        );
4526        assert_eq!(
4527            normalize_url_for_dedup("http://example.com:443/config.json"),
4528            "http://example.com:443/config.json"
4529        );
4530        assert_eq!(
4531            normalize_url_for_dedup("https://example.com:80/config.json"),
4532            "https://example.com:80/config.json"
4533        );
4534    }
4535
4536    #[test]
4537    fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4538        assert_eq!(
4539            normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4540            "https://example.com?profile=one"
4541        );
4542        assert_eq!(
4543            normalize_url_for_dedup("https://example.com:080/config.json"),
4544            "https://example.com:80/config.json"
4545        );
4546    }
4547
4548    #[test]
4549    fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4550        assert_eq!(
4551            normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4552            "https://[2001:db8::1]/config.json"
4553        );
4554    }
4555
4556    #[test]
4557    fn extends_http_rejected() {
4558        let dir = test_dir("http-rejected");
4559        std::fs::write(
4560            dir.path().join(".fallowrc.json"),
4561            r#"{"extends": "http://example.com/config.json"}"#,
4562        )
4563        .unwrap();
4564
4565        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4566        assert!(result.is_err());
4567        let err_msg = format!("{}", result.unwrap_err());
4568        assert!(
4569            err_msg.contains("https://"),
4570            "Expected https hint in error, got: {err_msg}"
4571        );
4572        assert!(
4573            err_msg.contains("http://"),
4574            "Expected http:// mention in error, got: {err_msg}"
4575        );
4576    }
4577
4578    #[test]
4579    fn extends_url_circular_detection() {
4580        let mut visited = FxHashSet::default();
4581        let url = "https://example.com/config.json";
4582        let normalized = normalize_url_for_dedup(url);
4583        visited.insert(normalized.clone());
4584
4585        assert!(
4586            !visited.insert(normalized),
4587            "Same URL should be detected as duplicate"
4588        );
4589    }
4590
4591    #[test]
4592    fn extends_url_circular_case_insensitive() {
4593        let mut visited = FxHashSet::default();
4594        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4595
4596        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4597        assert!(
4598            !visited.insert(normalized),
4599            "Case-different URLs should normalize to the same key"
4600        );
4601    }
4602
4603    #[test]
4604    fn extract_extends_array() {
4605        let mut value = serde_json::json!({
4606            "extends": ["a.json", "b.json"],
4607            "entry": ["src/index.ts"]
4608        });
4609        let extends = extract_extends(&mut value, "test.json").unwrap();
4610        assert_eq!(extends, vec!["a.json", "b.json"]);
4611        assert!(value.get("extends").is_none());
4612        assert!(value.get("entry").is_some());
4613    }
4614
4615    #[test]
4616    fn extract_extends_string_sugar() {
4617        let mut value = serde_json::json!({
4618            "extends": "base.json",
4619            "entry": ["src/index.ts"]
4620        });
4621        let extends = extract_extends(&mut value, "test.json").unwrap();
4622        assert_eq!(extends, vec!["base.json"]);
4623    }
4624
4625    #[test]
4626    fn extract_extends_none() {
4627        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4628        let extends = extract_extends(&mut value, "test.json").unwrap();
4629        assert!(extends.is_empty());
4630    }
4631
4632    #[test]
4633    fn url_timeout_default() {
4634        let timeout = url_timeout();
4635        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4636    }
4637
4638    #[test]
4639    fn extends_url_mixed_with_file_and_npm() {
4640        let dir = test_dir("url-mixed");
4641        std::fs::write(
4642            dir.path().join("local.json"),
4643            r#"{"rules": {"unused-files": "warn"}}"#,
4644        )
4645        .unwrap();
4646        std::fs::write(
4647            dir.path().join(".fallowrc.json"),
4648            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4649        )
4650        .unwrap();
4651
4652        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4653        assert!(result.is_err());
4654        let err_msg = format!("{}", result.unwrap_err());
4655        assert!(
4656            err_msg.contains("unreachable.invalid"),
4657            "Expected URL in error message, got: {err_msg}"
4658        );
4659    }
4660
4661    #[test]
4662    fn extends_https_url_default_denial_has_opt_in_hint() {
4663        let dir = test_dir("url-unreachable");
4664        std::fs::write(
4665            dir.path().join(".fallowrc.json"),
4666            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4667        )
4668        .unwrap();
4669
4670        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4671        assert!(result.is_err());
4672        let err_msg = format!("{}", result.unwrap_err());
4673        assert!(
4674            err_msg.contains("unreachable.invalid"),
4675            "Expected URL in error, got: {err_msg}"
4676        );
4677        assert!(
4678            err_msg.contains("--allow-remote-extends"),
4679            "Expected remediation hint, got: {err_msg}"
4680        );
4681    }
4682
4683    #[test]
4684    fn collect_unknown_rule_keys_flags_top_level_typo() {
4685        let merged = serde_json::json!({
4686            "rules": {
4687                "unsued-files": "warn",
4688                "unused-exports": "off"
4689            }
4690        });
4691        let findings = collect_unknown_rule_keys(&merged);
4692        assert_eq!(findings.len(), 1);
4693        assert_eq!(findings[0].context, "rules");
4694        assert_eq!(findings[0].key, "unsued-files");
4695        assert_eq!(findings[0].suggestion, Some("unused-files"));
4696    }
4697
4698    #[test]
4699    fn collect_unknown_rule_keys_flags_overrides_typo() {
4700        let merged = serde_json::json!({
4701            "overrides": [
4702                {
4703                    "files": ["src/**/*.ts"],
4704                    "rules": {
4705                        "unsued-files": "warn"
4706                    }
4707                },
4708                {
4709                    "files": ["tests/**/*.ts"],
4710                    "rules": {
4711                        "circular-dependnecy": "off"
4712                    }
4713                }
4714            ]
4715        });
4716        let findings = collect_unknown_rule_keys(&merged);
4717        assert_eq!(findings.len(), 2);
4718        assert_eq!(findings[0].context, "overrides[0].rules");
4719        assert_eq!(findings[1].context, "overrides[1].rules");
4720        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4721    }
4722
4723    #[test]
4724    fn collect_unknown_rule_keys_empty_for_valid_config() {
4725        let merged = serde_json::json!({
4726            "rules": {
4727                "unused-files": "warn",
4728                "unused-file": "off",
4729                "circular-dependency": "off",
4730                "boundary-violations": "warn"
4731            },
4732            "overrides": [
4733                {
4734                    "files": ["src/**"],
4735                    "rules": {
4736                        "unused-exports": "warn"
4737                    }
4738                }
4739            ]
4740        });
4741        let findings = collect_unknown_rule_keys(&merged);
4742        assert!(
4743            findings.is_empty(),
4744            "valid rule names and aliases must not be flagged: {findings:?}"
4745        );
4746    }
4747
4748    #[test]
4749    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
4750        let merged = serde_json::json!({
4751            "entry": ["src/main.ts"]
4752        });
4753        let findings = collect_unknown_rule_keys(&merged);
4754        assert!(findings.is_empty());
4755    }
4756
4757    #[test]
4758    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
4759        let dir = test_dir("wiring");
4760        let path = dir.path().join(".fallowrc.json");
4761        let typo = format!(
4762            "wiring-probe-{}-{}",
4763            std::process::id(),
4764            std::time::SystemTime::now()
4765                .duration_since(std::time::UNIX_EPOCH)
4766                .map_or(0, |d| d.as_nanos())
4767        );
4768        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
4769
4770        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
4771
4772        assert!(
4773            config_res.is_ok(),
4774            "load should succeed in phase 1: {:?}",
4775            config_res.err()
4776        );
4777        assert_eq!(
4778            captured.len(),
4779            1,
4780            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
4781        );
4782        assert_eq!(captured[0].key, typo);
4783        assert_eq!(captured[0].context, "rules");
4784    }
4785
4786    #[test]
4787    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
4788        let dir = test_dir("misspelled-rule");
4789        std::fs::write(
4790            dir.path().join(".fallowrc.json"),
4791            r#"{"rules": {"unsued-files": "warn"}}"#,
4792        )
4793        .unwrap();
4794
4795        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4796            .expect("load should succeed in phase 1");
4797
4798        assert_eq!(config.rules.unused_files, Severity::Error);
4799    }
4800
4801    #[test]
4802    fn validate_resolved_boundaries_passes_on_valid_config() {
4803        let dir = test_dir("boundaries-valid");
4804        let config = FallowConfig {
4805            boundaries: crate::BoundaryConfig {
4806                coverage: crate::BoundaryCoverageConfig::default(),
4807                calls: crate::BoundaryCallsConfig::default(),
4808                preset: None,
4809                zones: vec![
4810                    crate::BoundaryZone {
4811                        name: "ui".to_string(),
4812                        patterns: vec!["src/components/**".to_string()],
4813                        auto_discover: vec![],
4814                        root: None,
4815                    },
4816                    crate::BoundaryZone {
4817                        name: "db".to_string(),
4818                        patterns: vec!["src/db/**".to_string()],
4819                        auto_discover: vec![],
4820                        root: None,
4821                    },
4822                ],
4823                rules: vec![crate::BoundaryRule {
4824                    from: "ui".to_string(),
4825                    allow: vec!["db".to_string()],
4826                    allow_type_only: vec![],
4827                }],
4828            },
4829            ..FallowConfig::default()
4830        };
4831        config
4832            .validate_resolved_boundaries(dir.path())
4833            .expect("valid config should pass");
4834    }
4835
4836    #[test]
4837    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
4838        let dir = test_dir("boundaries-unknown-zones");
4839        let config = FallowConfig {
4840            boundaries: crate::BoundaryConfig {
4841                coverage: crate::BoundaryCoverageConfig::default(),
4842                calls: crate::BoundaryCallsConfig::default(),
4843                preset: None,
4844                zones: vec![crate::BoundaryZone {
4845                    name: "ui".to_string(),
4846                    patterns: vec!["src/ui/**".to_string()],
4847                    auto_discover: vec![],
4848                    root: None,
4849                }],
4850                rules: vec![
4851                    crate::BoundaryRule {
4852                        from: "typo-from".to_string(),
4853                        allow: vec!["typo-allow".to_string()],
4854                        allow_type_only: vec!["typo-type-only".to_string()],
4855                    },
4856                    crate::BoundaryRule {
4857                        from: "ui".to_string(),
4858                        allow: vec!["another-typo".to_string()],
4859                        allow_type_only: vec![],
4860                    },
4861                ],
4862            },
4863            ..FallowConfig::default()
4864        };
4865
4866        let errors = config
4867            .validate_resolved_boundaries(dir.path())
4868            .expect_err("invalid zone refs should fail");
4869
4870        assert_eq!(errors.len(), 4, "got: {errors:?}");
4871
4872        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4873        assert!(
4874            rendered
4875                .iter()
4876                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
4877        );
4878        assert!(
4879            rendered
4880                .iter()
4881                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
4882        );
4883        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
4884            && m.contains("rules[0]")
4885            && m.contains("allowTypeOnly")));
4886        assert!(
4887            rendered.iter().any(|m| m.contains("another-typo")
4888                && m.contains("rules[1]")
4889                && m.contains("allow"))
4890        );
4891    }
4892
4893    #[test]
4894    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
4895        let dir = test_dir("boundaries-redundant-prefix");
4896        let config = FallowConfig {
4897            boundaries: crate::BoundaryConfig {
4898                coverage: crate::BoundaryCoverageConfig::default(),
4899                calls: crate::BoundaryCallsConfig::default(),
4900                preset: None,
4901                zones: vec![crate::BoundaryZone {
4902                    name: "ui".to_string(),
4903                    patterns: vec!["packages/app/src/**".to_string()],
4904                    auto_discover: vec![],
4905                    root: Some("packages/app/".to_string()),
4906                }],
4907                rules: vec![],
4908            },
4909            ..FallowConfig::default()
4910        };
4911
4912        let errors = config
4913            .validate_resolved_boundaries(dir.path())
4914            .expect_err("redundant root prefix should fail");
4915        assert_eq!(errors.len(), 1, "got: {errors:?}");
4916        let rendered = errors[0].to_string();
4917        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
4918        assert!(rendered.contains("zone 'ui'"));
4919    }
4920
4921    #[test]
4922    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
4923        let dir = test_dir("boundaries-mixed-errors");
4924        let config = FallowConfig {
4925            boundaries: crate::BoundaryConfig {
4926                coverage: crate::BoundaryCoverageConfig::default(),
4927                calls: crate::BoundaryCallsConfig::default(),
4928                preset: None,
4929                zones: vec![crate::BoundaryZone {
4930                    name: "ui".to_string(),
4931                    patterns: vec!["packages/app/src/**".to_string()],
4932                    auto_discover: vec![],
4933                    root: Some("packages/app/".to_string()),
4934                }],
4935                rules: vec![crate::BoundaryRule {
4936                    from: "ui".to_string(),
4937                    allow: vec!["typo-zone".to_string()],
4938                    allow_type_only: vec![],
4939                }],
4940            },
4941            ..FallowConfig::default()
4942        };
4943        let errors = config
4944            .validate_resolved_boundaries(dir.path())
4945            .expect_err("mixed errors should fail");
4946        assert_eq!(errors.len(), 2, "got: {errors:?}");
4947        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
4948        assert!(
4949            rendered
4950                .iter()
4951                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
4952        );
4953        assert!(
4954            rendered
4955                .iter()
4956                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
4957        );
4958    }
4959
4960    #[test]
4961    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
4962        let dir = test_dir("boundaries-bulletproof");
4963        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
4964        let config = FallowConfig {
4965            boundaries: crate::BoundaryConfig {
4966                coverage: crate::BoundaryCoverageConfig::default(),
4967                calls: crate::BoundaryCallsConfig::default(),
4968                preset: Some(crate::BoundaryPreset::Bulletproof),
4969                zones: vec![],
4970                rules: vec![],
4971            },
4972            ..FallowConfig::default()
4973        };
4974        config
4975            .validate_resolved_boundaries(dir.path())
4976            .expect("Bulletproof with discoverable features should pass");
4977    }
4978
4979    // ------------------------------------------------------------------
4980    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
4981    // ------------------------------------------------------------------
4982
4983    #[test]
4984    #[cfg_attr(miri, ignore)]
4985    fn parse_config_to_value_strips_utf8_bom() {
4986        let dir = test_dir("parse-bom");
4987        let path = dir.path().join("fallow.toml");
4988        // Write TOML with a UTF-8 BOM prefix
4989        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
4990        std::fs::write(&path, content_with_bom).unwrap();
4991
4992        let value = parse_config_to_value(&path).unwrap();
4993        assert!(
4994            value.get("entry").is_some(),
4995            "BOM should be stripped before TOML parsing"
4996        );
4997    }
4998
4999    #[test]
5000    #[cfg_attr(miri, ignore)]
5001    fn parse_config_to_value_toml_parse_error() {
5002        let dir = test_dir("parse-toml-error");
5003        let path = dir.path().join("fallow.toml");
5004        std::fs::write(&path, "entry = [unquoted\n").unwrap();
5005
5006        let result = parse_config_to_value(&path);
5007        assert!(result.is_err());
5008        let err = result.unwrap_err().to_string();
5009        assert!(
5010            err.contains("Failed to parse config file"),
5011            "error should mention parse failure: {err}"
5012        );
5013    }
5014
5015    #[test]
5016    #[cfg_attr(miri, ignore)]
5017    fn parse_config_to_value_json_parse_error() {
5018        let dir = test_dir("parse-json-error");
5019        let path = dir.path().join(".fallowrc.json");
5020        std::fs::write(&path, "{ this is not json }").unwrap();
5021
5022        let result = parse_config_to_value(&path);
5023        assert!(result.is_err());
5024        let err = result.unwrap_err().to_string();
5025        assert!(
5026            err.contains("Failed to parse config file"),
5027            "error should mention parse failure: {err}"
5028        );
5029    }
5030
5031    #[test]
5032    #[cfg_attr(miri, ignore)]
5033    fn parse_config_to_value_missing_file_error() {
5034        let dir = test_dir("parse-missing");
5035        let path = dir.path().join("nonexistent.toml");
5036
5037        let result = parse_config_to_value(&path);
5038        assert!(result.is_err());
5039        let err = result.unwrap_err().to_string();
5040        assert!(
5041            err.contains("Failed to read config file"),
5042            "error should mention read failure: {err}"
5043        );
5044    }
5045
5046    // ------------------------------------------------------------------
5047    // is_repo_root: svn boundary
5048    // ------------------------------------------------------------------
5049
5050    #[test]
5051    #[cfg_attr(miri, ignore)]
5052    fn find_and_load_stops_at_svn_dir() {
5053        let dir = test_dir("find-svn-stop");
5054        let sub = dir.path().join("sub");
5055        std::fs::create_dir(&sub).unwrap();
5056        std::fs::create_dir(dir.path().join(".svn")).unwrap();
5057
5058        let result = FallowConfig::find_and_load(&sub).unwrap();
5059        assert!(result.is_none(), "svn boundary should stop config walk");
5060    }
5061
5062    // ------------------------------------------------------------------
5063    // validate_npm_package_name: dot-segment in the package name
5064    // (path traversal but using a single dot)
5065    // ------------------------------------------------------------------
5066
5067    #[test]
5068    #[cfg_attr(miri, ignore)]
5069    fn extends_npm_single_dot_package_name_rejected() {
5070        let dir = test_dir("npm-dot-name");
5071        std::fs::write(
5072            dir.path().join(".fallowrc.json"),
5073            r#"{"extends": "npm:./relative"}"#,
5074        )
5075        .unwrap();
5076
5077        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5078        assert!(result.is_err());
5079        let err = result.unwrap_err().to_string();
5080        assert!(
5081            err.contains("path traversal"),
5082            "single-dot component should be rejected as path traversal: {err}"
5083        );
5084    }
5085
5086    // ------------------------------------------------------------------
5087    // find_config_in_npm_package: main field points to nonexistent file,
5088    // falls through to config-name scan
5089    // ------------------------------------------------------------------
5090
5091    #[test]
5092    #[cfg_attr(miri, ignore)]
5093    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
5094        let dir = test_dir("npm-main-missing");
5095        let pkg_dir = dir.path().join("node_modules/my-config");
5096        std::fs::create_dir_all(&pkg_dir).unwrap();
5097        // package.json with main pointing at a file that does not exist
5098        std::fs::write(
5099            pkg_dir.join("package.json"),
5100            r#"{"name": "my-config", "main": "./missing.json"}"#,
5101        )
5102        .unwrap();
5103        // But a recognized config name is present for the fallback scan
5104        std::fs::write(
5105            pkg_dir.join(".fallowrc.json"),
5106            r#"{"rules": {"unused-files": "warn"}}"#,
5107        )
5108        .unwrap();
5109
5110        std::fs::write(
5111            dir.path().join(".fallowrc.json"),
5112            r#"{"extends": "npm:my-config"}"#,
5113        )
5114        .unwrap();
5115
5116        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5117        assert_eq!(config.rules.unused_files, Severity::Warn);
5118    }
5119
5120    // ------------------------------------------------------------------
5121    // find_config_in_npm_package: exports present but exports-pointed file
5122    // does not exist, falls through to main then config name
5123    // ------------------------------------------------------------------
5124
5125    #[test]
5126    #[cfg_attr(miri, ignore)]
5127    fn extends_npm_exports_nonexistent_falls_through_to_main() {
5128        let dir = test_dir("npm-exports-missing-file");
5129        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
5130        std::fs::create_dir_all(&pkg_dir).unwrap();
5131        // exports points to a file that does not exist; main is valid
5132        std::fs::write(
5133            pkg_dir.join("package.json"),
5134            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
5135        )
5136        .unwrap();
5137        std::fs::write(
5138            pkg_dir.join("real.json"),
5139            r#"{"rules": {"unused-types": "off"}}"#,
5140        )
5141        .unwrap();
5142
5143        std::fs::write(
5144            dir.path().join(".fallowrc.json"),
5145            r#"{"extends": "npm:cfg-pkg"}"#,
5146        )
5147        .unwrap();
5148
5149        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5150        assert_eq!(config.rules.unused_types, Severity::Off);
5151    }
5152
5153    // ------------------------------------------------------------------
5154    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
5155    // ------------------------------------------------------------------
5156
5157    #[test]
5158    fn normalize_url_no_scheme_returns_raw() {
5159        // A string without "://" must come back unchanged
5160        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
5161        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
5162    }
5163
5164    // ------------------------------------------------------------------
5165    // normalize_url_for_dedup: query before fragment (fragment then query)
5166    // ------------------------------------------------------------------
5167
5168    #[test]
5169    fn normalize_url_fragment_only_stripped() {
5170        // Fragment-only URL (no query)
5171        assert_eq!(
5172            normalize_url_for_dedup("https://example.com/file.json#anchor"),
5173            "https://example.com/file.json"
5174        );
5175    }
5176
5177    // ------------------------------------------------------------------
5178    // url_timeout: env var override
5179    // ------------------------------------------------------------------
5180
5181    // These exercise the pure `url_timeout_from` parser rather than mutating the
5182    // process-global env var, so they stay deterministic under parallel test
5183    // execution (an env-mutating version raced and failed on Windows CI).
5184    #[test]
5185    fn url_timeout_uses_env_var_when_set() {
5186        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
5187    }
5188
5189    #[test]
5190    fn url_timeout_zero_falls_back_to_default() {
5191        assert_eq!(
5192            url_timeout_from(Some("0")),
5193            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5194            "zero should fall back to the hardcoded default"
5195        );
5196    }
5197
5198    #[test]
5199    fn url_timeout_non_numeric_falls_back_to_default() {
5200        assert_eq!(
5201            url_timeout_from(Some("not-a-number")),
5202            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5203            "non-numeric value should fall back to the hardcoded default"
5204        );
5205    }
5206
5207    #[test]
5208    fn url_timeout_absent_uses_default() {
5209        assert_eq!(
5210            url_timeout_from(None),
5211            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
5212        );
5213    }
5214
5215    // ------------------------------------------------------------------
5216    // resolve_url_extends: depth limit reached
5217    // ------------------------------------------------------------------
5218
5219    #[test]
5220    fn resolve_url_extends_depth_limit_error() {
5221        let mut visited = FxHashSet::default();
5222        let result = resolve_url_extends(
5223            "https://example.invalid/config.json",
5224            &mut visited,
5225            MAX_EXTENDS_DEPTH, // at the limit
5226        );
5227        assert!(result.is_err());
5228        let err = result.unwrap_err().to_string();
5229        assert!(
5230            err.contains("too deep"),
5231            "error should mention depth limit: {err}"
5232        );
5233    }
5234
5235    // ------------------------------------------------------------------
5236    // resolve_extends_file: depth limit reached
5237    // ------------------------------------------------------------------
5238
5239    #[test]
5240    #[cfg_attr(miri, ignore)]
5241    fn resolve_extends_file_depth_limit_error() {
5242        let dir = test_dir("extends-file-depth");
5243        let path = dir.path().join(".fallowrc.json");
5244        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
5245
5246        let mut visited = FxHashSet::default();
5247        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
5248        assert!(result.is_err());
5249        let err = result.unwrap_err().to_string();
5250        assert!(
5251            err.contains("too deep"),
5252            "error should mention depth limit: {err}"
5253        );
5254    }
5255
5256    // ------------------------------------------------------------------
5257    // resolve_extends_file_entry: http:// in file-sourced extends
5258    // ------------------------------------------------------------------
5259
5260    #[test]
5261    #[cfg_attr(miri, ignore)]
5262    fn extends_http_url_in_file_extends_rejected() {
5263        let dir = test_dir("file-extends-http");
5264        std::fs::write(
5265            dir.path().join(".fallowrc.json"),
5266            r#"{"extends": ["http://example.com/config.json"]}"#,
5267        )
5268        .unwrap();
5269
5270        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5271        assert!(result.is_err());
5272        let err = result.unwrap_err().to_string();
5273        assert!(
5274            err.contains("https://"),
5275            "error should suggest https: {err}"
5276        );
5277    }
5278
5279    // ------------------------------------------------------------------
5280    // sealed_config_dir: when sealed = true canonicalization runs
5281    // ------------------------------------------------------------------
5282
5283    #[test]
5284    #[cfg_attr(miri, ignore)]
5285    fn sealed_config_dir_returns_some_when_sealed() {
5286        let dir = test_dir("sealed-dir");
5287        let result = sealed_config_dir(dir.path(), true);
5288        assert!(result.is_ok());
5289        assert!(
5290            result.unwrap().is_some(),
5291            "sealed=true must return Some(canonicalized path)"
5292        );
5293    }
5294
5295    #[test]
5296    fn sealed_config_dir_returns_none_when_not_sealed() {
5297        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5298        assert!(result.is_ok());
5299        assert!(result.unwrap().is_none(), "sealed=false must return None");
5300    }
5301
5302    // ------------------------------------------------------------------
5303    // collect_unknown_rule_keys: overrides entry without a rules key
5304    // (the inner `if let Some(rules)` branch is not taken)
5305    // ------------------------------------------------------------------
5306
5307    #[test]
5308    fn collect_unknown_rule_keys_override_without_rules_key() {
5309        let merged = serde_json::json!({
5310            "overrides": [
5311                {
5312                    "files": ["src/**/*.ts"]
5313                    // no "rules" key here
5314                },
5315                {
5316                    "files": ["tests/**"],
5317                    "rules": {
5318                        "unsued-exports": "off"
5319                    }
5320                }
5321            ]
5322        });
5323        let findings = collect_unknown_rule_keys(&merged);
5324        assert_eq!(
5325            findings.len(),
5326            1,
5327            "only the entry with rules should produce a finding"
5328        );
5329        assert_eq!(findings[0].context, "overrides[1].rules");
5330    }
5331
5332    // ------------------------------------------------------------------
5333    // FallowConfig::load: deserialization failure
5334    // ------------------------------------------------------------------
5335
5336    #[test]
5337    #[cfg_attr(miri, ignore)]
5338    fn load_fails_on_deserialization_error() {
5339        let dir = test_dir("deser-error");
5340        let path = dir.path().join(".fallowrc.json");
5341        // Valid JSON but contains a field value with the wrong type for the schema
5342        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5343
5344        let result = FallowConfig::load(&path);
5345        assert!(result.is_err());
5346        let err = result.unwrap_err().to_string();
5347        assert!(
5348            err.contains("invalid type") && err.contains(&path.display().to_string()),
5349            "error should lead with the reason and name the config file: {err}"
5350        );
5351    }
5352
5353    #[test]
5354    #[cfg_attr(miri, ignore)]
5355    fn unknown_override_field_error_names_path_once_and_hints_at_jsonc() {
5356        let dir = test_dir("override-unknown-field");
5357        let path = dir.path().join(".fallowrc.json");
5358        std::fs::write(
5359            &path,
5360            r#"{"overrides": [{"files": ["src/**"], "reason": "legacy"}]}"#,
5361        )
5362        .unwrap();
5363
5364        let err = FallowConfig::find_and_load(dir.path())
5365            .expect_err("unknown override field must be rejected");
5366        let path_str = path.display().to_string();
5367        assert!(
5368            err.contains("unknown field `reason`"),
5369            "error should lead with the serde reason: {err}"
5370        );
5371        assert_eq!(
5372            err.matches(&path_str).count(),
5373            1,
5374            "path must appear exactly once: {err}"
5375        );
5376        assert!(
5377            err.contains("// comment") && err.contains("JSONC"),
5378            "error should point annotations at JSONC comments: {err}"
5379        );
5380    }
5381
5382    // ------------------------------------------------------------------
5383    // FallowConfig::load: threshold override validation failure
5384    // (covers lines 921-927)
5385    // ------------------------------------------------------------------
5386
5387    #[test]
5388    #[cfg_attr(miri, ignore)]
5389    fn load_rejects_threshold_override_with_empty_files() {
5390        let dir = test_dir("threshold-empty-files");
5391        let path = dir.path().join(".fallowrc.json");
5392        std::fs::write(
5393            &path,
5394            r#"{
5395                "health": {
5396                    "thresholdOverrides": [
5397                        {"files": [], "maxCyclomatic": 30}
5398                    ]
5399                }
5400            }"#,
5401        )
5402        .unwrap();
5403
5404        let result = FallowConfig::load(&path);
5405        assert!(result.is_err());
5406        let err = result.unwrap_err().to_string();
5407        assert!(
5408            err.contains("thresholdOverrides"),
5409            "error should mention thresholdOverrides: {err}"
5410        );
5411        assert!(
5412            err.contains("files"),
5413            "error should name the files field: {err}"
5414        );
5415    }
5416
5417    #[test]
5418    #[cfg_attr(miri, ignore)]
5419    fn load_rejects_threshold_override_with_no_threshold_set() {
5420        let dir = test_dir("threshold-no-threshold");
5421        let path = dir.path().join(".fallowrc.json");
5422        std::fs::write(
5423            &path,
5424            r#"{
5425                "health": {
5426                    "thresholdOverrides": [
5427                        {"files": ["src/legacy.ts"]}
5428                    ]
5429                }
5430            }"#,
5431        )
5432        .unwrap();
5433
5434        let result = FallowConfig::load(&path);
5435        assert!(result.is_err());
5436        let err = result.unwrap_err().to_string();
5437        assert!(
5438            err.contains("maxCyclomatic")
5439                || err.contains("maxCognitive")
5440                || err.contains("maxCrap"),
5441            "error should name at least one threshold field: {err}"
5442        );
5443    }
5444
5445    // ------------------------------------------------------------------
5446    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
5447    // validation (covers lines 1026-1032)
5448    // ------------------------------------------------------------------
5449
5450    #[test]
5451    #[cfg_attr(miri, ignore)]
5452    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5453        let dir = test_dir("invalid-catalog-consumer-glob");
5454        let path = dir.path().join(".fallowrc.json");
5455        std::fs::write(
5456            &path,
5457            r#"{
5458                "ignoreCatalogReferences": [
5459                    {"package": "react", "consumer": "[invalid-glob"}
5460                ]
5461            }"#,
5462        )
5463        .unwrap();
5464
5465        let result = FallowConfig::load(&path);
5466        assert!(result.is_err());
5467        let err = result.unwrap_err().to_string();
5468        assert!(
5469            err.contains("ignoreCatalogReferences"),
5470            "error should mention the field: {err}"
5471        );
5472    }
5473
5474    #[test]
5475    #[cfg_attr(miri, ignore)]
5476    fn load_accepts_ignore_catalog_references_without_consumer() {
5477        let dir = test_dir("catalog-ref-no-consumer");
5478        let path = dir.path().join(".fallowrc.json");
5479        std::fs::write(
5480            &path,
5481            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5482        )
5483        .unwrap();
5484
5485        let config = FallowConfig::load(&path).unwrap();
5486        assert_eq!(config.ignore_catalog_references.len(), 1);
5487        assert!(config.ignore_catalog_references[0].consumer.is_none());
5488    }
5489
5490    #[test]
5491    #[cfg_attr(miri, ignore)]
5492    fn load_accepts_unused_component_props_ignore_pattern() {
5493        let dir = test_dir("unused-component-props-ignore-pattern");
5494        let path = dir.path().join(".fallowrc.json");
5495        std::fs::write(
5496            &path,
5497            r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5498        )
5499        .unwrap();
5500
5501        let config = FallowConfig::load(&path).unwrap();
5502        assert_eq!(
5503            config.unused_component_props.ignore_pattern.as_deref(),
5504            Some("^_")
5505        );
5506    }
5507
5508    #[test]
5509    #[cfg_attr(miri, ignore)]
5510    fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5511        let dir = test_dir("unused-component-props-bad-regex");
5512        let path = dir.path().join(".fallowrc.json");
5513        // `[` opens an unterminated character class: invalid regex.
5514        std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5515
5516        let result = FallowConfig::load(&path);
5517        assert!(result.is_err());
5518        let err = result.unwrap_err().to_string();
5519        assert!(
5520            err.contains("unusedComponentProps.ignorePattern"),
5521            "error should mention the field: {err}"
5522        );
5523    }
5524
5525    #[test]
5526    #[cfg_attr(miri, ignore)]
5527    fn load_rejects_unknown_unused_component_props_field() {
5528        let dir = test_dir("unused-component-props-unknown-field");
5529        let path = dir.path().join(".fallowrc.json");
5530        std::fs::write(
5531            &path,
5532            r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5533        )
5534        .unwrap();
5535
5536        // `deny_unknown_fields` rejects the plural typo.
5537        assert!(FallowConfig::load(&path).is_err());
5538    }
5539
5540    #[test]
5541    #[cfg_attr(miri, ignore)]
5542    fn load_rejects_unknown_override_entry_key() {
5543        let dir = test_dir("override-entry-unknown-key");
5544        let path = dir.path().join(".fallowrc.json");
5545        std::fs::write(
5546            &path,
5547            r#"{"overrides": [{"files": ["src/**"], "rule": {"unused-files": "off"}}]}"#,
5548        )
5549        .unwrap();
5550
5551        // `deny_unknown_fields` rejects the `rule` typo instead of silently
5552        // producing an override with empty rules.
5553        assert!(FallowConfig::load(&path).is_err());
5554    }
5555
5556    #[test]
5557    #[cfg_attr(miri, ignore)]
5558    fn load_rejects_unknown_ignore_exports_entry_key() {
5559        let dir = test_dir("ignore-exports-entry-unknown-key");
5560        let path = dir.path().join(".fallowrc.json");
5561        std::fs::write(
5562            &path,
5563            r#"{"ignoreExports": [{"file": "src/a.ts", "exports": ["*"], "reason": "legacy"}]}"#,
5564        )
5565        .unwrap();
5566
5567        assert!(FallowConfig::load(&path).is_err());
5568    }
5569
5570    // ------------------------------------------------------------------
5571    // validate_resolved_boundaries: tsconfig rootDir filtering
5572    // (covers lines 1158-1160 - rootDir value is ".", starts with "..", or
5573    // is absolute; all should fall back to "src")
5574    // ------------------------------------------------------------------
5575
5576    #[test]
5577    #[cfg_attr(miri, ignore)]
5578    fn validate_resolved_boundaries_with_preset_uses_src_fallback_when_no_tsconfig() {
5579        // No tsconfig.json present; parse_tsconfig_root_dir returns None,
5580        // unwrap_or_else supplies "src". This exercises the filter + fallback branch.
5581        let dir = test_dir("boundaries-preset-no-tsconfig");
5582        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5583        let config = FallowConfig {
5584            boundaries: crate::BoundaryConfig {
5585                coverage: crate::BoundaryCoverageConfig::default(),
5586                calls: crate::BoundaryCallsConfig::default(),
5587                preset: Some(crate::BoundaryPreset::Bulletproof),
5588                zones: vec![],
5589                rules: vec![],
5590            },
5591            ..FallowConfig::default()
5592        };
5593        // Should not panic; no zone-ref errors expected since preset adds zones
5594        let _ = config.validate_resolved_boundaries(dir.path());
5595    }
5596
5597    // ------------------------------------------------------------------
5598    // validate_user_globs: framework plugin invalid glob triggers error path
5599    // (covers lines 970-974)
5600    // ------------------------------------------------------------------
5601
5602    #[test]
5603    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5604        use crate::ExternalPluginDef;
5605        use crate::external_plugin::EntryPointRole;
5606        let config = FallowConfig {
5607            framework: vec![ExternalPluginDef {
5608                schema: None,
5609                name: "test-plugin".to_owned(),
5610                detection: None,
5611                enablers: vec![],
5612                entry_points: vec!["[invalid-glob".to_owned()],
5613                entry_point_role: EntryPointRole::Support,
5614                manifest_entries: vec![],
5615                config_patterns: vec![],
5616                always_used: vec![],
5617                tooling_dependencies: vec![],
5618                used_exports: vec![],
5619                used_class_members: vec![],
5620            }],
5621            ..FallowConfig::default()
5622        };
5623
5624        let result = config.validate_user_globs();
5625        assert!(
5626            result.is_err(),
5627            "invalid entry_points glob should fail validation"
5628        );
5629        let errors = result.unwrap_err();
5630        assert!(!errors.is_empty());
5631    }
5632
5633    // ------------------------------------------------------------------
5634    // shadowed_config_names: no lower-precedence names after the last index
5635    // ------------------------------------------------------------------
5636
5637    #[test]
5638    #[cfg_attr(miri, ignore)]
5639    fn shadowed_config_names_empty_when_last_config_wins() {
5640        let dir = test_dir("shadow-last");
5641        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5642        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
5643        assert!(shadowed_config_names(dir.path(), 3).is_empty());
5644    }
5645
5646    // ------------------------------------------------------------------
5647    // warn_on_coexisting_configs: path without filename (edge branch)
5648    // shadowed is empty -> early return without recording
5649    // ------------------------------------------------------------------
5650
5651    #[test]
5652    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5653        let ((), captured) = capture_coexisting_config_warnings(|| {
5654            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5655        });
5656        assert!(
5657            captured.is_empty(),
5658            "empty shadowed list must produce no warning"
5659        );
5660    }
5661
5662    // ------------------------------------------------------------------
5663    // extract_extends: malformed values fail loud instead of silently
5664    // skipping the base config (the key is removed before deserialization,
5665    // so deny_unknown_fields cannot catch these)
5666    // ------------------------------------------------------------------
5667
5668    #[test]
5669    fn extract_extends_rejects_number_value() {
5670        let mut value = serde_json::json!({"extends": 42});
5671        let err = extract_extends(&mut value, "test.json").unwrap_err();
5672        assert!(
5673            err.to_string()
5674                .contains("extends must be a string or an array of strings, got a number"),
5675            "unexpected error: {err}"
5676        );
5677    }
5678
5679    #[test]
5680    fn extract_extends_rejects_object_value() {
5681        let mut value = serde_json::json!({"extends": {"path": "./base.json"}});
5682        let err = extract_extends(&mut value, "test.json").unwrap_err();
5683        assert!(
5684            err.to_string()
5685                .contains("extends must be a string or an array of strings, got an object"),
5686            "unexpected error: {err}"
5687        );
5688    }
5689
5690    #[test]
5691    fn extract_extends_rejects_non_string_array_entry() {
5692        let mut value = serde_json::json!({"extends": ["a.json", 42]});
5693        let err = extract_extends(&mut value, "test.json").unwrap_err();
5694        assert!(
5695            err.to_string()
5696                .contains("extends entries must be strings, got a number"),
5697            "unexpected error: {err}"
5698        );
5699    }
5700
5701    #[test]
5702    #[cfg_attr(miri, ignore)]
5703    fn load_rejects_malformed_extends_value() {
5704        let dir = test_dir("malformed-extends");
5705        let path = dir.path().join(".fallowrc.json");
5706        std::fs::write(&path, r#"{"extends": 42}"#).unwrap();
5707
5708        let err = FallowConfig::load(&path).unwrap_err();
5709        assert!(
5710            err.to_string()
5711                .contains("extends must be a string or an array of strings"),
5712            "unexpected error: {err}"
5713        );
5714    }
5715
5716    // ------------------------------------------------------------------
5717    // Typed resource identities keep local and remote namespaces disjoint.
5718    // ------------------------------------------------------------------
5719
5720    #[test]
5721    #[cfg_attr(miri, ignore)]
5722    fn config_resource_identity_cannot_collide_across_kinds() {
5723        let dir = test_dir("visit-circular");
5724        let path = dir.path().join("config.json");
5725        std::fs::write(&path, "{}").unwrap();
5726
5727        let canonical = dunce::canonicalize(path).unwrap();
5728        let local = ConfigResourceId::Local(canonical.clone());
5729        let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
5730        assert_ne!(local, remote);
5731    }
5732
5733    // ------------------------------------------------------------------
5734    // find_and_load: stops at .svn dir (is_repo_root branch)
5735    // ------------------------------------------------------------------
5736
5737    #[test]
5738    #[cfg_attr(miri, ignore)]
5739    fn find_config_path_stops_at_svn_dir() {
5740        let dir = test_dir("find-path-svn");
5741        let sub = dir.path().join("sub");
5742        std::fs::create_dir(&sub).unwrap();
5743        std::fs::create_dir(dir.path().join(".svn")).unwrap();
5744
5745        let path = FallowConfig::find_config_path(&sub);
5746        assert!(path.is_none(), "svn root should stop config search");
5747    }
5748
5749    // ------------------------------------------------------------------
5750    // deep_merge: array over object replaces
5751    // ------------------------------------------------------------------
5752
5753    #[test]
5754    fn deep_merge_array_over_object_replaces() {
5755        let mut base = serde_json::json!({"key": "value"});
5756        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
5757        assert_eq!(base, serde_json::json!(["a", "b"]));
5758    }
5759
5760    // ------------------------------------------------------------------
5761    // find_and_load: returns an error when config parses but glob validation fails
5762    // ------------------------------------------------------------------
5763
5764    #[test]
5765    #[cfg_attr(miri, ignore)]
5766    fn find_and_load_returns_error_for_invalid_glob_in_config() {
5767        let dir = test_dir("find-invalid-glob");
5768        std::fs::create_dir(dir.path().join(".git")).unwrap();
5769        std::fs::write(
5770            dir.path().join(".fallowrc.json"),
5771            r#"{"entry": ["[invalid-glob"]}"#,
5772        )
5773        .unwrap();
5774
5775        let result = FallowConfig::find_and_load(dir.path());
5776        assert!(
5777            result.is_err(),
5778            "invalid glob should surface as an error from find_and_load"
5779        );
5780    }
5781
5782    #[test]
5783    #[cfg_attr(miri, ignore)]
5784    fn find_and_load_rejects_bare_finding_ignore_negation() {
5785        let dir = test_dir("find-bare-finding-ignore-negation");
5786        std::fs::create_dir(dir.path().join(".git")).unwrap();
5787        std::fs::write(
5788            dir.path().join(".fallowrc.json"),
5789            r#"{"ignoreFindings": ["!"]}"#,
5790        )
5791        .unwrap();
5792
5793        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5794        assert!(error.contains("ignoreFindings"), "error: {error}");
5795        assert!(error.contains("requires a pattern"), "error: {error}");
5796    }
5797
5798    #[test]
5799    #[cfg_attr(miri, ignore)]
5800    fn find_and_load_rejects_finding_ignore_set_that_exceeds_matcher_limits() {
5801        let dir = test_dir("find-oversized-finding-ignore-set");
5802        std::fs::create_dir(dir.path().join(".git")).unwrap();
5803        let alternatives = (0..50_000)
5804            .map(|index| format!("name{index}"))
5805            .collect::<Vec<_>>()
5806            .join(",");
5807        let config = serde_json::json!({
5808            "ignoreFindings": [format!("**/{{{alternatives}}}.ts")]
5809        });
5810        std::fs::write(
5811            dir.path().join(".fallowrc.json"),
5812            serde_json::to_vec(&config).unwrap(),
5813        )
5814        .unwrap();
5815
5816        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
5817        assert!(error.contains("ignoreFindings"), "error: {error}");
5818        assert!(
5819            error.contains("cannot be compiled together"),
5820            "error: {error}"
5821        );
5822    }
5823
5824    // ------------------------------------------------------------------
5825    // resolve_package_exports: Object map with "." key that is not a
5826    // string or object returns None (the `_ => None` arm)
5827    // ------------------------------------------------------------------
5828
5829    #[test]
5830    fn resolve_package_exports_dot_key_array_returns_none() {
5831        // "." value is an array, which is neither String nor Object
5832        let pkg = serde_json::json!({
5833            "exports": {".": ["array-value"]}
5834        });
5835        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5836        assert!(result.is_none(), "array dot-export should return None");
5837    }
5838
5839    #[test]
5840    fn resolve_package_exports_exports_is_array_returns_none() {
5841        // top-level "exports" is an array (not String or Object)
5842        let pkg = serde_json::json!({
5843            "exports": ["./index.js"]
5844        });
5845        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5846        assert!(result.is_none(), "array-form exports should return None");
5847    }
5848
5849    #[test]
5850    fn resolve_package_exports_object_no_dot_key_returns_none() {
5851        // Object exports without "." key
5852        let pkg = serde_json::json!({
5853            "exports": {"./sub": "./sub.js"}
5854        });
5855        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5856        assert!(result.is_none(), "no dot key should return None");
5857    }
5858
5859    #[test]
5860    fn resolve_package_exports_conditions_without_known_key_returns_none() {
5861        // "." is an Object but none of the known condition keys are present
5862        let pkg = serde_json::json!({
5863            "exports": {".": {"browser": "./browser.js"}}
5864        });
5865        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
5866        assert!(result.is_none(), "unknown condition key should return None");
5867    }
5868
5869    // ------------------------------------------------------------------
5870    // npm package: exports condition "import" key (one of the priority keys)
5871    // ------------------------------------------------------------------
5872
5873    #[test]
5874    #[cfg_attr(miri, ignore)]
5875    fn extends_npm_exports_import_condition() {
5876        let dir = test_dir("npm-import-cond");
5877        let pkg_dir = dir.path().join("node_modules/import-config");
5878        std::fs::create_dir_all(&pkg_dir).unwrap();
5879        std::fs::write(
5880            pkg_dir.join("package.json"),
5881            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
5882        )
5883        .unwrap();
5884        std::fs::write(
5885            pkg_dir.join("esm.json"),
5886            r#"{"rules": {"unused-types": "warn"}}"#,
5887        )
5888        .unwrap();
5889
5890        std::fs::write(
5891            dir.path().join(".fallowrc.json"),
5892            r#"{"extends": "npm:import-config"}"#,
5893        )
5894        .unwrap();
5895
5896        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5897        assert_eq!(config.rules.unused_types, Severity::Warn);
5898    }
5899
5900    // ------------------------------------------------------------------
5901    // npm package: exports condition "require" key
5902    // ------------------------------------------------------------------
5903
5904    #[test]
5905    #[cfg_attr(miri, ignore)]
5906    fn extends_npm_exports_require_condition() {
5907        let dir = test_dir("npm-require-cond");
5908        let pkg_dir = dir.path().join("node_modules/require-config");
5909        std::fs::create_dir_all(&pkg_dir).unwrap();
5910        std::fs::write(
5911            pkg_dir.join("package.json"),
5912            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
5913        )
5914        .unwrap();
5915        std::fs::write(
5916            pkg_dir.join("cjs.json"),
5917            r#"{"rules": {"unused-class-members": "warn"}}"#,
5918        )
5919        .unwrap();
5920
5921        std::fs::write(
5922            dir.path().join(".fallowrc.json"),
5923            r#"{"extends": "npm:require-config"}"#,
5924        )
5925        .unwrap();
5926
5927        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5928        assert_eq!(config.rules.unused_class_members, Severity::Warn);
5929    }
5930}