Skip to main content

fallow_config/config/
parsing.rs

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