Skip to main content

fallow_config/config/
parsing.rs

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