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