Skip to main content

fallow_config/config/
parsing.rs

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