Skip to main content

fallow_config/config/
parsing.rs

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