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