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 mut health_errors = config.health.threshold_override_errors();
1393        health_errors.extend(config.health.change_coupling.errors());
1394        if !health_errors.is_empty() {
1395            return Err(miette::miette!(
1396                "invalid config:\n  - {}",
1397                health_errors.join("\n  - ")
1398            ));
1399        }
1400        if let Some(pattern) = &config.unused_component_props.ignore_pattern
1401            && let Err(e) = regex::Regex::new(pattern)
1402        {
1403            return Err(miette::miette!(
1404                "invalid config:\n  - unusedComponentProps.ignorePattern is not a valid regex: {e}"
1405            ));
1406        }
1407
1408        Ok(config)
1409    }
1410
1411    /// Validate all user-supplied glob patterns and directory paths in this config.
1412    ///
1413    /// Accumulates errors from every glob- or path-bearing field so the user
1414    /// sees ALL offending values in one run rather than fixing them one at a
1415    /// time.
1416    ///
1417    /// Covered filesystem glob fields: `entry`, `ignorePatterns`, `ignoreFindings`,
1418    /// `dynamicallyLoaded`, `duplicates.ignore`, `similarCode.ignore`, `health.ignore`,
1419    /// `health.thresholdOverrides[].files`, `overrides[].files`, `ignoreExports[].file`,
1420    /// `ignoreCatalogReferences[].consumer`, `boundaries.zones[].patterns`,
1421    /// `boundaries.coverage.allowUnmatched`,
1422    /// plus every glob-bearing field on inline `framework[]` plugin
1423    /// definitions (entry points, always-used, config patterns, used-exports
1424    /// patterns, and `fileExists` detection patterns; the last reaches
1425    /// `glob::glob` on disk so a `..` segment there is a real path traversal).
1426    ///
1427    /// Covered specifier glob fields: `ignoreUnresolvedImports`. These match
1428    /// raw import strings, so parent-relative specifiers like `../generated/**`
1429    /// are valid and only glob syntax is checked.
1430    ///
1431    /// Covered directory-path fields: `boundaries.zones[].root` and
1432    /// `boundaries.zones[].autoDiscover`. These are literal paths (not
1433    /// globs), so only the absolute-path + traversal checks apply.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns a non-empty `Vec` of
1438    /// [`glob_validation::GlobValidationError`](super::glob_validation::GlobValidationError)
1439    /// when any field contains a rejected value.
1440    pub fn validate_user_globs(
1441        &self,
1442    ) -> Result<(), Vec<super::glob_validation::GlobValidationError>> {
1443        let mut errors = Vec::new();
1444
1445        self.validate_top_level_globs(&mut errors);
1446        self.validate_ignore_rule_globs(&mut errors);
1447        self.validate_boundary_globs(&mut errors);
1448        self.validate_workspace_baseline_keys(&mut errors);
1449
1450        for plugin in &self.framework {
1451            if let Err(mut plugin_errors) = plugin.validate_user_globs() {
1452                errors.append(&mut plugin_errors);
1453            }
1454        }
1455
1456        if errors.is_empty() {
1457            Ok(())
1458        } else {
1459            Err(errors)
1460        }
1461    }
1462
1463    /// Validate the top-level filesystem and specifier glob fields plus the
1464    /// per-override and threshold-override file globs.
1465    fn validate_top_level_globs(
1466        &self,
1467        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1468    ) {
1469        use super::glob_validation::{
1470            validate_user_dependency_globs, validate_user_finding_ignore_globs,
1471            validate_user_globs, validate_user_ignore_pattern_globs, validate_user_specifier_globs,
1472        };
1473
1474        validate_user_globs(&self.entry, "entry", errors);
1475        validate_user_ignore_pattern_globs(&self.ignore_patterns, "ignorePatterns", errors);
1476        validate_user_finding_ignore_globs(&self.ignore_findings, "ignoreFindings", errors);
1477        validate_user_globs(&self.dynamically_loaded, "dynamicallyLoaded", errors);
1478        validate_user_dependency_globs(&self.ignore_dependencies, "ignoreDependencies", errors);
1479        validate_user_specifier_globs(
1480            &self.ignore_unresolved_imports,
1481            "ignoreUnresolvedImports",
1482            errors,
1483        );
1484        validate_user_globs(&self.duplicates.ignore, "duplicates.ignore", errors);
1485        validate_user_globs(&self.similar_code.ignore, "similarCode.ignore", errors);
1486        validate_user_globs(&self.health.ignore, "health.ignore", errors);
1487        for override_entry in &self.health.threshold_overrides {
1488            validate_user_globs(
1489                &override_entry.files,
1490                "health.thresholdOverrides[].files",
1491                errors,
1492            );
1493        }
1494        for override_entry in &self.overrides {
1495            validate_user_globs(&override_entry.files, "overrides[].files", errors);
1496        }
1497    }
1498
1499    /// Validate the `ignoreExports` and `ignoreCatalogReferences` rule globs.
1500    fn validate_ignore_rule_globs(
1501        &self,
1502        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1503    ) {
1504        use super::glob_validation::compile_user_glob;
1505
1506        for rule in &self.ignore_exports {
1507            if let Err(e) = compile_user_glob(&rule.file, "ignoreExports[].file") {
1508                errors.push(e);
1509            }
1510        }
1511
1512        for rule in &self.ignore_catalog_references {
1513            if let Some(consumer) = &rule.consumer
1514                && let Err(e) = compile_user_glob(consumer, "ignoreCatalogReferences[].consumer")
1515            {
1516                errors.push(e);
1517            }
1518        }
1519    }
1520
1521    /// Validate the `boundaries.zones[]` patterns/roots/autoDiscover and the
1522    /// coverage `allowUnmatched` globs.
1523    fn validate_boundary_globs(
1524        &self,
1525        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1526    ) {
1527        use super::glob_validation::{
1528            validate_user_globs, validate_user_path, validate_user_paths,
1529        };
1530
1531        for zone in &self.boundaries.zones {
1532            validate_user_globs(&zone.patterns, "boundaries.zones[].patterns", errors);
1533            if let Some(root) = &zone.root
1534                && let Err(e) = validate_user_path(root, "boundaries.zones[].root")
1535            {
1536                errors.push(e);
1537            }
1538            validate_user_paths(
1539                &zone.auto_discover,
1540                "boundaries.zones[].autoDiscover",
1541                errors,
1542            );
1543        }
1544        validate_user_globs(
1545            &self.boundaries.coverage.allow_unmatched,
1546            "boundaries.coverage.allowUnmatched",
1547            errors,
1548        );
1549    }
1550
1551    /// Validate the shape of the `workspaces.changedSince` keys. Whether a
1552    /// key names a discovered workspace is known only after discovery, so the
1553    /// engine checks that when a run reads the map.
1554    fn validate_workspace_baseline_keys(
1555        &self,
1556        errors: &mut Vec<super::glob_validation::GlobValidationError>,
1557    ) {
1558        let Some(workspaces) = &self.workspaces else {
1559            return;
1560        };
1561        for key in workspaces.changed_since.keys() {
1562            if let Err(e) =
1563                super::glob_validation::validate_workspace_root_key(key, "workspaces.changedSince")
1564            {
1565                errors.push(e);
1566            }
1567        }
1568    }
1569
1570    /// Find the config file path without loading it.
1571    /// Searches the same locations as `find_and_load`.
1572    #[must_use]
1573    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
1574        let mut dir = start;
1575        loop {
1576            for name in CONFIG_NAMES {
1577                let candidate = dir.join(name);
1578                if candidate.exists() {
1579                    return Some(candidate);
1580                }
1581            }
1582            if is_repo_root(dir) {
1583                break;
1584            }
1585            dir = dir.parent()?;
1586        }
1587        None
1588    }
1589
1590    /// Find and load config, searching from `start` up to the project root.
1591    ///
1592    /// # Errors
1593    ///
1594    /// Returns an error if a config file is found but cannot be read or parsed.
1595    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
1596        Self::find_and_load_with_options(start, ConfigLoadOptions::default())
1597    }
1598
1599    /// Find and load config with a host-controlled inheritance trust policy.
1600    ///
1601    /// # Errors
1602    ///
1603    /// Returns an error if a config file is found but cannot be read, parsed,
1604    /// or is not permitted by `options`.
1605    pub fn find_and_load_with_options(
1606        start: &Path,
1607        options: ConfigLoadOptions,
1608    ) -> Result<Option<(Self, PathBuf)>, String> {
1609        let mut dir = start;
1610        loop {
1611            for (idx, name) in CONFIG_NAMES.iter().enumerate() {
1612                let candidate = dir.join(name);
1613                if candidate.exists() {
1614                    warn_on_coexisting_configs(&candidate, &shadowed_config_names(dir, idx));
1615                    match Self::load_with_options(&candidate, options) {
1616                        Ok(config) => return Ok(Some((config, candidate))),
1617                        Err(e) => {
1618                            // Most load errors already name the config file;
1619                            // add the path only when the reason lacks it, so
1620                            // it is mentioned exactly once.
1621                            let msg = e.to_string();
1622                            return Err(if msg.contains(&candidate.display().to_string()) {
1623                                msg
1624                            } else {
1625                                format!("Failed to parse {}: {msg}", candidate.display())
1626                            });
1627                        }
1628                    }
1629                }
1630            }
1631            if is_repo_root(dir) {
1632                break;
1633            }
1634            dir = match dir.parent() {
1635                Some(parent) => parent,
1636                None => break,
1637            };
1638        }
1639        Ok(None)
1640    }
1641
1642    /// Generate JSON Schema for the configuration format.
1643    #[must_use]
1644    pub fn json_schema() -> serde_json::Value {
1645        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
1646    }
1647
1648    /// Validate boundary zone references and zone-root-prefix conflicts AFTER
1649    /// preset and auto-discover expansion.
1650    ///
1651    /// Runs the same expand sequence as [`FallowConfig::resolve`] (preset
1652    /// expansion gated on tsconfig `rootDir`, then `expand_auto_discover`)
1653    /// before invoking
1654    /// [`BoundaryConfig::validate_zone_references`](super::boundaries::BoundaryConfig::validate_zone_references)
1655    /// and
1656    /// [`BoundaryConfig::validate_root_prefixes`](super::boundaries::BoundaryConfig::validate_root_prefixes),
1657    /// so Bulletproof-style presets whose authored rule references logical
1658    /// groups (`features`) still load cleanly.
1659    ///
1660    /// Call sites (`runtime_support::load_config_for_analysis` in the CLI,
1661    /// `core::lib::config_for_project` for LSP and programmatic embedders)
1662    /// surface every collected error in a single rendered diagnostic, then
1663    /// exit with code 2. Previously these failures emitted `tracing::error!`
1664    /// and continued, producing a flood of false-positive boundary violations
1665    /// at analysis time (#468).
1666    ///
1667    /// `root` is the project root used by `expand_auto_discover` to scan for
1668    /// child directories. Caller is responsible for passing the same root it
1669    /// later hands to `resolve()`.
1670    ///
1671    /// # Errors
1672    ///
1673    /// Returns a non-empty `Vec<ZoneValidationError>` aggregating every
1674    /// offending zone reference and redundant-root-prefix pattern; the empty
1675    /// case becomes `Ok(())`.
1676    pub fn validate_resolved_boundaries(
1677        &self,
1678        root: &Path,
1679    ) -> Result<(), Vec<super::boundaries::ZoneValidationError>> {
1680        use super::boundaries::ZoneValidationError;
1681
1682        let mut boundaries = self.boundaries.clone();
1683        if boundaries.preset.is_some() {
1684            let source_root = crate::workspace::parse_tsconfig_root_dir(root)
1685                .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
1686                .unwrap_or_else(|| "src".to_owned());
1687            boundaries.expand(&source_root);
1688        }
1689        let _logical_groups = boundaries.expand_auto_discover(root);
1690
1691        let mut errors: Vec<ZoneValidationError> = boundaries
1692            .validate_zone_references()
1693            .into_iter()
1694            .map(ZoneValidationError::UnknownZoneReference)
1695            .collect();
1696        errors.extend(
1697            boundaries
1698                .validate_root_prefixes()
1699                .into_iter()
1700                .map(ZoneValidationError::RedundantRootPrefix),
1701        );
1702        errors.extend(
1703            boundaries
1704                .validate_call_rules()
1705                .into_iter()
1706                .map(ZoneValidationError::InvalidForbiddenCallee),
1707        );
1708
1709        if errors.is_empty() {
1710            Ok(())
1711        } else {
1712            Err(errors)
1713        }
1714    }
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719
1720    #[test]
1721    fn version_triple_accepts_exactly_three_numbers() {
1722        assert_eq!(version_triple("3.21.0"), Some((3, 21, 0)));
1723        assert_eq!(version_triple("10.0.100"), Some((10, 0, 100)));
1724        for rejected in ["3.21", "3.21.0.1", "v3.21.0", "3.21.x", "", "3..0"] {
1725            assert_eq!(version_triple(rejected), None, "{rejected}");
1726        }
1727    }
1728
1729    #[test]
1730    fn minimum_version_at_or_below_the_binary_loads() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let path = dir.path().join(".fallowrc.json");
1733        let running = env!("CARGO_PKG_VERSION");
1734        std::fs::write(
1735            &path,
1736            format!(r#"{{"minimumVersion": "{running}", "entry": ["src/main.ts"]}}"#),
1737        )
1738        .unwrap();
1739
1740        let config = FallowConfig::load(&path).expect("current version satisfies its own floor");
1741        assert_eq!(config.minimum_version.as_deref(), Some(running));
1742        assert_eq!(config.entry, vec!["src/main.ts".to_string()]);
1743    }
1744
1745    #[test]
1746    fn a_config_written_for_a_newer_binary_names_both_versions() {
1747        let dir = tempfile::tempdir().unwrap();
1748        let path = dir.path().join(".fallowrc.json");
1749        std::fs::write(
1750            &path,
1751            r#"{"minimumVersion": "999.0.0", "someFieldFromTheFuture": true}"#,
1752        )
1753        .unwrap();
1754
1755        let error = FallowConfig::load(&path).expect_err("a newer floor must stop the run");
1756        let message = error.to_string();
1757        assert!(
1758            message.contains("requires fallow 999.0.0 or newer"),
1759            "{message}"
1760        );
1761        assert!(message.contains(env!("CARGO_PKG_VERSION")), "{message}");
1762        // The version skew is the cause, so the unknown field must not be the
1763        // headline: that reads like a typo and sends the reader to the wrong fix.
1764        assert!(!message.contains("unknown field"), "{message}");
1765    }
1766
1767    #[test]
1768    fn an_unknown_field_still_fails_on_a_binary_that_meets_the_floor() {
1769        let dir = tempfile::tempdir().unwrap();
1770        let path = dir.path().join(".fallowrc.json");
1771        std::fs::write(
1772            &path,
1773            r#"{"minimumVersion": "0.1.0", "ignorePaths": ["dist/**"]}"#,
1774        )
1775        .unwrap();
1776
1777        let error = FallowConfig::load(&path).expect_err("a typo is still a typo");
1778        assert!(error.to_string().contains("unknown field"), "{error}");
1779    }
1780
1781    #[test]
1782    fn a_floor_declared_by_an_extended_base_config_still_applies() {
1783        let dir = tempfile::tempdir().unwrap();
1784        std::fs::write(
1785            dir.path().join("base.json"),
1786            r#"{"minimumVersion": "999.0.0"}"#,
1787        )
1788        .unwrap();
1789        let path = dir.path().join(".fallowrc.json");
1790        std::fs::write(&path, r#"{"extends": ["./base.json"]}"#).unwrap();
1791
1792        let error = FallowConfig::load(&path).expect_err("an inherited floor must stop the run");
1793        assert!(
1794            error
1795                .to_string()
1796                .contains("requires fallow 999.0.0 or newer"),
1797            "{error}"
1798        );
1799    }
1800
1801    #[test]
1802    fn a_malformed_minimum_version_is_a_config_error() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let path = dir.path().join(".fallowrc.json");
1805        std::fs::write(&path, r#"{"minimumVersion": "v3.21"}"#).unwrap();
1806
1807        let error = FallowConfig::load(&path).expect_err("a malformed floor must not be ignored");
1808        assert!(
1809            error
1810                .to_string()
1811                .contains("minimumVersion must be MAJOR.MINOR.PATCH"),
1812            "{error}"
1813        );
1814    }
1815    use super::*;
1816    use crate::CacheConfig;
1817    use crate::PackageJson;
1818    use crate::config::format::OutputFormat;
1819    use crate::config::rules::Severity;
1820
1821    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
1822    fn test_dir(_name: &str) -> tempfile::TempDir {
1823        tempfile::tempdir().expect("create temp dir")
1824    }
1825
1826    #[derive(Default)]
1827    struct MockRemoteFetcher {
1828        responses: rustc_hash::FxHashMap<String, serde_json::Value>,
1829        requests: Vec<String>,
1830    }
1831
1832    impl MockRemoteFetcher {
1833        fn with_response(mut self, url: &str, value: serde_json::Value) -> Self {
1834            self.responses.insert(url.to_string(), value);
1835            self
1836        }
1837    }
1838
1839    impl RemoteConfigFetcher for MockRemoteFetcher {
1840        fn fetch(&mut self, url: &str, _source: &str) -> Result<serde_json::Value, miette::Report> {
1841            self.requests.push(url.to_string());
1842            self.responses
1843                .get(url)
1844                .cloned()
1845                .ok_or_else(|| miette::miette!("missing mock response for {url}"))
1846        }
1847    }
1848
1849    #[test]
1850    fn fallow_config_deserialize_minimal() {
1851        let toml_str = r#"
1852entry = ["src/main.ts"]
1853"#;
1854        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1855        assert_eq!(config.entry, vec!["src/main.ts"]);
1856        assert!(config.ignore_patterns.is_empty());
1857    }
1858
1859    #[test]
1860    fn fallow_config_deserialize_ignore_exports() {
1861        let toml_str = r#"
1862[[ignoreExports]]
1863file = "src/types/*.ts"
1864exports = ["*"]
1865
1866[[ignoreExports]]
1867file = "src/constants.ts"
1868exports = ["FOO", "BAR"]
1869"#;
1870        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1871        assert_eq!(config.ignore_exports.len(), 2);
1872        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
1873        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1874        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
1875    }
1876
1877    #[test]
1878    fn fallow_config_deserialize_ignore_dependencies() {
1879        let toml_str = r#"
1880ignoreDependencies = ["autoprefixer", "postcss"]
1881"#;
1882        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1883        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1884    }
1885
1886    #[test]
1887    fn fallow_config_deserialize_ignore_command_entries() {
1888        let toml_config: FallowConfig =
1889            toml::from_str(r#"ignoreCommandEntries = ["my-codegen", "*"]"#).unwrap();
1890        assert_eq!(toml_config.ignore_command_entries, vec!["my-codegen", "*"]);
1891
1892        let json_config: FallowConfig =
1893            serde_json::from_str(r#"{"ignoreCommandEntries": ["my-codegen"]}"#).unwrap();
1894        assert_eq!(json_config.ignore_command_entries, vec!["my-codegen"]);
1895
1896        let default_config: FallowConfig = serde_json::from_str("{}").unwrap();
1897        assert!(default_config.ignore_command_entries.is_empty());
1898        let serialized = serde_json::to_value(&default_config).unwrap();
1899        assert!(serialized.get("ignoreCommandEntries").is_none());
1900    }
1901
1902    #[test]
1903    fn fallow_config_deserialize_ignore_unresolved_imports() {
1904        let toml_str = r#"
1905ignoreUnresolvedImports = ["@example/icons", "@example/icons/**", "../generated/**"]
1906"#;
1907        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1908        assert_eq!(
1909            config.ignore_unresolved_imports,
1910            vec!["@example/icons", "@example/icons/**", "../generated/**"]
1911        );
1912    }
1913
1914    #[test]
1915    fn fallow_config_resolve_default_ignores() {
1916        let config = FallowConfig::default();
1917        let resolved = config.resolve(
1918            PathBuf::from("/tmp/test"),
1919            OutputFormat::Human,
1920            4,
1921            true,
1922            true,
1923            None,
1924        );
1925
1926        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
1927        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1928        assert!(resolved.ignore_patterns.is_match("build/output.js"));
1929        assert!(resolved.ignore_patterns.is_match(".git/config"));
1930        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
1931        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
1932        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
1933    }
1934
1935    #[test]
1936    fn fallow_config_resolve_custom_ignores() {
1937        let config = FallowConfig {
1938            entry: vec!["src/**/*.ts".to_string()],
1939            ignore_patterns: vec!["**/*.generated.ts".to_string()],
1940            ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
1941            ..Default::default()
1942        };
1943        let resolved = config.resolve(
1944            PathBuf::from("/tmp/test"),
1945            OutputFormat::Json,
1946            4,
1947            false,
1948            true,
1949            None,
1950        );
1951
1952        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
1953        assert!(
1954            resolved
1955                .ignore_findings
1956                .is_ignored("src/private/app.test.ts")
1957        );
1958        assert!(
1959            !resolved
1960                .ignore_findings
1961                .is_ignored("src/public/app.test.ts")
1962        );
1963        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
1964        assert!(matches!(resolved.output, OutputFormat::Json));
1965        assert!(!resolved.no_cache);
1966    }
1967
1968    #[test]
1969    fn fallow_config_resolve_cache_dir() {
1970        let config = FallowConfig::default();
1971        let resolved = config.resolve(
1972            PathBuf::from("/tmp/project"),
1973            OutputFormat::Human,
1974            4,
1975            true,
1976            true,
1977            None,
1978        );
1979        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
1980        assert!(resolved.no_cache);
1981    }
1982
1983    #[test]
1984    fn package_json_entry_points_main() {
1985        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
1986        let entries = pkg.entry_points();
1987        assert!(entries.contains(&"dist/index.js".to_string()));
1988    }
1989
1990    #[test]
1991    fn package_json_entry_points_module() {
1992        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
1993        let entries = pkg.entry_points();
1994        assert!(entries.contains(&"dist/index.mjs".to_string()));
1995    }
1996
1997    #[test]
1998    fn package_json_entry_points_types() {
1999        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
2000        let entries = pkg.entry_points();
2001        assert!(entries.contains(&"dist/index.d.ts".to_string()));
2002    }
2003
2004    #[test]
2005    fn package_json_entry_points_bin_string() {
2006        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
2007        let entries = pkg.entry_points();
2008        assert!(entries.contains(&"bin/cli.js".to_string()));
2009    }
2010
2011    #[test]
2012    fn package_json_entry_points_bin_object() {
2013        let pkg: PackageJson =
2014            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
2015                .unwrap();
2016        let entries = pkg.entry_points();
2017        assert!(entries.contains(&"bin/cli.js".to_string()));
2018        assert!(entries.contains(&"bin/serve.js".to_string()));
2019    }
2020
2021    #[test]
2022    fn package_json_entry_points_exports_string() {
2023        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
2024        let entries = pkg.entry_points();
2025        assert!(entries.contains(&"./dist/index.js".to_string()));
2026    }
2027
2028    #[test]
2029    fn package_json_entry_points_exports_object() {
2030        let pkg: PackageJson = serde_json::from_str(
2031            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
2032        )
2033        .unwrap();
2034        let entries = pkg.entry_points();
2035        assert!(entries.contains(&"./dist/index.mjs".to_string()));
2036        assert!(entries.contains(&"./dist/index.cjs".to_string()));
2037    }
2038
2039    #[test]
2040    fn package_json_dependency_names() {
2041        let pkg: PackageJson = serde_json::from_str(
2042            r#"{
2043            "dependencies": {"react": "^18", "lodash": "^4"},
2044            "devDependencies": {"typescript": "^5"},
2045            "peerDependencies": {"react-dom": "^18"}
2046        }"#,
2047        )
2048        .unwrap();
2049
2050        let all = pkg.all_dependency_names();
2051        assert!(all.contains(&"react".to_string()));
2052        assert!(all.contains(&"lodash".to_string()));
2053        assert!(all.contains(&"typescript".to_string()));
2054        assert!(all.contains(&"react-dom".to_string()));
2055
2056        let prod = pkg.production_dependency_names();
2057        assert!(prod.contains(&"react".to_string()));
2058        assert!(!prod.contains(&"typescript".to_string()));
2059
2060        let dev = pkg.dev_dependency_names();
2061        assert!(dev.contains(&"typescript".to_string()));
2062        assert!(!dev.contains(&"react".to_string()));
2063    }
2064
2065    #[test]
2066    fn package_json_no_dependencies() {
2067        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2068        assert!(pkg.all_dependency_names().is_empty());
2069        assert!(pkg.production_dependency_names().is_empty());
2070        assert!(pkg.dev_dependency_names().is_empty());
2071        assert!(pkg.entry_points().is_empty());
2072    }
2073
2074    #[test]
2075    fn rules_deserialize_toml_kebab_case() {
2076        let toml_str = r#"
2077[rules]
2078unused-files = "error"
2079unused-exports = "warn"
2080unused-types = "off"
2081"#;
2082        let config: FallowConfig = toml::from_str(toml_str).unwrap();
2083        assert_eq!(config.rules.unused_files, Severity::Error);
2084        assert_eq!(config.rules.unused_exports, Severity::Warn);
2085        assert_eq!(config.rules.unused_types, Severity::Off);
2086        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2087    }
2088
2089    #[test]
2090    fn config_without_rules_defaults_to_error() {
2091        let toml_str = r#"
2092entry = ["src/main.ts"]
2093"#;
2094        let config: FallowConfig = toml::from_str(toml_str).unwrap();
2095        assert_eq!(config.rules.unused_files, Severity::Error);
2096        assert_eq!(config.rules.unused_exports, Severity::Error);
2097    }
2098
2099    #[test]
2100    fn fallow_config_denies_unknown_fields() {
2101        let toml_str = r"
2102unknown_field = true
2103";
2104        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
2105        assert!(result.is_err());
2106    }
2107
2108    #[test]
2109    fn fallow_config_deserialize_json() {
2110        let json_str = r#"{"entry": ["src/main.ts"]}"#;
2111        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2112        assert_eq!(config.entry, vec!["src/main.ts"]);
2113    }
2114
2115    #[test]
2116    fn fallow_config_deserialize_jsonc() {
2117        let jsonc_str = r#"{
2118            "entry": ["src/main.ts"],
2119            "rules": {
2120                "unused-files": "warn"
2121            }
2122        }"#;
2123        let config: FallowConfig = crate::jsonc::parse_to_value(jsonc_str).unwrap();
2124        assert_eq!(config.entry, vec!["src/main.ts"]);
2125        assert_eq!(config.rules.unused_files, Severity::Warn);
2126    }
2127
2128    #[test]
2129    fn fallow_config_json_with_schema_field() {
2130        let json_str =
2131            r#"{"$schema": "./node_modules/fallow/schema.json", "entry": ["src/main.ts"]}"#;
2132        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2133        assert_eq!(config.entry, vec!["src/main.ts"]);
2134    }
2135
2136    #[test]
2137    fn fallow_config_json_schema_generation() {
2138        let schema = FallowConfig::json_schema();
2139        assert!(schema.is_object());
2140        let obj = schema.as_object().unwrap();
2141        assert!(obj.contains_key("properties"));
2142    }
2143
2144    /// Both `.fallowrc.json` and `.fallowrc.jsonc` are loaded through
2145    /// [`crate::jsonc::parse_options`], so the published schema has to declare
2146    /// the same dialect. Editors built on the JSON language service read
2147    /// `allowComments` and `allowTrailingCommas` off the root schema object and
2148    /// otherwise report syntax fallow accepts. Asserting against the parser's
2149    /// own options makes the schema and the loader fail together if either one
2150    /// moves.
2151    #[test]
2152    fn json_schema_advertises_the_jsonc_dialect_it_parses() {
2153        let options = crate::jsonc::parse_options();
2154        let schema = FallowConfig::json_schema();
2155        let root = schema.as_object().expect("schema should be an object");
2156
2157        assert_eq!(
2158            root.get("allowTrailingCommas")
2159                .and_then(serde_json::Value::as_bool),
2160            Some(options.allow_trailing_commas),
2161            "schema should advertise the parser's trailing-comma support"
2162        );
2163        assert_eq!(
2164            root.get("allowComments")
2165                .and_then(serde_json::Value::as_bool),
2166            Some(options.allow_comments),
2167            "schema should advertise the parser's comment support"
2168        );
2169    }
2170
2171    #[test]
2172    fn config_format_detection() {
2173        assert!(matches!(
2174            ConfigFormat::from_path(Path::new("fallow.toml")),
2175            ConfigFormat::Toml
2176        ));
2177        assert!(matches!(
2178            ConfigFormat::from_path(Path::new(".fallowrc.json")),
2179            ConfigFormat::Json
2180        ));
2181        assert!(matches!(
2182            ConfigFormat::from_path(Path::new(".fallowrc.jsonc")),
2183            ConfigFormat::Json
2184        ));
2185        assert!(matches!(
2186            ConfigFormat::from_path(Path::new(".fallow.toml")),
2187            ConfigFormat::Toml
2188        ));
2189    }
2190
2191    #[test]
2192    fn config_names_priority_order() {
2193        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
2194        assert_eq!(CONFIG_NAMES[1], ".fallowrc.jsonc");
2195        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
2196        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
2197    }
2198
2199    #[test]
2200    fn load_json_config_file() {
2201        let dir = test_dir("json-config");
2202        let config_path = dir.path().join(".fallowrc.json");
2203        std::fs::write(
2204            &config_path,
2205            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
2206        )
2207        .unwrap();
2208
2209        let config = FallowConfig::load(&config_path).unwrap();
2210        assert_eq!(config.entry, vec!["src/index.ts"]);
2211        assert_eq!(config.rules.unused_exports, Severity::Warn);
2212    }
2213
2214    #[test]
2215    fn load_records_explicit_private_type_leaks_setting() {
2216        let dir = test_dir("explicit-private-type-leaks");
2217        let config_path = dir.path().join(".fallowrc.json");
2218        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
2219
2220        let config = FallowConfig::load(&config_path).unwrap();
2221        assert_eq!(config.rules.private_type_leaks, Severity::Off);
2222        assert!(config.rules.private_type_leaks_configured);
2223    }
2224
2225    #[test]
2226    fn load_records_explicit_private_type_leaks_singular_alias() {
2227        let dir = test_dir("explicit-private-type-leak-alias");
2228        let config_path = dir.path().join(".fallowrc.json");
2229        std::fs::write(&config_path, r#"{"rules": {"private-type-leak": "off"}}"#).unwrap();
2230
2231        let config = FallowConfig::load(&config_path).unwrap();
2232        assert!(config.rules.private_type_leaks_configured);
2233    }
2234
2235    #[test]
2236    fn load_leaves_defaulted_private_type_leaks_unmarked() {
2237        let dir = test_dir("defaulted-private-type-leaks");
2238        let config_path = dir.path().join(".fallowrc.json");
2239        std::fs::write(&config_path, r#"{"rules": {"unused-exports": "warn"}}"#).unwrap();
2240
2241        let config = FallowConfig::load(&config_path).unwrap();
2242        assert_eq!(config.rules.private_type_leaks, Severity::Off);
2243        assert!(!config.rules.private_type_leaks_configured);
2244    }
2245
2246    /// The explicit-configuration flag is `serde(skip)`, so a serde round-trip
2247    /// of a loaded config drops it and the type-aware `warn` default would
2248    /// force the rule back on. No production path round-trips a
2249    /// [`FallowConfig`] today; this test pins the hazard so introducing one
2250    /// fails loudly instead of silently re-enabling an explicitly-off rule.
2251    #[test]
2252    fn serde_round_trip_drops_explicit_private_type_leaks_flag() {
2253        let dir = test_dir("round-trip-private-type-leaks");
2254        let config_path = dir.path().join(".fallowrc.json");
2255        std::fs::write(&config_path, r#"{"rules": {"private-type-leaks": "off"}}"#).unwrap();
2256
2257        let config = FallowConfig::load(&config_path).unwrap();
2258        assert!(config.rules.private_type_leaks_configured);
2259
2260        let serialized = serde_json::to_value(&config).unwrap();
2261        let round_tripped: FallowConfig = serde_json::from_value(serialized).unwrap();
2262
2263        assert_eq!(round_tripped.rules.private_type_leaks, Severity::Off);
2264        assert!(
2265            !round_tripped.rules.private_type_leaks_configured,
2266            "a serde round-trip drops the serde(skip) flag; any code path that \
2267             round-trips a loaded FallowConfig must re-record it (see the field \
2268             docs on RulesConfig::private_type_leaks_configured)"
2269        );
2270    }
2271
2272    #[test]
2273    fn load_json_config_file_with_health_threshold_override() {
2274        let dir = test_dir("json-health-threshold-override");
2275        let config_path = dir.path().join(".fallowrc.json");
2276        std::fs::write(
2277            &config_path,
2278            r#"{
2279                "health": {
2280                    "thresholdOverrides": [
2281                        {
2282                            "files": ["src/legacy.ts"],
2283                            "functions": ["legacyFlow"],
2284                            "maxCyclomatic": 30,
2285                            "maxCognitive": 25,
2286                            "maxCrap": 80.5,
2287                            "reason": "legacy migration"
2288                        }
2289                    ]
2290                }
2291            }"#,
2292        )
2293        .unwrap();
2294
2295        let config = FallowConfig::load(&config_path).unwrap();
2296        let override_config = &config.health.threshold_overrides[0];
2297        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2298        assert_eq!(override_config.functions, vec!["legacyFlow"]);
2299        assert_eq!(override_config.max_cyclomatic, Some(30));
2300        assert_eq!(override_config.max_cognitive, Some(25));
2301        assert_eq!(override_config.max_crap, Some(80.5));
2302        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
2303    }
2304
2305    #[test]
2306    fn load_jsonc_config_file() {
2307        let dir = test_dir("jsonc-config");
2308        let config_path = dir.path().join(".fallowrc.json");
2309        std::fs::write(
2310            &config_path,
2311            r#"{
2312                "entry": ["src/index.ts"],
2313                /* Block comment */
2314                "rules": {
2315                    "unused-exports": "warn"
2316                }
2317            }"#,
2318        )
2319        .unwrap();
2320
2321        let config = FallowConfig::load(&config_path).unwrap();
2322        assert_eq!(config.entry, vec!["src/index.ts"]);
2323        assert_eq!(config.rules.unused_exports, Severity::Warn);
2324    }
2325
2326    #[test]
2327    fn load_jsonc_config_file_with_health_threshold_override() {
2328        let dir = test_dir("jsonc-health-threshold-override");
2329        let config_path = dir.path().join(".fallowrc.jsonc");
2330        std::fs::write(
2331            &config_path,
2332            r#"{
2333                "health": {
2334                    // Empty functions means every function in matching files.
2335                    "thresholdOverrides": [
2336                        { "files": ["src/legacy.ts"], "maxCognitive": 25 }
2337                    ]
2338                }
2339            }"#,
2340        )
2341        .unwrap();
2342
2343        let config = FallowConfig::load(&config_path).unwrap();
2344        let override_config = &config.health.threshold_overrides[0];
2345        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
2346        assert!(override_config.functions.is_empty());
2347        assert_eq!(override_config.max_cognitive, Some(25));
2348    }
2349
2350    #[test]
2351    fn load_fallowrc_jsonc_extension() {
2352        let dir = test_dir("jsonc-extension");
2353        let config_path = dir.path().join(".fallowrc.jsonc");
2354        std::fs::write(
2355            &config_path,
2356            r#"{
2357                "ignoreDependencies": ["tailwindcss-react-aria-components"],
2358                "entry": ["src/index.ts"]
2359            }"#,
2360        )
2361        .unwrap();
2362
2363        let config = FallowConfig::load(&config_path).unwrap();
2364        assert_eq!(config.entry, vec!["src/index.ts"]);
2365        assert_eq!(
2366            config.ignore_dependencies,
2367            vec!["tailwindcss-react-aria-components"]
2368        );
2369    }
2370
2371    #[test]
2372    fn json_config_ignore_dependencies_camel_case() {
2373        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
2374        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2375        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
2376    }
2377
2378    #[test]
2379    fn json_config_ignore_unresolved_imports_camel_case() {
2380        let json_str = r#"{"ignoreUnresolvedImports": ["@example/icons", "@example/icons/**"]}"#;
2381        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2382        assert_eq!(
2383            config.ignore_unresolved_imports,
2384            vec!["@example/icons", "@example/icons/**"]
2385        );
2386    }
2387
2388    #[test]
2389    fn json_config_all_fields() {
2390        let json_str = r#"{
2391            "ignoreDependencies": ["lodash"],
2392            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
2393            "rules": {
2394                "unused-files": "off",
2395                "unused-exports": "warn",
2396                "unused-dependencies": "error",
2397                "unused-dev-dependencies": "off",
2398                "unused-types": "warn",
2399                "unused-enum-members": "error",
2400                "unused-class-members": "off",
2401                "unresolved-imports": "warn",
2402                "unlisted-dependencies": "error",
2403                "duplicate-exports": "off"
2404            },
2405            "duplicates": {
2406                "minTokens": 100,
2407                "minLines": 10,
2408                "skipLocal": true,
2409                "ignoreSymlinks": true
2410            }
2411        }"#;
2412        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2413        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
2414        assert_eq!(config.rules.unused_files, Severity::Off);
2415        assert_eq!(config.rules.unused_exports, Severity::Warn);
2416        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2417        assert_eq!(config.duplicates.min_tokens, 100);
2418        assert_eq!(config.duplicates.min_lines, 10);
2419        assert!(config.duplicates.skip_local);
2420        assert!(config.duplicates.ignore_symlinks);
2421    }
2422
2423    #[test]
2424    fn extends_single_base() {
2425        let dir = test_dir("extends-single");
2426
2427        std::fs::write(
2428            dir.path().join("base.json"),
2429            r#"{"rules": {"unused-files": "warn"}}"#,
2430        )
2431        .unwrap();
2432        std::fs::write(
2433            dir.path().join(".fallowrc.json"),
2434            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
2435        )
2436        .unwrap();
2437
2438        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2439        assert_eq!(config.rules.unused_files, Severity::Warn);
2440        assert_eq!(config.entry, vec!["src/index.ts"]);
2441        assert_eq!(config.rules.unused_exports, Severity::Error);
2442    }
2443
2444    #[test]
2445    fn extends_overlay_overrides_base() {
2446        let dir = test_dir("extends-overlay");
2447
2448        std::fs::write(
2449            dir.path().join("base.json"),
2450            r#"{"ignoreFindings": ["generated/**"], "rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
2451        )
2452        .unwrap();
2453        std::fs::write(
2454            dir.path().join(".fallowrc.json"),
2455            r#"{"extends": ["base.json"], "ignoreFindings": ["**/*.test.ts"], "rules": {"unused-files": "error"}}"#,
2456        )
2457        .unwrap();
2458
2459        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2460        assert_eq!(config.ignore_findings, vec!["**/*.test.ts"]);
2461        assert_eq!(config.rules.unused_files, Severity::Error);
2462        assert_eq!(config.rules.unused_exports, Severity::Off);
2463    }
2464
2465    #[test]
2466    fn extends_chained() {
2467        let dir = test_dir("extends-chained");
2468
2469        std::fs::write(
2470            dir.path().join("grandparent.json"),
2471            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
2472        )
2473        .unwrap();
2474        std::fs::write(
2475            dir.path().join("parent.json"),
2476            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
2477        )
2478        .unwrap();
2479        std::fs::write(
2480            dir.path().join(".fallowrc.json"),
2481            r#"{"extends": ["parent.json"]}"#,
2482        )
2483        .unwrap();
2484
2485        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2486        assert_eq!(config.rules.unused_files, Severity::Warn);
2487        assert_eq!(config.rules.unused_exports, Severity::Warn);
2488    }
2489
2490    #[test]
2491    fn extends_local_diamond_reuses_resolved_base() {
2492        let dir = test_dir("extends-local-diamond");
2493        std::fs::write(
2494            dir.path().join("base.json"),
2495            r#"{"ignorePatterns": ["generated/**"]}"#,
2496        )
2497        .unwrap();
2498        std::fs::write(
2499            dir.path().join("left.json"),
2500            r#"{"extends": ["base.json"], "rules": {"unused-files": "warn"}}"#,
2501        )
2502        .unwrap();
2503        std::fs::write(
2504            dir.path().join("right.json"),
2505            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
2506        )
2507        .unwrap();
2508        std::fs::write(
2509            dir.path().join(".fallowrc.json"),
2510            r#"{"extends": ["left.json", "right.json"]}"#,
2511        )
2512        .unwrap();
2513
2514        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2515        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2516        assert_eq!(config.rules.unused_files, Severity::Warn);
2517        assert_eq!(config.rules.unused_exports, Severity::Off);
2518    }
2519
2520    #[test]
2521    fn extends_circular_detected() {
2522        let dir = test_dir("extends-circular");
2523
2524        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
2525        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
2526
2527        let result = FallowConfig::load(&dir.path().join("a.json"));
2528        assert!(result.is_err());
2529        let err_msg = format!("{}", result.unwrap_err());
2530        assert!(
2531            err_msg.contains("Circular extends"),
2532            "Expected circular error, got: {err_msg}"
2533        );
2534    }
2535
2536    #[test]
2537    fn remote_extends_are_denied_before_fetch_by_default() {
2538        let dir = test_dir("remote-default-denied");
2539        let url = "https://config-user:config-password@config.example:8443/base.json?token=config-token#config-fragment";
2540        std::fs::write(
2541            dir.path().join(".fallowrc.json"),
2542            format!(r#"{{"extends": "{url}"}}"#),
2543        )
2544        .unwrap();
2545        let mut fetcher = MockRemoteFetcher::default().with_response(url, serde_json::json!({}));
2546
2547        let error = load_with_fetcher(
2548            &dir.path().join(".fallowrc.json"),
2549            ConfigLoadOptions::default(),
2550            &mut fetcher,
2551        )
2552        .unwrap_err()
2553        .to_string();
2554
2555        assert!(
2556            error.contains("https://config.example:8443/base.json"),
2557            "denial must name the URL without secrets"
2558        );
2559        for secret in [
2560            "config-user",
2561            "config-password",
2562            "config-token",
2563            "config-fragment",
2564        ] {
2565            assert!(
2566                !error.contains(secret),
2567                "denial must not expose a secret value"
2568            );
2569        }
2570        assert!(
2571            error.contains("--allow-remote-extends"),
2572            "denial must name the CLI opt-in"
2573        );
2574        assert!(
2575            error.contains("ConfigLoadOptions"),
2576            "denial must name the library opt-in"
2577        );
2578        assert!(
2579            fetcher.requests.is_empty(),
2580            "denial must happen before fetch"
2581        );
2582    }
2583
2584    #[test]
2585    fn remote_parent_and_requested_url_secrets_are_redacted_in_errors() {
2586        let dir = test_dir("remote-error-redaction");
2587        let parent = "https://parent-user:parent-password@[2001:db8::1]:8443/base.json?token=parent-token#parent-fragment";
2588        let requested = "http://child-user:child-password@child.example/child.json?token=child-token#child-fragment";
2589        std::fs::write(
2590            dir.path().join(".fallowrc.json"),
2591            format!(r#"{{"extends": "{parent}"}}"#),
2592        )
2593        .unwrap();
2594        let mut fetcher = MockRemoteFetcher::default()
2595            .with_response(parent, serde_json::json!({"extends": requested}));
2596
2597        let error = load_with_fetcher(
2598            &dir.path().join(".fallowrc.json"),
2599            ConfigLoadOptions {
2600                allow_remote_extends: true,
2601            },
2602            &mut fetcher,
2603        )
2604        .unwrap_err()
2605        .to_string();
2606
2607        assert!(
2608            error.contains("http://child.example/child.json"),
2609            "error must preserve the requested host and path"
2610        );
2611        assert!(
2612            error.contains("https://[2001:db8::1]:8443/base.json"),
2613            "error must preserve the remote parent host, port, and path"
2614        );
2615        for secret in [
2616            "parent-user",
2617            "parent-password",
2618            "parent-token",
2619            "parent-fragment",
2620            "child-user",
2621            "child-password",
2622            "child-token",
2623            "child-fragment",
2624        ] {
2625            assert!(
2626                !error.contains(secret),
2627                "remote error must not expose a secret value"
2628            );
2629        }
2630    }
2631
2632    #[test]
2633    fn remote_fetch_network_error_redacts_requested_url_and_source() {
2634        let url = "https://request-user:request-password@127.0.0.1:0/config.json?token=request-token#request-fragment";
2635        let source = "https://source-user:source-password@source.example/parent.json?token=source-token#source-fragment";
2636
2637        let error = fetch_url_config(url, source)
2638            .expect_err("the reserved local port must reject the request")
2639            .to_string();
2640
2641        assert!(
2642            error.contains("https://127.0.0.1:0/config.json"),
2643            "network error must preserve the requested host, port, and path"
2644        );
2645        assert!(
2646            error.contains("https://source.example/parent.json"),
2647            "network error must preserve the source host and path"
2648        );
2649        for secret in [
2650            "request-user",
2651            "request-password",
2652            "request-token",
2653            "request-fragment",
2654            "source-user",
2655            "source-password",
2656            "source-token",
2657            "source-fragment",
2658        ] {
2659            assert!(
2660                !error.contains(secret),
2661                "network error must not expose a secret value"
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn remote_fetch_error_detail_does_not_trust_normalized_urls() {
2668        let url = "https://request-user:request-password@config.example/config.json?token=request-token#request-fragment";
2669        let normalized_error = ureq::Error::BadUri(
2670            "https://request-user:request-password@config.example/config.json?token=request-token"
2671                .to_string(),
2672        );
2673
2674        assert!(
2675            remote_fetch_error_display(&normalized_error, url) == "request failed",
2676            "normalized network errors must not bypass URL redaction"
2677        );
2678    }
2679
2680    #[test]
2681    fn remote_fetch_error_detail_does_not_trust_redirect_like_payloads() {
2682        let original = "https://config.example/config.json";
2683        let error = ureq::Error::BadUri(
2684            "http://redirect-user:redirect-password@example.com/next.json?token=secret#anchor"
2685                .to_string(),
2686        );
2687
2688        let display = remote_fetch_error_display(&error, original);
2689        assert_eq!(display, "request failed");
2690        for secret in [
2691            "redirect-user",
2692            "redirect-password",
2693            "token=secret",
2694            "anchor",
2695        ] {
2696            assert!(
2697                !display.contains(secret),
2698                "untrusted error detail must be hidden"
2699            );
2700        }
2701    }
2702
2703    #[test]
2704    fn remote_extends_dispatch_when_explicitly_allowed() {
2705        let dir = test_dir("remote-explicitly-allowed");
2706        let url = "https://config.example/base.json";
2707        std::fs::write(
2708            dir.path().join(".fallowrc.json"),
2709            format!(r#"{{"extends": "{url}"}}"#),
2710        )
2711        .unwrap();
2712        let mut fetcher = MockRemoteFetcher::default()
2713            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2714
2715        let config = load_with_fetcher(
2716            &dir.path().join(".fallowrc.json"),
2717            ConfigLoadOptions {
2718                allow_remote_extends: true,
2719            },
2720            &mut fetcher,
2721        )
2722        .unwrap();
2723
2724        assert_eq!(config.rules.unused_files, Severity::Warn);
2725        assert_eq!(fetcher.requests, vec![url]);
2726    }
2727
2728    #[test]
2729    fn uppercase_https_remote_extends_dispatch_when_allowed() {
2730        let dir = test_dir("remote-uppercase-scheme");
2731        let url = "HTTPS://config.example/base.json";
2732        std::fs::write(
2733            dir.path().join(".fallowrc.json"),
2734            format!(r#"{{"extends": "{url}"}}"#),
2735        )
2736        .unwrap();
2737        let mut fetcher = MockRemoteFetcher::default()
2738            .with_response(url, serde_json::json!({"rules": {"unused-files": "warn"}}));
2739
2740        let config = load_with_fetcher(
2741            &dir.path().join(".fallowrc.json"),
2742            ConfigLoadOptions {
2743                allow_remote_extends: true,
2744            },
2745            &mut fetcher,
2746        )
2747        .unwrap();
2748
2749        assert_eq!(config.rules.unused_files, Severity::Warn);
2750        assert_eq!(fetcher.requests, vec![url]);
2751    }
2752
2753    #[test]
2754    fn remote_extends_diamond_reuses_mocked_base() {
2755        let dir = test_dir("remote-diamond");
2756        let left = "https://config.example/left.json";
2757        let right = "https://config.example/right.json";
2758        let base = "https://config.example/base.json";
2759        std::fs::write(
2760            dir.path().join(".fallowrc.json"),
2761            format!(r#"{{"extends": ["{left}", "{right}"]}}"#),
2762        )
2763        .unwrap();
2764        let mut fetcher = MockRemoteFetcher::default()
2765            .with_response(
2766                left,
2767                serde_json::json!({
2768                    "extends": base,
2769                    "rules": {"unused-files": "warn"}
2770                }),
2771            )
2772            .with_response(
2773                right,
2774                serde_json::json!({
2775                    "extends": base,
2776                    "rules": {"unused-exports": "off"}
2777                }),
2778            )
2779            .with_response(
2780                base,
2781                serde_json::json!({"ignorePatterns": ["generated/**"]}),
2782            );
2783
2784        let config = load_with_fetcher(
2785            &dir.path().join(".fallowrc.json"),
2786            ConfigLoadOptions {
2787                allow_remote_extends: true,
2788            },
2789            &mut fetcher,
2790        )
2791        .unwrap();
2792
2793        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
2794        assert_eq!(config.rules.unused_files, Severity::Warn);
2795        assert_eq!(config.rules.unused_exports, Severity::Off);
2796        assert_eq!(
2797            fetcher
2798                .requests
2799                .iter()
2800                .filter(|request| *request == base)
2801                .count(),
2802            1,
2803            "the shared remote base should be fetched once"
2804        );
2805    }
2806
2807    #[test]
2808    fn remote_extends_active_cycle_still_fails() {
2809        let dir = test_dir("remote-cycle");
2810        let first = "https://config.example/first.json";
2811        let second = "https://config.example/second.json";
2812        std::fs::write(
2813            dir.path().join(".fallowrc.json"),
2814            format!(r#"{{"extends": "{first}"}}"#),
2815        )
2816        .unwrap();
2817        let mut fetcher = MockRemoteFetcher::default()
2818            .with_response(first, serde_json::json!({"extends": second}))
2819            .with_response(second, serde_json::json!({"extends": first}));
2820
2821        let error = load_with_fetcher(
2822            &dir.path().join(".fallowrc.json"),
2823            ConfigLoadOptions {
2824                allow_remote_extends: true,
2825            },
2826            &mut fetcher,
2827        )
2828        .unwrap_err()
2829        .to_string();
2830
2831        assert!(
2832            error.contains("Circular extends"),
2833            "unexpected error: {error}"
2834        );
2835    }
2836
2837    #[test]
2838    fn query_distinct_remote_extends_remain_distinct() {
2839        let dir = test_dir("remote-query-distinct");
2840        let first = "https://config.example/base.json?profile=one";
2841        let second = "https://config.example/base.json?profile=two";
2842        std::fs::write(
2843            dir.path().join(".fallowrc.json"),
2844            format!(r#"{{"extends": ["{first}", "{second}"]}}"#),
2845        )
2846        .unwrap();
2847        let mut fetcher = MockRemoteFetcher::default()
2848            .with_response(
2849                first,
2850                serde_json::json!({"rules": {"unused-files": "warn"}}),
2851            )
2852            .with_response(
2853                second,
2854                serde_json::json!({"rules": {"unused-exports": "off"}}),
2855            );
2856
2857        let config = load_with_fetcher(
2858            &dir.path().join(".fallowrc.json"),
2859            ConfigLoadOptions {
2860                allow_remote_extends: true,
2861            },
2862            &mut fetcher,
2863        )
2864        .unwrap();
2865
2866        assert_eq!(config.rules.unused_files, Severity::Warn);
2867        assert_eq!(config.rules.unused_exports, Severity::Off);
2868        assert_eq!(fetcher.requests, vec![first, second]);
2869    }
2870
2871    #[test]
2872    fn extends_missing_file_errors() {
2873        let dir = test_dir("extends-missing");
2874
2875        std::fs::write(
2876            dir.path().join(".fallowrc.json"),
2877            r#"{"extends": ["nonexistent.json"]}"#,
2878        )
2879        .unwrap();
2880
2881        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2882        assert!(result.is_err());
2883        let err_msg = format!("{}", result.unwrap_err());
2884        assert!(
2885            err_msg.contains("not found"),
2886            "Expected not found error, got: {err_msg}"
2887        );
2888    }
2889
2890    #[test]
2891    fn sealed_allows_in_directory_extends() {
2892        let dir = test_dir("sealed-allows-local");
2893        std::fs::write(
2894            dir.path().join("base.json"),
2895            r#"{"ignorePatterns": ["gen/**"]}"#,
2896        )
2897        .unwrap();
2898        std::fs::write(
2899            dir.path().join(".fallowrc.json"),
2900            r#"{"sealed": true, "extends": ["./base.json"]}"#,
2901        )
2902        .unwrap();
2903
2904        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2905        assert!(config.sealed);
2906        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
2907    }
2908
2909    #[test]
2910    fn load_rejects_invalid_boundary_coverage_allow_unmatched_glob() {
2911        let dir = test_dir("boundary-coverage-invalid-glob");
2912        std::fs::write(
2913            dir.path().join(".fallowrc.json"),
2914            r#"{"boundaries":{"coverage":{"allowUnmatched":["[invalid"]}}}"#,
2915        )
2916        .unwrap();
2917
2918        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2919        assert!(result.is_err());
2920        let err_msg = format!("{}", result.unwrap_err());
2921        assert!(
2922            err_msg.contains("boundaries.coverage.allowUnmatched"),
2923            "expected coverage field in error, got: {err_msg}"
2924        );
2925    }
2926
2927    #[test]
2928    fn sealed_rejects_extends_escaping_directory() {
2929        let dir = test_dir("sealed-rejects-escape");
2930        let sub = dir.path().join("packages").join("app");
2931        std::fs::create_dir_all(&sub).unwrap();
2932
2933        std::fs::write(
2934            dir.path().join("base.json"),
2935            r#"{"ignorePatterns": ["dist/**"]}"#,
2936        )
2937        .unwrap();
2938        std::fs::write(
2939            sub.join(".fallowrc.json"),
2940            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
2941        )
2942        .unwrap();
2943
2944        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
2945        assert!(
2946            result.is_err(),
2947            "Expected sealed config to reject escaping extends"
2948        );
2949        let err_msg = format!("{}", result.unwrap_err());
2950        assert!(
2951            err_msg.contains("sealed"),
2952            "Error must mention sealed: {err_msg}"
2953        );
2954        assert!(
2955            err_msg.contains("outside the config's directory"),
2956            "Error must explain the constraint: {err_msg}"
2957        );
2958    }
2959
2960    #[test]
2961    fn sealed_rejects_https_extends() {
2962        let dir = test_dir("sealed-rejects-https");
2963        std::fs::write(
2964            dir.path().join(".fallowrc.json"),
2965            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
2966        )
2967        .unwrap();
2968
2969        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2970        assert!(result.is_err());
2971        let err_msg = format!("{}", result.unwrap_err());
2972        assert!(
2973            err_msg.contains("sealed"),
2974            "Error must mention sealed: {err_msg}"
2975        );
2976        assert!(
2977            err_msg.contains("URL extends"),
2978            "Error must mention URL: {err_msg}"
2979        );
2980    }
2981
2982    #[test]
2983    fn sealed_rejects_npm_extends() {
2984        let dir = test_dir("sealed-rejects-npm");
2985        std::fs::write(
2986            dir.path().join(".fallowrc.json"),
2987            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
2988        )
2989        .unwrap();
2990
2991        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2992        assert!(result.is_err());
2993        let err_msg = format!("{}", result.unwrap_err());
2994        assert!(
2995            err_msg.contains("sealed"),
2996            "Error must mention sealed: {err_msg}"
2997        );
2998        assert!(
2999            err_msg.contains("npm extends"),
3000            "Error must mention npm: {err_msg}"
3001        );
3002    }
3003
3004    #[test]
3005    fn sealed_default_is_false() {
3006        let dir = test_dir("sealed-default");
3007        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
3008        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3009        assert!(!config.sealed);
3010    }
3011
3012    #[test]
3013    fn sealed_false_allows_escaping_extends() {
3014        let dir = test_dir("sealed-false-allows");
3015        let sub = dir.path().join("packages").join("app");
3016        std::fs::create_dir_all(&sub).unwrap();
3017
3018        std::fs::write(
3019            dir.path().join("base.json"),
3020            r#"{"ignorePatterns": ["dist/**"]}"#,
3021        )
3022        .unwrap();
3023        std::fs::write(
3024            sub.join(".fallowrc.json"),
3025            r#"{"extends": ["../../base.json"]}"#,
3026        )
3027        .unwrap();
3028
3029        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
3030        assert!(!config.sealed);
3031        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
3032    }
3033
3034    #[test]
3035    fn extends_string_sugar() {
3036        let dir = test_dir("extends-string");
3037
3038        std::fs::write(
3039            dir.path().join("base.json"),
3040            r#"{"ignorePatterns": ["gen/**"]}"#,
3041        )
3042        .unwrap();
3043        std::fs::write(
3044            dir.path().join(".fallowrc.json"),
3045            r#"{"extends": "base.json"}"#,
3046        )
3047        .unwrap();
3048
3049        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3050        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
3051    }
3052
3053    #[test]
3054    fn extends_deep_merge_preserves_arrays() {
3055        let dir = test_dir("extends-array");
3056
3057        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
3058        std::fs::write(
3059            dir.path().join(".fallowrc.json"),
3060            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
3061        )
3062        .unwrap();
3063
3064        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3065        assert_eq!(config.entry, vec!["src/b.ts"]);
3066    }
3067
3068    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
3069        let pkg_dir = root.join("node_modules").join(name);
3070        std::fs::create_dir_all(&pkg_dir).unwrap();
3071        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
3072    }
3073
3074    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
3075        let pkg_dir = root.join("node_modules").join(name);
3076        std::fs::create_dir_all(&pkg_dir).unwrap();
3077        std::fs::write(
3078            pkg_dir.join("package.json"),
3079            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
3080        )
3081        .unwrap();
3082        std::fs::write(pkg_dir.join(main), config_json).unwrap();
3083    }
3084
3085    #[test]
3086    fn extends_npm_basic_unscoped() {
3087        let dir = test_dir("npm-basic");
3088        create_npm_package(
3089            dir.path(),
3090            "fallow-config-acme",
3091            r#"{"rules": {"unused-files": "warn"}}"#,
3092        );
3093        std::fs::write(
3094            dir.path().join(".fallowrc.json"),
3095            r#"{"extends": "npm:fallow-config-acme"}"#,
3096        )
3097        .unwrap();
3098
3099        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3100        assert_eq!(config.rules.unused_files, Severity::Warn);
3101    }
3102
3103    #[test]
3104    fn extends_npm_scoped_package() {
3105        let dir = test_dir("npm-scoped");
3106        create_npm_package(
3107            dir.path(),
3108            "@company/fallow-config",
3109            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
3110        );
3111        std::fs::write(
3112            dir.path().join(".fallowrc.json"),
3113            r#"{"extends": "npm:@company/fallow-config"}"#,
3114        )
3115        .unwrap();
3116
3117        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3118        assert_eq!(config.rules.unused_exports, Severity::Off);
3119        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
3120    }
3121
3122    #[test]
3123    fn extends_npm_with_subpath() {
3124        let dir = test_dir("npm-subpath");
3125        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
3126        std::fs::create_dir_all(&pkg_dir).unwrap();
3127        std::fs::write(
3128            pkg_dir.join("strict.json"),
3129            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
3130        )
3131        .unwrap();
3132
3133        std::fs::write(
3134            dir.path().join(".fallowrc.json"),
3135            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
3136        )
3137        .unwrap();
3138
3139        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3140        assert_eq!(config.rules.unused_files, Severity::Error);
3141        assert_eq!(config.rules.unused_exports, Severity::Error);
3142    }
3143
3144    #[test]
3145    fn extends_npm_package_json_main() {
3146        let dir = test_dir("npm-main");
3147        create_npm_package_with_main(
3148            dir.path(),
3149            "fallow-config-acme",
3150            "config.json",
3151            r#"{"rules": {"unused-types": "off"}}"#,
3152        );
3153        std::fs::write(
3154            dir.path().join(".fallowrc.json"),
3155            r#"{"extends": "npm:fallow-config-acme"}"#,
3156        )
3157        .unwrap();
3158
3159        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3160        assert_eq!(config.rules.unused_types, Severity::Off);
3161    }
3162
3163    #[test]
3164    fn extends_npm_package_json_exports_string() {
3165        let dir = test_dir("npm-exports-str");
3166        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
3167        std::fs::create_dir_all(&pkg_dir).unwrap();
3168        std::fs::write(
3169            pkg_dir.join("package.json"),
3170            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
3171        )
3172        .unwrap();
3173        std::fs::write(
3174            pkg_dir.join("base.json"),
3175            r#"{"rules": {"circular-dependencies": "warn"}}"#,
3176        )
3177        .unwrap();
3178
3179        std::fs::write(
3180            dir.path().join(".fallowrc.json"),
3181            r#"{"extends": "npm:fallow-config-co"}"#,
3182        )
3183        .unwrap();
3184
3185        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3186        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
3187    }
3188
3189    #[test]
3190    fn extends_npm_package_json_exports_object() {
3191        let dir = test_dir("npm-exports-obj");
3192        let pkg_dir = dir.path().join("node_modules/@co/cfg");
3193        std::fs::create_dir_all(&pkg_dir).unwrap();
3194        std::fs::write(
3195            pkg_dir.join("package.json"),
3196            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
3197        )
3198        .unwrap();
3199        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
3200
3201        std::fs::write(
3202            dir.path().join(".fallowrc.json"),
3203            r#"{"extends": "npm:@co/cfg"}"#,
3204        )
3205        .unwrap();
3206
3207        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3208        assert_eq!(config.entry, vec!["src/app.ts"]);
3209    }
3210
3211    #[test]
3212    fn extends_npm_exports_takes_priority_over_main() {
3213        let dir = test_dir("npm-exports-prio");
3214        let pkg_dir = dir.path().join("node_modules/my-config");
3215        std::fs::create_dir_all(&pkg_dir).unwrap();
3216        std::fs::write(
3217            pkg_dir.join("package.json"),
3218            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
3219        )
3220        .unwrap();
3221        std::fs::write(
3222            pkg_dir.join("old.json"),
3223            r#"{"rules": {"unused-files": "off"}}"#,
3224        )
3225        .unwrap();
3226        std::fs::write(
3227            pkg_dir.join("new.json"),
3228            r#"{"rules": {"unused-files": "warn"}}"#,
3229        )
3230        .unwrap();
3231
3232        std::fs::write(
3233            dir.path().join(".fallowrc.json"),
3234            r#"{"extends": "npm:my-config"}"#,
3235        )
3236        .unwrap();
3237
3238        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3239        assert_eq!(config.rules.unused_files, Severity::Warn);
3240    }
3241
3242    #[test]
3243    fn extends_npm_walk_up_directories() {
3244        let dir = test_dir("npm-walkup");
3245        create_npm_package(
3246            dir.path(),
3247            "shared-config",
3248            r#"{"rules": {"unused-files": "warn"}}"#,
3249        );
3250        let sub = dir.path().join("packages/app");
3251        std::fs::create_dir_all(&sub).unwrap();
3252        std::fs::write(
3253            sub.join(".fallowrc.json"),
3254            r#"{"extends": "npm:shared-config"}"#,
3255        )
3256        .unwrap();
3257
3258        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
3259        assert_eq!(config.rules.unused_files, Severity::Warn);
3260    }
3261
3262    #[test]
3263    fn extends_npm_overlay_overrides_base() {
3264        let dir = test_dir("npm-overlay");
3265        create_npm_package(
3266            dir.path(),
3267            "@company/base",
3268            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
3269        );
3270        std::fs::write(
3271            dir.path().join(".fallowrc.json"),
3272            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
3273        )
3274        .unwrap();
3275
3276        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3277        assert_eq!(config.rules.unused_files, Severity::Error);
3278        assert_eq!(config.rules.unused_exports, Severity::Off);
3279        assert_eq!(config.entry, vec!["src/app.ts"]);
3280    }
3281
3282    #[test]
3283    fn extends_npm_chained_with_relative() {
3284        let dir = test_dir("npm-chained");
3285        let pkg_dir = dir.path().join("node_modules/my-config");
3286        std::fs::create_dir_all(&pkg_dir).unwrap();
3287        std::fs::write(
3288            pkg_dir.join("base.json"),
3289            r#"{"rules": {"unused-files": "warn"}}"#,
3290        )
3291        .unwrap();
3292        std::fs::write(
3293            pkg_dir.join(".fallowrc.json"),
3294            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
3295        )
3296        .unwrap();
3297
3298        std::fs::write(
3299            dir.path().join(".fallowrc.json"),
3300            r#"{"extends": "npm:my-config"}"#,
3301        )
3302        .unwrap();
3303
3304        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3305        assert_eq!(config.rules.unused_files, Severity::Warn);
3306        assert_eq!(config.rules.unused_exports, Severity::Off);
3307    }
3308
3309    #[test]
3310    fn extends_npm_mixed_with_relative_paths() {
3311        let dir = test_dir("npm-mixed");
3312        create_npm_package(
3313            dir.path(),
3314            "shared-base",
3315            r#"{"rules": {"unused-files": "off"}}"#,
3316        );
3317        std::fs::write(
3318            dir.path().join("local-overrides.json"),
3319            r#"{"rules": {"unused-files": "warn"}}"#,
3320        )
3321        .unwrap();
3322        std::fs::write(
3323            dir.path().join(".fallowrc.json"),
3324            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
3325        )
3326        .unwrap();
3327
3328        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3329        assert_eq!(config.rules.unused_files, Severity::Warn);
3330    }
3331
3332    #[test]
3333    fn extends_npm_missing_package_errors() {
3334        let dir = test_dir("npm-missing");
3335        std::fs::write(
3336            dir.path().join(".fallowrc.json"),
3337            r#"{"extends": "npm:nonexistent-package"}"#,
3338        )
3339        .unwrap();
3340
3341        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3342        assert!(result.is_err());
3343        let err_msg = format!("{}", result.unwrap_err());
3344        assert!(
3345            err_msg.contains("not found"),
3346            "Expected 'not found' error, got: {err_msg}"
3347        );
3348        assert!(
3349            err_msg.contains("nonexistent-package"),
3350            "Expected package name in error, got: {err_msg}"
3351        );
3352        assert!(
3353            err_msg.contains("install it"),
3354            "Expected install hint in error, got: {err_msg}"
3355        );
3356    }
3357
3358    #[test]
3359    fn extends_npm_no_config_in_package_errors() {
3360        let dir = test_dir("npm-no-config");
3361        let pkg_dir = dir.path().join("node_modules/empty-pkg");
3362        std::fs::create_dir_all(&pkg_dir).unwrap();
3363        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
3364
3365        std::fs::write(
3366            dir.path().join(".fallowrc.json"),
3367            r#"{"extends": "npm:empty-pkg"}"#,
3368        )
3369        .unwrap();
3370
3371        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3372        assert!(result.is_err());
3373        let err_msg = format!("{}", result.unwrap_err());
3374        assert!(
3375            err_msg.contains("No fallow config found"),
3376            "Expected 'No fallow config found' error, got: {err_msg}"
3377        );
3378    }
3379
3380    #[test]
3381    fn extends_npm_missing_subpath_errors() {
3382        let dir = test_dir("npm-missing-sub");
3383        let pkg_dir = dir.path().join("node_modules/@co/config");
3384        std::fs::create_dir_all(&pkg_dir).unwrap();
3385
3386        std::fs::write(
3387            dir.path().join(".fallowrc.json"),
3388            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
3389        )
3390        .unwrap();
3391
3392        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3393        assert!(result.is_err());
3394        let err_msg = format!("{}", result.unwrap_err());
3395        assert!(
3396            err_msg.contains("nonexistent.json"),
3397            "Expected subpath in error, got: {err_msg}"
3398        );
3399    }
3400
3401    #[test]
3402    fn extends_npm_empty_specifier_errors() {
3403        let dir = test_dir("npm-empty");
3404        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
3405
3406        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3407        assert!(result.is_err());
3408        let err_msg = format!("{}", result.unwrap_err());
3409        assert!(
3410            err_msg.contains("Empty npm specifier"),
3411            "Expected 'Empty npm specifier' error, got: {err_msg}"
3412        );
3413    }
3414
3415    #[test]
3416    fn extends_npm_space_after_colon_trimmed() {
3417        let dir = test_dir("npm-space");
3418        create_npm_package(
3419            dir.path(),
3420            "fallow-config-acme",
3421            r#"{"rules": {"unused-files": "warn"}}"#,
3422        );
3423        std::fs::write(
3424            dir.path().join(".fallowrc.json"),
3425            r#"{"extends": "npm: fallow-config-acme"}"#,
3426        )
3427        .unwrap();
3428
3429        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3430        assert_eq!(config.rules.unused_files, Severity::Warn);
3431    }
3432
3433    #[test]
3434    fn extends_npm_exports_node_condition() {
3435        let dir = test_dir("npm-node-cond");
3436        let pkg_dir = dir.path().join("node_modules/node-config");
3437        std::fs::create_dir_all(&pkg_dir).unwrap();
3438        std::fs::write(
3439            pkg_dir.join("package.json"),
3440            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
3441        )
3442        .unwrap();
3443        std::fs::write(
3444            pkg_dir.join("node.json"),
3445            r#"{"rules": {"unused-files": "off"}}"#,
3446        )
3447        .unwrap();
3448
3449        std::fs::write(
3450            dir.path().join(".fallowrc.json"),
3451            r#"{"extends": "npm:node-config"}"#,
3452        )
3453        .unwrap();
3454
3455        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
3456        assert_eq!(config.rules.unused_files, Severity::Off);
3457    }
3458
3459    #[test]
3460    fn parse_npm_specifier_unscoped() {
3461        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
3462    }
3463
3464    #[test]
3465    fn parse_npm_specifier_unscoped_with_subpath() {
3466        assert_eq!(
3467            parse_npm_specifier("my-config/strict.json"),
3468            ("my-config", Some("strict.json"))
3469        );
3470    }
3471
3472    #[test]
3473    fn parse_npm_specifier_scoped() {
3474        assert_eq!(
3475            parse_npm_specifier("@company/fallow-config"),
3476            ("@company/fallow-config", None)
3477        );
3478    }
3479
3480    #[test]
3481    fn parse_npm_specifier_scoped_with_subpath() {
3482        assert_eq!(
3483            parse_npm_specifier("@company/fallow-config/strict.json"),
3484            ("@company/fallow-config", Some("strict.json"))
3485        );
3486    }
3487
3488    #[test]
3489    fn parse_npm_specifier_scoped_with_nested_subpath() {
3490        assert_eq!(
3491            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
3492            ("@company/fallow-config", Some("presets/strict.json"))
3493        );
3494    }
3495
3496    #[test]
3497    fn extends_npm_subpath_traversal_rejected() {
3498        let dir = test_dir("npm-traversal-sub");
3499        let pkg_dir = dir.path().join("node_modules/evil-pkg");
3500        std::fs::create_dir_all(&pkg_dir).unwrap();
3501        std::fs::write(
3502            dir.path().join("secret.json"),
3503            r#"{"entry": ["stolen.ts"]}"#,
3504        )
3505        .unwrap();
3506
3507        std::fs::write(
3508            dir.path().join(".fallowrc.json"),
3509            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
3510        )
3511        .unwrap();
3512
3513        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3514        assert!(result.is_err());
3515        let err_msg = format!("{}", result.unwrap_err());
3516        assert!(
3517            err_msg.contains("traversal") || err_msg.contains("not found"),
3518            "Expected traversal or not-found error, got: {err_msg}"
3519        );
3520    }
3521
3522    #[test]
3523    fn extends_npm_dotdot_package_name_rejected() {
3524        let dir = test_dir("npm-dotdot-name");
3525        std::fs::write(
3526            dir.path().join(".fallowrc.json"),
3527            r#"{"extends": "npm:../relative"}"#,
3528        )
3529        .unwrap();
3530
3531        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3532        assert!(result.is_err());
3533        let err_msg = format!("{}", result.unwrap_err());
3534        assert!(
3535            err_msg.contains("path traversal"),
3536            "Expected 'path traversal' error, got: {err_msg}"
3537        );
3538    }
3539
3540    #[test]
3541    fn extends_npm_scoped_without_name_rejected() {
3542        let dir = test_dir("npm-scope-only");
3543        std::fs::write(
3544            dir.path().join(".fallowrc.json"),
3545            r#"{"extends": "npm:@scope"}"#,
3546        )
3547        .unwrap();
3548
3549        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3550        assert!(result.is_err());
3551        let err_msg = format!("{}", result.unwrap_err());
3552        assert!(
3553            err_msg.contains("@scope/name"),
3554            "Expected scoped name format error, got: {err_msg}"
3555        );
3556    }
3557
3558    #[test]
3559    fn extends_npm_malformed_package_json_errors() {
3560        let dir = test_dir("npm-bad-pkgjson");
3561        let pkg_dir = dir.path().join("node_modules/bad-pkg");
3562        std::fs::create_dir_all(&pkg_dir).unwrap();
3563        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
3564
3565        std::fs::write(
3566            dir.path().join(".fallowrc.json"),
3567            r#"{"extends": "npm:bad-pkg"}"#,
3568        )
3569        .unwrap();
3570
3571        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3572        assert!(result.is_err());
3573        let err_msg = format!("{}", result.unwrap_err());
3574        assert!(
3575            err_msg.contains("Failed to parse"),
3576            "Expected parse error, got: {err_msg}"
3577        );
3578    }
3579
3580    #[test]
3581    fn extends_npm_exports_traversal_rejected() {
3582        let dir = test_dir("npm-exports-escape");
3583        let pkg_dir = dir.path().join("node_modules/evil-exports");
3584        std::fs::create_dir_all(&pkg_dir).unwrap();
3585        std::fs::write(
3586            pkg_dir.join("package.json"),
3587            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
3588        )
3589        .unwrap();
3590        std::fs::write(
3591            dir.path().join("secret.json"),
3592            r#"{"entry": ["stolen.ts"]}"#,
3593        )
3594        .unwrap();
3595
3596        std::fs::write(
3597            dir.path().join(".fallowrc.json"),
3598            r#"{"extends": "npm:evil-exports"}"#,
3599        )
3600        .unwrap();
3601
3602        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
3603        assert!(result.is_err());
3604        let err_msg = format!("{}", result.unwrap_err());
3605        assert!(
3606            err_msg.contains("traversal"),
3607            "Expected traversal error, got: {err_msg}"
3608        );
3609    }
3610
3611    #[test]
3612    fn deep_merge_scalar_overlay_replaces_base() {
3613        let mut base = serde_json::json!("hello");
3614        deep_merge_json(&mut base, serde_json::json!("world"));
3615        assert_eq!(base, serde_json::json!("world"));
3616    }
3617
3618    #[test]
3619    fn deep_merge_array_overlay_replaces_base() {
3620        let mut base = serde_json::json!(["a", "b"]);
3621        deep_merge_json(&mut base, serde_json::json!(["c"]));
3622        assert_eq!(base, serde_json::json!(["c"]));
3623    }
3624
3625    #[test]
3626    fn deep_merge_nested_object_merge() {
3627        let mut base = serde_json::json!({
3628            "level1": {
3629                "level2": {
3630                    "a": 1,
3631                    "b": 2
3632                }
3633            }
3634        });
3635        let overlay = serde_json::json!({
3636            "level1": {
3637                "level2": {
3638                    "b": 99,
3639                    "c": 3
3640                }
3641            }
3642        });
3643        deep_merge_json(&mut base, overlay);
3644        assert_eq!(base["level1"]["level2"]["a"], 1);
3645        assert_eq!(base["level1"]["level2"]["b"], 99);
3646        assert_eq!(base["level1"]["level2"]["c"], 3);
3647    }
3648
3649    #[test]
3650    fn deep_merge_overlay_adds_new_fields() {
3651        let mut base = serde_json::json!({"existing": true});
3652        let overlay = serde_json::json!({"new_field": "added", "another": 42});
3653        deep_merge_json(&mut base, overlay);
3654        assert_eq!(base["existing"], true);
3655        assert_eq!(base["new_field"], "added");
3656        assert_eq!(base["another"], 42);
3657    }
3658
3659    #[test]
3660    fn deep_merge_null_overlay_replaces_object() {
3661        let mut base = serde_json::json!({"key": "value"});
3662        deep_merge_json(&mut base, serde_json::json!(null));
3663        assert_eq!(base, serde_json::json!(null));
3664    }
3665
3666    #[test]
3667    fn deep_merge_empty_object_overlay_preserves_base() {
3668        let mut base = serde_json::json!({"a": 1, "b": 2});
3669        deep_merge_json(&mut base, serde_json::json!({}));
3670        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
3671    }
3672
3673    #[test]
3674    fn rules_severity_error_warn_off_from_json() {
3675        let json_str = r#"{
3676            "rules": {
3677                "unused-files": "error",
3678                "unused-exports": "warn",
3679                "unused-types": "off"
3680            }
3681        }"#;
3682        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3683        assert_eq!(config.rules.unused_files, Severity::Error);
3684        assert_eq!(config.rules.unused_exports, Severity::Warn);
3685        assert_eq!(config.rules.unused_types, Severity::Off);
3686    }
3687
3688    #[test]
3689    fn rules_omitted_default_to_error() {
3690        let json_str = r#"{
3691            "rules": {
3692                "unused-files": "warn"
3693            }
3694        }"#;
3695        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
3696        assert_eq!(config.rules.unused_files, Severity::Warn);
3697        assert_eq!(config.rules.unused_exports, Severity::Error);
3698        assert_eq!(config.rules.unused_types, Severity::Error);
3699        assert_eq!(config.rules.unused_dependencies, Severity::Error);
3700        assert_eq!(config.rules.unresolved_imports, Severity::Error);
3701        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
3702        assert_eq!(config.rules.duplicate_exports, Severity::Error);
3703        assert_eq!(config.rules.circular_dependencies, Severity::Error);
3704        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
3705    }
3706
3707    #[test]
3708    fn find_and_load_returns_none_when_no_config() {
3709        let dir = test_dir("find-none");
3710        std::fs::create_dir(dir.path().join(".git")).unwrap();
3711
3712        let result = FallowConfig::find_and_load(dir.path()).unwrap();
3713        assert!(result.is_none());
3714    }
3715
3716    #[test]
3717    fn find_and_load_finds_fallowrc_json() {
3718        let dir = test_dir("find-json");
3719        std::fs::create_dir(dir.path().join(".git")).unwrap();
3720        std::fs::write(
3721            dir.path().join(".fallowrc.json"),
3722            r#"{"entry": ["src/main.ts"]}"#,
3723        )
3724        .unwrap();
3725
3726        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3727        assert_eq!(config.entry, vec!["src/main.ts"]);
3728        assert!(path.ends_with(".fallowrc.json"));
3729    }
3730
3731    #[test]
3732    fn find_and_load_finds_fallowrc_jsonc() {
3733        let dir = test_dir("find-jsonc");
3734        std::fs::create_dir(dir.path().join(".git")).unwrap();
3735        std::fs::write(
3736            dir.path().join(".fallowrc.jsonc"),
3737            r#"{
3738                "entry": ["src/main.ts"]
3739            }"#,
3740        )
3741        .unwrap();
3742
3743        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3744        assert_eq!(config.entry, vec!["src/main.ts"]);
3745        assert!(path.ends_with(".fallowrc.jsonc"));
3746    }
3747
3748    #[test]
3749    fn find_and_load_prefers_fallowrc_json_over_jsonc() {
3750        let dir = test_dir("find-json-vs-jsonc");
3751        std::fs::create_dir(dir.path().join(".git")).unwrap();
3752        std::fs::write(
3753            dir.path().join(".fallowrc.json"),
3754            r#"{"entry": ["from-json.ts"]}"#,
3755        )
3756        .unwrap();
3757        std::fs::write(
3758            dir.path().join(".fallowrc.jsonc"),
3759            r#"{"entry": ["from-jsonc.ts"]}"#,
3760        )
3761        .unwrap();
3762
3763        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3764        assert_eq!(config.entry, vec!["from-json.ts"]);
3765        assert!(path.ends_with(".fallowrc.json"));
3766    }
3767
3768    #[test]
3769    fn find_and_load_prefers_fallowrc_json_over_toml() {
3770        let dir = test_dir("find-priority");
3771        std::fs::create_dir(dir.path().join(".git")).unwrap();
3772        std::fs::write(
3773            dir.path().join(".fallowrc.json"),
3774            r#"{"entry": ["from-json.ts"]}"#,
3775        )
3776        .unwrap();
3777        std::fs::write(
3778            dir.path().join("fallow.toml"),
3779            "entry = [\"from-toml.ts\"]\n",
3780        )
3781        .unwrap();
3782
3783        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3784        assert_eq!(config.entry, vec!["from-json.ts"]);
3785        assert!(path.ends_with(".fallowrc.json"));
3786    }
3787
3788    #[test]
3789    fn shadowed_config_names_empty_when_single_config() {
3790        let dir = test_dir("shadow-single");
3791        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3792        assert!(shadowed_config_names(dir.path(), 0).is_empty());
3793    }
3794
3795    #[test]
3796    fn shadowed_config_names_reports_lower_precedence_toml() {
3797        let dir = test_dir("shadow-json-toml");
3798        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3799        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3800        assert_eq!(shadowed_config_names(dir.path(), 0), vec!["fallow.toml"]);
3801    }
3802
3803    #[test]
3804    fn shadowed_config_names_reports_jsonc_sibling() {
3805        let dir = test_dir("shadow-json-jsonc");
3806        std::fs::write(dir.path().join(".fallowrc.json"), "").unwrap();
3807        std::fs::write(dir.path().join(".fallowrc.jsonc"), "").unwrap();
3808        assert_eq!(
3809            shadowed_config_names(dir.path(), 0),
3810            vec![".fallowrc.jsonc"]
3811        );
3812    }
3813
3814    #[test]
3815    fn shadowed_config_names_reports_all_lower_when_four_coexist() {
3816        let dir = test_dir("shadow-all-four");
3817        for name in CONFIG_NAMES {
3818            std::fs::write(dir.path().join(name), "").unwrap();
3819        }
3820        assert_eq!(
3821            shadowed_config_names(dir.path(), 0),
3822            vec![".fallowrc.jsonc", "fallow.toml", ".fallow.toml"],
3823        );
3824    }
3825
3826    #[test]
3827    fn shadowed_config_names_scoped_to_indices_after_winner() {
3828        let dir = test_dir("shadow-toml-dottoml");
3829        std::fs::write(dir.path().join("fallow.toml"), "").unwrap();
3830        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
3831        assert_eq!(shadowed_config_names(dir.path(), 2), vec![".fallow.toml"]);
3832    }
3833
3834    #[test]
3835    fn find_and_load_warns_when_configs_coexist() {
3836        let dir = test_dir("coexist-warn");
3837        std::fs::create_dir(dir.path().join(".git")).unwrap();
3838        std::fs::write(
3839            dir.path().join(".fallowrc.json"),
3840            r#"{"entry": ["from-json.ts"]}"#,
3841        )
3842        .unwrap();
3843        std::fs::write(
3844            dir.path().join("fallow.toml"),
3845            "entry = [\"from-toml.ts\"]\n",
3846        )
3847        .unwrap();
3848
3849        let (result, captured) =
3850            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3851
3852        let (config, path) = result.unwrap().unwrap();
3853        assert_eq!(config.entry, vec!["from-json.ts"]);
3854        assert!(path.ends_with(".fallowrc.json"));
3855
3856        assert_eq!(captured.len(), 1);
3857        let (chosen, shadowed) = &captured[0];
3858        assert_eq!(chosen, ".fallowrc.json");
3859        assert_eq!(shadowed, &vec!["fallow.toml".to_owned()]);
3860    }
3861
3862    #[test]
3863    fn find_and_load_does_not_warn_for_single_config() {
3864        let dir = test_dir("coexist-none");
3865        std::fs::create_dir(dir.path().join(".git")).unwrap();
3866        std::fs::write(
3867            dir.path().join(".fallowrc.json"),
3868            r#"{"entry": ["only.ts"]}"#,
3869        )
3870        .unwrap();
3871
3872        let (result, captured) =
3873            capture_coexisting_config_warnings(|| FallowConfig::find_and_load(dir.path()));
3874        assert!(result.unwrap().is_some());
3875        assert!(captured.is_empty());
3876    }
3877
3878    #[test]
3879    fn find_and_load_warns_per_directory_independently() {
3880        let make = |name: &str| {
3881            let dir = test_dir(name);
3882            std::fs::create_dir(dir.path().join(".git")).unwrap();
3883            std::fs::write(dir.path().join(".fallowrc.json"), r#"{"entry": ["a.ts"]}"#).unwrap();
3884            std::fs::write(dir.path().join("fallow.toml"), "entry = [\"a.ts\"]\n").unwrap();
3885            dir
3886        };
3887        let first = make("coexist-dir-a");
3888        let second = make("coexist-dir-b");
3889
3890        let ((), captured) = capture_coexisting_config_warnings(|| {
3891            FallowConfig::find_and_load(first.path()).unwrap();
3892            FallowConfig::find_and_load(second.path()).unwrap();
3893        });
3894
3895        assert_eq!(captured.len(), 2);
3896        assert!(captured.iter().all(|(chosen, shadowed)| {
3897            chosen == ".fallowrc.json" && shadowed == &vec!["fallow.toml".to_owned()]
3898        }));
3899    }
3900
3901    #[test]
3902    fn explicit_load_does_not_warn_about_coexisting_configs() {
3903        let dir = test_dir("coexist-explicit");
3904        std::fs::write(
3905            dir.path().join(".fallowrc.json"),
3906            r#"{"entry": ["chosen.ts"]}"#,
3907        )
3908        .unwrap();
3909        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"other.ts\"]\n").unwrap();
3910
3911        let chosen = dir.path().join("fallow.toml");
3912        let (result, captured) = capture_coexisting_config_warnings(|| FallowConfig::load(&chosen));
3913        assert!(result.is_ok());
3914        assert!(captured.is_empty());
3915    }
3916
3917    #[test]
3918    fn find_and_load_finds_fallow_toml() {
3919        let dir = test_dir("find-toml");
3920        std::fs::create_dir(dir.path().join(".git")).unwrap();
3921        std::fs::write(
3922            dir.path().join("fallow.toml"),
3923            "entry = [\"src/index.ts\"]\n",
3924        )
3925        .unwrap();
3926
3927        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
3928        assert_eq!(config.entry, vec!["src/index.ts"]);
3929    }
3930
3931    #[test]
3932    fn find_and_load_stops_at_git_dir() {
3933        let dir = test_dir("find-git-stop");
3934        let sub = dir.path().join("sub");
3935        std::fs::create_dir(&sub).unwrap();
3936        std::fs::create_dir(dir.path().join(".git")).unwrap();
3937        let result = FallowConfig::find_and_load(&sub).unwrap();
3938        assert!(result.is_none());
3939    }
3940
3941    #[test]
3942    fn find_and_load_walks_past_package_json_in_monorepo() {
3943        let dir = test_dir("find-monorepo");
3944        std::fs::create_dir(dir.path().join(".git")).unwrap();
3945        std::fs::write(
3946            dir.path().join(".fallowrc.json"),
3947            r#"{"entry": ["src/index.ts"]}"#,
3948        )
3949        .unwrap();
3950
3951        let sub = dir.path().join("packages").join("app");
3952        std::fs::create_dir_all(&sub).unwrap();
3953        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3954
3955        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3956        assert_eq!(config.entry, vec!["src/index.ts"]);
3957        assert_eq!(path, dir.path().join(".fallowrc.json"));
3958    }
3959
3960    #[test]
3961    fn find_and_load_sub_package_config_wins_over_root() {
3962        let dir = test_dir("find-monorepo-override");
3963        std::fs::create_dir(dir.path().join(".git")).unwrap();
3964        std::fs::write(
3965            dir.path().join(".fallowrc.json"),
3966            r#"{"entry": ["src/root.ts"]}"#,
3967        )
3968        .unwrap();
3969
3970        let sub = dir.path().join("packages").join("app");
3971        std::fs::create_dir_all(&sub).unwrap();
3972        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
3973        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
3974
3975        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
3976        assert_eq!(config.entry, vec!["src/sub.ts"]);
3977        assert_eq!(path, sub.join(".fallowrc.json"));
3978    }
3979
3980    #[test]
3981    fn find_and_load_stops_at_git_file_submodule() {
3982        let dir = test_dir("find-git-file");
3983        std::fs::create_dir(dir.path().join(".git")).unwrap();
3984        std::fs::write(
3985            dir.path().join(".fallowrc.json"),
3986            r#"{"entry": ["src/parent.ts"]}"#,
3987        )
3988        .unwrap();
3989
3990        let submodule = dir.path().join("vendor").join("lib");
3991        std::fs::create_dir_all(&submodule).unwrap();
3992        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
3993
3994        let result = FallowConfig::find_and_load(&submodule).unwrap();
3995        assert!(
3996            result.is_none(),
3997            "submodule boundary should stop config walk",
3998        );
3999    }
4000
4001    #[test]
4002    fn find_and_load_stops_at_hg_dir() {
4003        let dir = test_dir("find-hg-stop");
4004        let sub = dir.path().join("sub");
4005        std::fs::create_dir(&sub).unwrap();
4006        std::fs::create_dir(dir.path().join(".hg")).unwrap();
4007
4008        let result = FallowConfig::find_and_load(&sub).unwrap();
4009        assert!(result.is_none());
4010    }
4011
4012    #[test]
4013    fn find_and_load_returns_error_for_invalid_config() {
4014        let dir = test_dir("find-invalid");
4015        std::fs::create_dir(dir.path().join(".git")).unwrap();
4016        std::fs::write(
4017            dir.path().join(".fallowrc.json"),
4018            r"{ this is not valid json }",
4019        )
4020        .unwrap();
4021
4022        let result = FallowConfig::find_and_load(dir.path());
4023        assert!(result.is_err());
4024    }
4025
4026    #[test]
4027    fn load_toml_config_file() {
4028        let dir = test_dir("toml-config");
4029        let config_path = dir.path().join("fallow.toml");
4030        std::fs::write(
4031            &config_path,
4032            r#"
4033entry = ["src/index.ts"]
4034ignorePatterns = ["dist/**"]
4035
4036[rules]
4037unused-files = "warn"
4038
4039[duplicates]
4040minTokens = 100
4041"#,
4042        )
4043        .unwrap();
4044
4045        let config = FallowConfig::load(&config_path).unwrap();
4046        assert_eq!(config.entry, vec!["src/index.ts"]);
4047        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
4048        assert_eq!(config.rules.unused_files, Severity::Warn);
4049        assert_eq!(config.duplicates.min_tokens, 100);
4050    }
4051
4052    #[test]
4053    fn load_toml_config_file_with_health_threshold_override() {
4054        let dir = test_dir("toml-health-threshold-override");
4055        let config_path = dir.path().join("fallow.toml");
4056        std::fs::write(
4057            &config_path,
4058            r#"
4059[health]
4060thresholdOverrides = [
4061  { files = ["src/legacy.ts"], functions = ["legacyFlow"], maxCyclomatic = 30, maxCognitive = 25, maxCrap = 80.5, reason = "legacy migration" }
4062]
4063"#,
4064        )
4065        .unwrap();
4066
4067        let config = FallowConfig::load(&config_path).unwrap();
4068        let override_config = &config.health.threshold_overrides[0];
4069        assert_eq!(override_config.files, vec!["src/legacy.ts"]);
4070        assert_eq!(override_config.functions, vec!["legacyFlow"]);
4071        assert_eq!(override_config.max_cyclomatic, Some(30));
4072        assert_eq!(override_config.max_cognitive, Some(25));
4073        assert_eq!(override_config.max_crap, Some(80.5));
4074        assert_eq!(override_config.reason.as_deref(), Some("legacy migration"));
4075    }
4076
4077    #[test]
4078    fn extends_absolute_path_rejected() {
4079        let dir = test_dir("extends-absolute");
4080
4081        #[cfg(unix)]
4082        let abs_path = "/absolute/path/config.json";
4083        #[cfg(windows)]
4084        let abs_path = "C:\\absolute\\path\\config.json";
4085
4086        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
4087        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
4088
4089        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4090        assert!(result.is_err());
4091        let err_msg = format!("{}", result.unwrap_err());
4092        assert!(
4093            err_msg.contains("must be relative"),
4094            "Expected 'must be relative' error, got: {err_msg}"
4095        );
4096    }
4097
4098    #[test]
4099    fn extends_windows_drive_absolute_path_rejected_on_any_host() {
4100        let dir = test_dir("extends-windows-absolute");
4101
4102        std::fs::write(
4103            dir.path().join(".fallowrc.json"),
4104            r#"{"extends": ["C:\\absolute\\path\\config.json"]}"#,
4105        )
4106        .unwrap();
4107
4108        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4109        assert!(result.is_err());
4110        let err_msg = format!("{}", result.unwrap_err());
4111        assert!(
4112            err_msg.contains("must be relative"),
4113            "Expected 'must be relative' error, got: {err_msg}"
4114        );
4115    }
4116
4117    #[cfg(windows)]
4118    #[test]
4119    fn extends_posix_rooted_absolute_path_rejected_on_windows() {
4120        let dir = test_dir("extends-posix-rooted-absolute");
4121
4122        std::fs::write(
4123            dir.path().join(".fallowrc.json"),
4124            r#"{"extends": ["/absolute/path/config.json"]}"#,
4125        )
4126        .unwrap();
4127
4128        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4129        assert!(result.is_err());
4130        let err_msg = format!("{}", result.unwrap_err());
4131        assert!(
4132            err_msg.contains("must be relative"),
4133            "Expected 'must be relative' error, got: {err_msg}"
4134        );
4135    }
4136
4137    #[test]
4138    fn resolve_production_mode_disables_dev_deps() {
4139        let config = FallowConfig {
4140            production: true.into(),
4141            ..Default::default()
4142        };
4143        let resolved = config.resolve(
4144            PathBuf::from("/tmp/test"),
4145            OutputFormat::Human,
4146            4,
4147            false,
4148            true,
4149            None,
4150        );
4151        assert!(resolved.production);
4152        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
4153        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
4154        assert_eq!(resolved.rules.unused_files, Severity::Error);
4155        assert_eq!(resolved.rules.unused_exports, Severity::Error);
4156    }
4157
4158    #[test]
4159    fn include_entry_exports_deserializes_from_camelcase_json() {
4160        let json = r#"{ "includeEntryExports": true }"#;
4161        let config: FallowConfig = serde_json::from_str(json).unwrap();
4162        assert!(config.include_entry_exports);
4163    }
4164
4165    #[test]
4166    fn include_entry_exports_deserializes_from_camelcase_toml() {
4167        let toml_str = "includeEntryExports = true\n";
4168        let config: FallowConfig = toml::from_str(toml_str).unwrap();
4169        assert!(config.include_entry_exports);
4170    }
4171
4172    #[test]
4173    fn include_entry_exports_default_is_false() {
4174        let config: FallowConfig = serde_json::from_str("{}").unwrap();
4175        assert!(!config.include_entry_exports);
4176    }
4177
4178    #[test]
4179    fn include_entry_exports_propagates_through_resolve() {
4180        let config = FallowConfig {
4181            include_entry_exports: true,
4182            auto_imports: false,
4183            cache: CacheConfig::default(),
4184            ..Default::default()
4185        };
4186        let resolved = config.resolve(
4187            PathBuf::from("/tmp/test"),
4188            OutputFormat::Human,
4189            1,
4190            true,
4191            true,
4192            None,
4193        );
4194        assert!(resolved.include_entry_exports);
4195    }
4196
4197    #[test]
4198    fn fail_on_parse_error_reads_from_json_and_toml_and_reaches_the_resolved_config() {
4199        let json: FallowConfig = serde_json::from_str(r#"{"failOnParseError": true}"#).unwrap();
4200        assert!(json.fail_on_parse_error);
4201        let toml: FallowConfig = toml::from_str("failOnParseError = true").unwrap();
4202        assert!(toml.fail_on_parse_error);
4203        let unset: FallowConfig = serde_json::from_str("{}").unwrap();
4204        assert!(
4205            !unset.fail_on_parse_error,
4206            "the gate is never armed by default"
4207        );
4208
4209        let resolved = json.resolve(
4210            PathBuf::from("/tmp/test"),
4211            OutputFormat::Human,
4212            1,
4213            true,
4214            true,
4215            None,
4216        );
4217        assert!(resolved.fail_on_parse_error);
4218    }
4219
4220    #[test]
4221    fn config_format_defaults_to_toml_for_unknown() {
4222        assert!(matches!(
4223            ConfigFormat::from_path(Path::new("config.yaml")),
4224            ConfigFormat::Toml
4225        ));
4226        assert!(matches!(
4227            ConfigFormat::from_path(Path::new("config")),
4228            ConfigFormat::Toml
4229        ));
4230    }
4231
4232    #[test]
4233    fn deep_merge_object_over_scalar_replaces() {
4234        let mut base = serde_json::json!("just a string");
4235        let overlay = serde_json::json!({"key": "value"});
4236        deep_merge_json(&mut base, overlay);
4237        assert_eq!(base, serde_json::json!({"key": "value"}));
4238    }
4239
4240    #[test]
4241    fn deep_merge_scalar_over_object_replaces() {
4242        let mut base = serde_json::json!({"key": "value"});
4243        let overlay = serde_json::json!(42);
4244        deep_merge_json(&mut base, overlay);
4245        assert_eq!(base, serde_json::json!(42));
4246    }
4247
4248    #[test]
4249    fn extends_non_string_non_array_fails_loud() {
4250        let dir = test_dir("extends-numeric");
4251        std::fs::write(
4252            dir.path().join(".fallowrc.json"),
4253            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
4254        )
4255        .unwrap();
4256
4257        let err = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap_err();
4258        assert!(
4259            err.to_string()
4260                .contains("extends must be a string or an array of strings"),
4261            "unexpected error: {err}"
4262        );
4263    }
4264
4265    #[test]
4266    fn extends_multiple_bases_later_wins() {
4267        let dir = test_dir("extends-multi-base");
4268
4269        std::fs::write(
4270            dir.path().join("base-a.json"),
4271            r#"{"rules": {"unused-files": "warn"}}"#,
4272        )
4273        .unwrap();
4274        std::fs::write(
4275            dir.path().join("base-b.json"),
4276            r#"{"rules": {"unused-files": "off"}}"#,
4277        )
4278        .unwrap();
4279        std::fs::write(
4280            dir.path().join(".fallowrc.json"),
4281            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
4282        )
4283        .unwrap();
4284
4285        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
4286        assert_eq!(config.rules.unused_files, Severity::Off);
4287    }
4288
4289    #[test]
4290    fn load_rejects_empty_security_request_receivers() {
4291        let dir = test_dir("empty-security-request-receivers");
4292        std::fs::write(
4293            dir.path().join(".fallowrc.json"),
4294            r#"{"security": {"requestReceivers": ["req", "  "]}}"#,
4295        )
4296        .unwrap();
4297
4298        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4299        let err = result.expect_err("empty receiver should be rejected");
4300        assert!(
4301            err.to_string().contains("security.requestReceivers"),
4302            "error should name security.requestReceivers: {err}"
4303        );
4304    }
4305
4306    #[test]
4307    fn resolve_normalizes_security_request_receivers() {
4308        let dir = test_dir("normalize-security-request-receivers");
4309        std::fs::write(
4310            dir.path().join(".fallowrc.json"),
4311            r#"{"security": {"requestReceivers": [" HttpReq ", "httpreq", "R"]}}"#,
4312        )
4313        .unwrap();
4314
4315        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
4316            .unwrap()
4317            .resolve(
4318                dir.path().to_path_buf(),
4319                OutputFormat::Human,
4320                1,
4321                true,
4322                true,
4323                None,
4324            );
4325        assert_eq!(
4326            config.security.request_receivers,
4327            vec!["httpreq".to_string(), "r".to_string()]
4328        );
4329    }
4330
4331    #[test]
4332    fn fallow_config_deserialize_production() {
4333        let json_str = r#"{"production": true}"#;
4334        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
4335        assert!(config.production);
4336    }
4337
4338    #[test]
4339    fn package_json_optional_dependency_names() {
4340        let pkg: PackageJson = serde_json::from_str(
4341            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
4342        )
4343        .unwrap();
4344        let opt = pkg.optional_dependency_names();
4345        assert_eq!(opt.len(), 2);
4346        assert!(opt.contains(&"fsevents".to_string()));
4347        assert!(opt.contains(&"chokidar".to_string()));
4348    }
4349
4350    #[test]
4351    fn package_json_optional_deps_empty_when_missing() {
4352        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
4353        assert!(pkg.optional_dependency_names().is_empty());
4354    }
4355
4356    #[test]
4357    fn find_config_path_returns_fallowrc_json() {
4358        let dir = test_dir("find-path-json");
4359        std::fs::create_dir(dir.path().join(".git")).unwrap();
4360        std::fs::write(
4361            dir.path().join(".fallowrc.json"),
4362            r#"{"entry": ["src/main.ts"]}"#,
4363        )
4364        .unwrap();
4365
4366        let path = FallowConfig::find_config_path(dir.path());
4367        assert!(path.is_some());
4368        assert!(path.unwrap().ends_with(".fallowrc.json"));
4369    }
4370
4371    #[test]
4372    fn find_config_path_returns_fallow_toml() {
4373        let dir = test_dir("find-path-toml");
4374        std::fs::create_dir(dir.path().join(".git")).unwrap();
4375        std::fs::write(
4376            dir.path().join("fallow.toml"),
4377            "entry = [\"src/main.ts\"]\n",
4378        )
4379        .unwrap();
4380
4381        let path = FallowConfig::find_config_path(dir.path());
4382        assert!(path.is_some());
4383        assert!(path.unwrap().ends_with("fallow.toml"));
4384    }
4385
4386    #[test]
4387    fn find_config_path_returns_dot_fallow_toml() {
4388        let dir = test_dir("find-path-dot-toml");
4389        std::fs::create_dir(dir.path().join(".git")).unwrap();
4390        std::fs::write(
4391            dir.path().join(".fallow.toml"),
4392            "entry = [\"src/main.ts\"]\n",
4393        )
4394        .unwrap();
4395
4396        let path = FallowConfig::find_config_path(dir.path());
4397        assert!(path.is_some());
4398        assert!(path.unwrap().ends_with(".fallow.toml"));
4399    }
4400
4401    #[test]
4402    fn find_config_path_prefers_json_over_toml() {
4403        let dir = test_dir("find-path-priority");
4404        std::fs::create_dir(dir.path().join(".git")).unwrap();
4405        std::fs::write(
4406            dir.path().join(".fallowrc.json"),
4407            r#"{"entry": ["json.ts"]}"#,
4408        )
4409        .unwrap();
4410        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
4411
4412        let path = FallowConfig::find_config_path(dir.path());
4413        assert!(path.unwrap().ends_with(".fallowrc.json"));
4414    }
4415
4416    #[test]
4417    fn find_config_path_none_when_no_config() {
4418        let dir = test_dir("find-path-none");
4419        std::fs::create_dir(dir.path().join(".git")).unwrap();
4420
4421        let path = FallowConfig::find_config_path(dir.path());
4422        assert!(path.is_none());
4423    }
4424
4425    #[test]
4426    fn find_config_path_walks_past_package_json_in_monorepo() {
4427        let dir = test_dir("find-path-monorepo");
4428        std::fs::create_dir(dir.path().join(".git")).unwrap();
4429        std::fs::write(
4430            dir.path().join(".fallowrc.json"),
4431            r#"{"entry": ["src/index.ts"]}"#,
4432        )
4433        .unwrap();
4434
4435        let sub = dir.path().join("packages").join("app");
4436        std::fs::create_dir_all(&sub).unwrap();
4437        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
4438
4439        let path = FallowConfig::find_config_path(&sub).unwrap();
4440        assert_eq!(path, dir.path().join(".fallowrc.json"));
4441    }
4442
4443    #[test]
4444    fn extends_toml_base() {
4445        let dir = test_dir("extends-toml");
4446
4447        std::fs::write(
4448            dir.path().join("base.json"),
4449            r#"{"rules": {"unused-files": "warn"}}"#,
4450        )
4451        .unwrap();
4452        std::fs::write(
4453            dir.path().join("fallow.toml"),
4454            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
4455        )
4456        .unwrap();
4457
4458        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4459        assert_eq!(config.rules.unused_files, Severity::Warn);
4460        assert_eq!(config.entry, vec!["src/index.ts"]);
4461    }
4462
4463    #[test]
4464    fn deep_merge_boolean_overlay() {
4465        let mut base = serde_json::json!(true);
4466        deep_merge_json(&mut base, serde_json::json!(false));
4467        assert_eq!(base, serde_json::json!(false));
4468    }
4469
4470    #[test]
4471    fn deep_merge_number_overlay() {
4472        let mut base = serde_json::json!(42);
4473        deep_merge_json(&mut base, serde_json::json!(99));
4474        assert_eq!(base, serde_json::json!(99));
4475    }
4476
4477    #[test]
4478    fn deep_merge_disjoint_objects() {
4479        let mut base = serde_json::json!({"a": 1});
4480        let overlay = serde_json::json!({"b": 2});
4481        deep_merge_json(&mut base, overlay);
4482        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
4483    }
4484
4485    #[test]
4486    fn max_extends_depth_is_reasonable() {
4487        assert_eq!(MAX_EXTENDS_DEPTH, 10);
4488    }
4489
4490    #[test]
4491    fn config_names_has_four_entries() {
4492        assert_eq!(CONFIG_NAMES.len(), 4);
4493        for name in CONFIG_NAMES {
4494            assert!(
4495                name.starts_with('.') || name.starts_with("fallow"),
4496                "unexpected config name: {name}"
4497            );
4498        }
4499    }
4500
4501    #[test]
4502    fn package_json_peer_dependency_names() {
4503        let pkg: PackageJson = serde_json::from_str(
4504            r#"{
4505            "dependencies": {"react": "^18"},
4506            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
4507        }"#,
4508        )
4509        .unwrap();
4510        let all = pkg.all_dependency_names();
4511        assert!(all.contains(&"react".to_string()));
4512        assert!(all.contains(&"react-dom".to_string()));
4513        assert!(all.contains(&"react-native".to_string()));
4514    }
4515
4516    #[test]
4517    fn package_json_scripts_field() {
4518        let pkg: PackageJson = serde_json::from_str(
4519            r#"{
4520            "scripts": {
4521                "build": "tsc",
4522                "test": "vitest",
4523                "lint": "fallow check"
4524            }
4525        }"#,
4526        )
4527        .unwrap();
4528        let scripts = pkg.scripts.unwrap();
4529        assert_eq!(scripts.len(), 3);
4530        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
4531        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
4532    }
4533
4534    #[test]
4535    fn extends_toml_chain() {
4536        let dir = test_dir("extends-toml-chain");
4537
4538        std::fs::write(
4539            dir.path().join("base.json"),
4540            r#"{"entry": ["src/base.ts"]}"#,
4541        )
4542        .unwrap();
4543        std::fs::write(
4544            dir.path().join("middle.json"),
4545            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
4546        )
4547        .unwrap();
4548        std::fs::write(
4549            dir.path().join("fallow.toml"),
4550            "extends = [\"middle.json\"]\n",
4551        )
4552        .unwrap();
4553
4554        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
4555        assert_eq!(config.entry, vec!["src/base.ts"]);
4556        assert_eq!(config.rules.unused_files, Severity::Off);
4557    }
4558
4559    #[test]
4560    fn find_and_load_walks_up_directories() {
4561        let dir = test_dir("find-walk-up");
4562        let sub = dir.path().join("src").join("deep");
4563        std::fs::create_dir_all(&sub).unwrap();
4564        std::fs::write(
4565            dir.path().join(".fallowrc.json"),
4566            r#"{"entry": ["src/main.ts"]}"#,
4567        )
4568        .unwrap();
4569        std::fs::create_dir(dir.path().join(".git")).unwrap();
4570
4571        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
4572        assert_eq!(config.entry, vec!["src/main.ts"]);
4573        assert!(path.ends_with(".fallowrc.json"));
4574    }
4575
4576    #[test]
4577    fn json_schema_contains_entry_field() {
4578        let schema = FallowConfig::json_schema();
4579        let obj = schema.as_object().unwrap();
4580        let props = obj.get("properties").and_then(|v| v.as_object());
4581        assert!(props.is_some(), "schema should have properties");
4582        assert!(
4583            props.unwrap().contains_key("entry"),
4584            "schema should contain entry property"
4585        );
4586    }
4587
4588    #[test]
4589    fn fallow_config_json_duplicates_all_fields() {
4590        let json = r#"{
4591            "duplicates": {
4592                "enabled": true,
4593                "mode": "semantic",
4594                "minTokens": 200,
4595                "minLines": 20,
4596                "threshold": 10.5,
4597                "ignore": ["**/*.test.ts"],
4598                "skipLocal": true,
4599                "crossLanguage": true,
4600                "normalization": {
4601                    "ignoreIdentifiers": true,
4602                    "ignoreStringValues": false
4603                }
4604            }
4605        }"#;
4606        let config: FallowConfig = serde_json::from_str(json).unwrap();
4607        assert!(config.duplicates.enabled);
4608        assert_eq!(
4609            config.duplicates.mode,
4610            crate::config::DetectionMode::Semantic
4611        );
4612        assert_eq!(config.duplicates.min_tokens, 200);
4613        assert_eq!(config.duplicates.min_lines, 20);
4614        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
4615        assert!(config.duplicates.skip_local);
4616        assert!(config.duplicates.cross_language);
4617        assert_eq!(
4618            config.duplicates.normalization.ignore_identifiers,
4619            Some(true)
4620        );
4621        assert_eq!(
4622            config.duplicates.normalization.ignore_string_values,
4623            Some(false)
4624        );
4625    }
4626
4627    #[test]
4628    fn normalize_url_basic() {
4629        assert_eq!(
4630            normalize_url_for_dedup("https://example.com/config.json"),
4631            "https://example.com/config.json"
4632        );
4633    }
4634
4635    #[test]
4636    fn remote_config_display_redacts_url_secrets_and_preserves_local_paths() {
4637        let cases = [
4638            (
4639                "https://user:password@example.com/config.json",
4640                "https://example.com/config.json",
4641            ),
4642            (
4643                "https://example.com/config.json?token=query-secret#fragment-secret",
4644                "https://example.com/config.json",
4645            ),
4646            (
4647                "https://user:password@[2001:db8::1]:8443/config.json?token=query-secret#fragment-secret",
4648                "https://[2001:db8::1]:8443/config.json",
4649            ),
4650            (
4651                "/workspace/configs/base.json?literal-query#literal-fragment",
4652                "/workspace/configs/base.json?literal-query#literal-fragment",
4653            ),
4654        ];
4655
4656        for (case_index, (input, expected)) in cases.into_iter().enumerate() {
4657            assert!(
4658                remote_config_display(input) == expected,
4659                "remote config display case {case_index} must be sanitized"
4660            );
4661        }
4662    }
4663
4664    #[test]
4665    fn remote_fetch_error_redacts_require_https_only_redirect_target() {
4666        let redirect_target =
4667            "http://redirect-user:redirect-password@example.com/config.json?token=secret#anchor";
4668        let dependency_error = ureq::Error::RequireHttpsOnly(redirect_target.to_string());
4669
4670        let display =
4671            remote_fetch_error_display(&dependency_error, "https://config.example.com/config.json");
4672
4673        assert_eq!(
4674            display,
4675            "configured for https only: http://example.com/config.json"
4676        );
4677        for secret in [
4678            "redirect-user",
4679            "redirect-password",
4680            "token=secret",
4681            "anchor",
4682        ] {
4683            assert!(
4684                !display.contains(secret),
4685                "redirect secret must be redacted"
4686            );
4687        }
4688    }
4689
4690    #[test]
4691    fn normalize_url_preserves_non_root_trailing_slash() {
4692        assert_eq!(
4693            normalize_url_for_dedup("https://example.com/config/"),
4694            "https://example.com/config/"
4695        );
4696    }
4697
4698    #[test]
4699    fn normalize_url_uppercase_scheme_and_host() {
4700        assert_eq!(
4701            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
4702            "https://example.com/Config.json"
4703        );
4704    }
4705
4706    #[test]
4707    fn normalize_url_root_path() {
4708        assert_eq!(
4709            normalize_url_for_dedup("https://example.com/"),
4710            "https://example.com"
4711        );
4712        assert_eq!(
4713            normalize_url_for_dedup("https://example.com"),
4714            "https://example.com"
4715        );
4716    }
4717
4718    #[test]
4719    fn normalize_url_preserves_path_case() {
4720        assert_eq!(
4721            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
4722            "https://github.com/Org/Repo/Fallow.json"
4723        );
4724    }
4725
4726    #[test]
4727    fn normalize_url_preserves_userinfo_case_and_normalizes_host() {
4728        assert_eq!(
4729            normalize_url_for_dedup(
4730                "HTTPS://CaseSensitiveUser:CaseSensitivePassword@Example.COM/Config.json"
4731            ),
4732            "https://CaseSensitiveUser:CaseSensitivePassword@example.com/Config.json"
4733        );
4734    }
4735
4736    #[test]
4737    fn normalize_url_preserves_query_string() {
4738        assert_eq!(
4739            normalize_url_for_dedup("https://example.com/config.json?v=1"),
4740            "https://example.com/config.json?v=1"
4741        );
4742    }
4743
4744    #[test]
4745    fn normalize_url_strips_fragment() {
4746        assert_eq!(
4747            normalize_url_for_dedup("https://example.com/config.json#section"),
4748            "https://example.com/config.json"
4749        );
4750    }
4751
4752    #[test]
4753    fn normalize_url_preserves_query_and_strips_fragment() {
4754        assert_eq!(
4755            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
4756            "https://example.com/config.json?v=1"
4757        );
4758    }
4759
4760    #[test]
4761    fn normalize_url_query_selects_resource_but_fragment_does_not() {
4762        assert_ne!(
4763            normalize_url_for_dedup("https://example.com/config.json?tenant=one"),
4764            normalize_url_for_dedup("https://example.com/config.json?tenant=two")
4765        );
4766        assert_eq!(
4767            normalize_url_for_dedup("https://example.com/config.json?tenant=one#first"),
4768            normalize_url_for_dedup("https://example.com/config.json?tenant=one#second")
4769        );
4770    }
4771
4772    #[test]
4773    fn normalize_url_default_https_port() {
4774        assert_eq!(
4775            normalize_url_for_dedup("https://example.com:443/config.json"),
4776            "https://example.com/config.json"
4777        );
4778        assert_eq!(
4779            normalize_url_for_dedup("https://example.com:8443/config.json"),
4780            "https://example.com:8443/config.json"
4781        );
4782    }
4783
4784    #[test]
4785    fn normalize_url_only_strips_the_scheme_default_port() {
4786        assert_eq!(
4787            normalize_url_for_dedup("http://example.com:80/config.json"),
4788            "http://example.com/config.json"
4789        );
4790        assert_eq!(
4791            normalize_url_for_dedup("http://example.com:443/config.json"),
4792            "http://example.com:443/config.json"
4793        );
4794        assert_eq!(
4795            normalize_url_for_dedup("https://example.com:80/config.json"),
4796            "https://example.com:80/config.json"
4797        );
4798    }
4799
4800    #[test]
4801    fn normalize_url_canonicalizes_numeric_ports_and_root_query_path() {
4802        assert_eq!(
4803            normalize_url_for_dedup("https://example.com:0443/?profile=one"),
4804            "https://example.com?profile=one"
4805        );
4806        assert_eq!(
4807            normalize_url_for_dedup("https://example.com:080/config.json"),
4808            "https://example.com:80/config.json"
4809        );
4810    }
4811
4812    #[test]
4813    fn normalize_url_canonicalizes_equivalent_ipv6_hosts() {
4814        assert_eq!(
4815            normalize_url_for_dedup("https://[2001:0DB8:0:0:0:0:0:1]/config.json"),
4816            "https://[2001:db8::1]/config.json"
4817        );
4818    }
4819
4820    #[test]
4821    fn extends_http_rejected() {
4822        let dir = test_dir("http-rejected");
4823        std::fs::write(
4824            dir.path().join(".fallowrc.json"),
4825            r#"{"extends": "http://example.com/config.json"}"#,
4826        )
4827        .unwrap();
4828
4829        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4830        assert!(result.is_err());
4831        let err_msg = format!("{}", result.unwrap_err());
4832        assert!(
4833            err_msg.contains("https://"),
4834            "Expected https hint in error, got: {err_msg}"
4835        );
4836        assert!(
4837            err_msg.contains("http://"),
4838            "Expected http:// mention in error, got: {err_msg}"
4839        );
4840    }
4841
4842    #[test]
4843    fn extends_url_circular_detection() {
4844        let mut visited = FxHashSet::default();
4845        let url = "https://example.com/config.json";
4846        let normalized = normalize_url_for_dedup(url);
4847        visited.insert(normalized.clone());
4848
4849        assert!(
4850            !visited.insert(normalized),
4851            "Same URL should be detected as duplicate"
4852        );
4853    }
4854
4855    #[test]
4856    fn extends_url_circular_case_insensitive() {
4857        let mut visited = FxHashSet::default();
4858        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
4859
4860        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
4861        assert!(
4862            !visited.insert(normalized),
4863            "Case-different URLs should normalize to the same key"
4864        );
4865    }
4866
4867    #[test]
4868    fn extract_extends_array() {
4869        let mut value = serde_json::json!({
4870            "extends": ["a.json", "b.json"],
4871            "entry": ["src/index.ts"]
4872        });
4873        let extends = extract_extends(&mut value, "test.json").unwrap();
4874        assert_eq!(extends, vec!["a.json", "b.json"]);
4875        assert!(value.get("extends").is_none());
4876        assert!(value.get("entry").is_some());
4877    }
4878
4879    #[test]
4880    fn extract_extends_string_sugar() {
4881        let mut value = serde_json::json!({
4882            "extends": "base.json",
4883            "entry": ["src/index.ts"]
4884        });
4885        let extends = extract_extends(&mut value, "test.json").unwrap();
4886        assert_eq!(extends, vec!["base.json"]);
4887    }
4888
4889    #[test]
4890    fn extract_extends_none() {
4891        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
4892        let extends = extract_extends(&mut value, "test.json").unwrap();
4893        assert!(extends.is_empty());
4894    }
4895
4896    #[test]
4897    fn url_timeout_default() {
4898        let timeout = url_timeout();
4899        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
4900    }
4901
4902    #[test]
4903    fn extends_url_mixed_with_file_and_npm() {
4904        let dir = test_dir("url-mixed");
4905        std::fs::write(
4906            dir.path().join("local.json"),
4907            r#"{"rules": {"unused-files": "warn"}}"#,
4908        )
4909        .unwrap();
4910        std::fs::write(
4911            dir.path().join(".fallowrc.json"),
4912            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
4913        )
4914        .unwrap();
4915
4916        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4917        assert!(result.is_err());
4918        let err_msg = format!("{}", result.unwrap_err());
4919        assert!(
4920            err_msg.contains("unreachable.invalid"),
4921            "Expected URL in error message, got: {err_msg}"
4922        );
4923    }
4924
4925    #[test]
4926    fn extends_https_url_default_denial_has_opt_in_hint() {
4927        let dir = test_dir("url-unreachable");
4928        std::fs::write(
4929            dir.path().join(".fallowrc.json"),
4930            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
4931        )
4932        .unwrap();
4933
4934        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
4935        assert!(result.is_err());
4936        let err_msg = format!("{}", result.unwrap_err());
4937        assert!(
4938            err_msg.contains("unreachable.invalid"),
4939            "Expected URL in error, got: {err_msg}"
4940        );
4941        assert!(
4942            err_msg.contains("--allow-remote-extends"),
4943            "Expected remediation hint, got: {err_msg}"
4944        );
4945    }
4946
4947    #[test]
4948    fn collect_unknown_rule_keys_flags_top_level_typo() {
4949        let merged = serde_json::json!({
4950            "rules": {
4951                "unsued-files": "warn",
4952                "unused-exports": "off"
4953            }
4954        });
4955        let findings = collect_unknown_rule_keys(&merged);
4956        assert_eq!(findings.len(), 1);
4957        assert_eq!(findings[0].context, "rules");
4958        assert_eq!(findings[0].key, "unsued-files");
4959        assert_eq!(findings[0].suggestion, Some("unused-files"));
4960    }
4961
4962    #[test]
4963    fn collect_unknown_rule_keys_flags_overrides_typo() {
4964        let merged = serde_json::json!({
4965            "overrides": [
4966                {
4967                    "files": ["src/**/*.ts"],
4968                    "rules": {
4969                        "unsued-files": "warn"
4970                    }
4971                },
4972                {
4973                    "files": ["tests/**/*.ts"],
4974                    "rules": {
4975                        "circular-dependnecy": "off"
4976                    }
4977                }
4978            ]
4979        });
4980        let findings = collect_unknown_rule_keys(&merged);
4981        assert_eq!(findings.len(), 2);
4982        assert_eq!(findings[0].context, "overrides[0].rules");
4983        assert_eq!(findings[1].context, "overrides[1].rules");
4984        assert_eq!(findings[1].suggestion, Some("circular-dependency"));
4985    }
4986
4987    #[test]
4988    fn collect_unknown_rule_keys_empty_for_valid_config() {
4989        let merged = serde_json::json!({
4990            "rules": {
4991                "unused-files": "warn",
4992                "unused-file": "off",
4993                "circular-dependency": "off",
4994                "boundary-violations": "warn"
4995            },
4996            "overrides": [
4997                {
4998                    "files": ["src/**"],
4999                    "rules": {
5000                        "unused-exports": "warn"
5001                    }
5002                }
5003            ]
5004        });
5005        let findings = collect_unknown_rule_keys(&merged);
5006        assert!(
5007            findings.is_empty(),
5008            "valid rule names and aliases must not be flagged: {findings:?}"
5009        );
5010    }
5011
5012    #[test]
5013    fn collect_unknown_rule_keys_ignores_missing_rules_section() {
5014        let merged = serde_json::json!({
5015            "entry": ["src/main.ts"]
5016        });
5017        let findings = collect_unknown_rule_keys(&merged);
5018        assert!(findings.is_empty());
5019    }
5020
5021    #[test]
5022    fn load_wires_warn_on_unknown_rule_keys_into_load_path() {
5023        let dir = test_dir("wiring");
5024        let path = dir.path().join(".fallowrc.json");
5025        let typo = format!(
5026            "wiring-probe-{}-{}",
5027            std::process::id(),
5028            std::time::SystemTime::now()
5029                .duration_since(std::time::UNIX_EPOCH)
5030                .map_or(0, |d| d.as_nanos())
5031        );
5032        std::fs::write(&path, format!(r#"{{"rules": {{"{typo}": "warn"}}}}"#)).unwrap();
5033
5034        let (config_res, captured) = capture_unknown_rule_warnings(|| FallowConfig::load(&path));
5035
5036        assert!(
5037            config_res.is_ok(),
5038            "load should succeed in phase 1: {:?}",
5039            config_res.err()
5040        );
5041        assert_eq!(
5042            captured.len(),
5043            1,
5044            "FallowConfig::load must invoke warn_on_unknown_rule_keys exactly once for one new unknown key, got: {captured:?}"
5045        );
5046        assert_eq!(captured[0].key, typo);
5047        assert_eq!(captured[0].context, "rules");
5048    }
5049
5050    #[test]
5051    fn load_with_misspelled_rule_succeeds_and_ignores_typo() {
5052        let dir = test_dir("misspelled-rule");
5053        std::fs::write(
5054            dir.path().join(".fallowrc.json"),
5055            r#"{"rules": {"unsued-files": "warn"}}"#,
5056        )
5057        .unwrap();
5058
5059        let config = FallowConfig::load(&dir.path().join(".fallowrc.json"))
5060            .expect("load should succeed in phase 1");
5061
5062        assert_eq!(config.rules.unused_files, Severity::Error);
5063    }
5064
5065    #[test]
5066    fn validate_resolved_boundaries_passes_on_valid_config() {
5067        let dir = test_dir("boundaries-valid");
5068        let config = FallowConfig {
5069            boundaries: crate::BoundaryConfig {
5070                coverage: crate::BoundaryCoverageConfig::default(),
5071                calls: crate::BoundaryCallsConfig::default(),
5072                preset: None,
5073                zones: vec![
5074                    crate::BoundaryZone {
5075                        name: "ui".to_string(),
5076                        patterns: vec!["src/components/**".to_string()],
5077                        auto_discover: vec![],
5078                        root: None,
5079                    },
5080                    crate::BoundaryZone {
5081                        name: "db".to_string(),
5082                        patterns: vec!["src/db/**".to_string()],
5083                        auto_discover: vec![],
5084                        root: None,
5085                    },
5086                ],
5087                rules: vec![crate::BoundaryRule {
5088                    from: "ui".to_string(),
5089                    allow: vec!["db".to_string()],
5090                    allow_type_only: vec![],
5091                }],
5092            },
5093            ..FallowConfig::default()
5094        };
5095        config
5096            .validate_resolved_boundaries(dir.path())
5097            .expect("valid config should pass");
5098    }
5099
5100    #[test]
5101    fn validate_resolved_boundaries_aggregates_unknown_zone_refs() {
5102        let dir = test_dir("boundaries-unknown-zones");
5103        let config = FallowConfig {
5104            boundaries: crate::BoundaryConfig {
5105                coverage: crate::BoundaryCoverageConfig::default(),
5106                calls: crate::BoundaryCallsConfig::default(),
5107                preset: None,
5108                zones: vec![crate::BoundaryZone {
5109                    name: "ui".to_string(),
5110                    patterns: vec!["src/ui/**".to_string()],
5111                    auto_discover: vec![],
5112                    root: None,
5113                }],
5114                rules: vec![
5115                    crate::BoundaryRule {
5116                        from: "typo-from".to_string(),
5117                        allow: vec!["typo-allow".to_string()],
5118                        allow_type_only: vec!["typo-type-only".to_string()],
5119                    },
5120                    crate::BoundaryRule {
5121                        from: "ui".to_string(),
5122                        allow: vec!["another-typo".to_string()],
5123                        allow_type_only: vec![],
5124                    },
5125                ],
5126            },
5127            ..FallowConfig::default()
5128        };
5129
5130        let errors = config
5131            .validate_resolved_boundaries(dir.path())
5132            .expect_err("invalid zone refs should fail");
5133
5134        assert_eq!(errors.len(), 4, "got: {errors:?}");
5135
5136        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
5137        assert!(
5138            rendered
5139                .iter()
5140                .any(|m| m.contains("typo-from") && m.contains("rules[0]") && m.contains("from"))
5141        );
5142        assert!(
5143            rendered
5144                .iter()
5145                .any(|m| m.contains("typo-allow") && m.contains("rules[0]") && m.contains("allow"))
5146        );
5147        assert!(rendered.iter().any(|m| m.contains("typo-type-only")
5148            && m.contains("rules[0]")
5149            && m.contains("allowTypeOnly")));
5150        assert!(
5151            rendered.iter().any(|m| m.contains("another-typo")
5152                && m.contains("rules[1]")
5153                && m.contains("allow"))
5154        );
5155    }
5156
5157    #[test]
5158    fn validate_resolved_boundaries_flags_redundant_root_prefix() {
5159        let dir = test_dir("boundaries-redundant-prefix");
5160        let config = FallowConfig {
5161            boundaries: crate::BoundaryConfig {
5162                coverage: crate::BoundaryCoverageConfig::default(),
5163                calls: crate::BoundaryCallsConfig::default(),
5164                preset: None,
5165                zones: vec![crate::BoundaryZone {
5166                    name: "ui".to_string(),
5167                    patterns: vec!["packages/app/src/**".to_string()],
5168                    auto_discover: vec![],
5169                    root: Some("packages/app/".to_string()),
5170                }],
5171                rules: vec![],
5172            },
5173            ..FallowConfig::default()
5174        };
5175
5176        let errors = config
5177            .validate_resolved_boundaries(dir.path())
5178            .expect_err("redundant root prefix should fail");
5179        assert_eq!(errors.len(), 1, "got: {errors:?}");
5180        let rendered = errors[0].to_string();
5181        assert!(rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"));
5182        assert!(rendered.contains("zone 'ui'"));
5183    }
5184
5185    #[test]
5186    fn validate_resolved_boundaries_aggregates_unknown_zones_and_root_prefixes() {
5187        let dir = test_dir("boundaries-mixed-errors");
5188        let config = FallowConfig {
5189            boundaries: crate::BoundaryConfig {
5190                coverage: crate::BoundaryCoverageConfig::default(),
5191                calls: crate::BoundaryCallsConfig::default(),
5192                preset: None,
5193                zones: vec![crate::BoundaryZone {
5194                    name: "ui".to_string(),
5195                    patterns: vec!["packages/app/src/**".to_string()],
5196                    auto_discover: vec![],
5197                    root: Some("packages/app/".to_string()),
5198                }],
5199                rules: vec![crate::BoundaryRule {
5200                    from: "ui".to_string(),
5201                    allow: vec!["typo-zone".to_string()],
5202                    allow_type_only: vec![],
5203                }],
5204            },
5205            ..FallowConfig::default()
5206        };
5207        let errors = config
5208            .validate_resolved_boundaries(dir.path())
5209            .expect_err("mixed errors should fail");
5210        assert_eq!(errors.len(), 2, "got: {errors:?}");
5211        let rendered: Vec<String> = errors.iter().map(ToString::to_string).collect();
5212        assert!(
5213            rendered
5214                .iter()
5215                .any(|m| m.contains("typo-zone") && m.contains("rules[0]"))
5216        );
5217        assert!(
5218            rendered
5219                .iter()
5220                .any(|m| m.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"))
5221        );
5222    }
5223
5224    #[test]
5225    fn validate_resolved_boundaries_passes_on_bulletproof_preset() {
5226        let dir = test_dir("boundaries-bulletproof");
5227        std::fs::create_dir_all(dir.path().join("src/features/auth")).unwrap();
5228        let config = FallowConfig {
5229            boundaries: crate::BoundaryConfig {
5230                coverage: crate::BoundaryCoverageConfig::default(),
5231                calls: crate::BoundaryCallsConfig::default(),
5232                preset: Some(crate::BoundaryPreset::Bulletproof),
5233                zones: vec![],
5234                rules: vec![],
5235            },
5236            ..FallowConfig::default()
5237        };
5238        config
5239            .validate_resolved_boundaries(dir.path())
5240            .expect("Bulletproof with discoverable features should pass");
5241    }
5242
5243    // ------------------------------------------------------------------
5244    // parse_config_to_value: BOM stripping, TOML parse error, JSON parse error
5245    // ------------------------------------------------------------------
5246
5247    #[test]
5248    #[cfg_attr(miri, ignore)]
5249    fn parse_config_to_value_strips_utf8_bom() {
5250        let dir = test_dir("parse-bom");
5251        let path = dir.path().join("fallow.toml");
5252        // Write TOML with a UTF-8 BOM prefix
5253        let content_with_bom = "\u{FEFF}entry = [\"src/main.ts\"]\n";
5254        std::fs::write(&path, content_with_bom).unwrap();
5255
5256        let value = parse_config_to_value(&path).unwrap();
5257        assert!(
5258            value.get("entry").is_some(),
5259            "BOM should be stripped before TOML parsing"
5260        );
5261    }
5262
5263    #[test]
5264    #[cfg_attr(miri, ignore)]
5265    fn parse_config_to_value_toml_parse_error() {
5266        let dir = test_dir("parse-toml-error");
5267        let path = dir.path().join("fallow.toml");
5268        std::fs::write(&path, "entry = [unquoted\n").unwrap();
5269
5270        let result = parse_config_to_value(&path);
5271        assert!(result.is_err());
5272        let err = result.unwrap_err().to_string();
5273        assert!(
5274            err.contains("Failed to parse config file"),
5275            "error should mention parse failure: {err}"
5276        );
5277    }
5278
5279    #[test]
5280    #[cfg_attr(miri, ignore)]
5281    fn parse_config_to_value_json_parse_error() {
5282        let dir = test_dir("parse-json-error");
5283        let path = dir.path().join(".fallowrc.json");
5284        std::fs::write(&path, "{ this is not json }").unwrap();
5285
5286        let result = parse_config_to_value(&path);
5287        assert!(result.is_err());
5288        let err = result.unwrap_err().to_string();
5289        assert!(
5290            err.contains("Failed to parse config file"),
5291            "error should mention parse failure: {err}"
5292        );
5293    }
5294
5295    #[test]
5296    #[cfg_attr(miri, ignore)]
5297    fn parse_config_to_value_missing_file_error() {
5298        let dir = test_dir("parse-missing");
5299        let path = dir.path().join("nonexistent.toml");
5300
5301        let result = parse_config_to_value(&path);
5302        assert!(result.is_err());
5303        let err = result.unwrap_err().to_string();
5304        assert!(
5305            err.contains("Failed to read config file"),
5306            "error should mention read failure: {err}"
5307        );
5308    }
5309
5310    // ------------------------------------------------------------------
5311    // is_repo_root: svn boundary
5312    // ------------------------------------------------------------------
5313
5314    #[test]
5315    #[cfg_attr(miri, ignore)]
5316    fn find_and_load_stops_at_svn_dir() {
5317        let dir = test_dir("find-svn-stop");
5318        let sub = dir.path().join("sub");
5319        std::fs::create_dir(&sub).unwrap();
5320        std::fs::create_dir(dir.path().join(".svn")).unwrap();
5321
5322        let result = FallowConfig::find_and_load(&sub).unwrap();
5323        assert!(result.is_none(), "svn boundary should stop config walk");
5324    }
5325
5326    // ------------------------------------------------------------------
5327    // validate_npm_package_name: dot-segment in the package name
5328    // (path traversal but using a single dot)
5329    // ------------------------------------------------------------------
5330
5331    #[test]
5332    #[cfg_attr(miri, ignore)]
5333    fn extends_npm_single_dot_package_name_rejected() {
5334        let dir = test_dir("npm-dot-name");
5335        std::fs::write(
5336            dir.path().join(".fallowrc.json"),
5337            r#"{"extends": "npm:./relative"}"#,
5338        )
5339        .unwrap();
5340
5341        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5342        assert!(result.is_err());
5343        let err = result.unwrap_err().to_string();
5344        assert!(
5345            err.contains("path traversal"),
5346            "single-dot component should be rejected as path traversal: {err}"
5347        );
5348    }
5349
5350    // ------------------------------------------------------------------
5351    // find_config_in_npm_package: main field points to nonexistent file,
5352    // falls through to config-name scan
5353    // ------------------------------------------------------------------
5354
5355    #[test]
5356    #[cfg_attr(miri, ignore)]
5357    fn extends_npm_main_points_to_nonexistent_falls_through_to_config_name() {
5358        let dir = test_dir("npm-main-missing");
5359        let pkg_dir = dir.path().join("node_modules/my-config");
5360        std::fs::create_dir_all(&pkg_dir).unwrap();
5361        // package.json with main pointing at a file that does not exist
5362        std::fs::write(
5363            pkg_dir.join("package.json"),
5364            r#"{"name": "my-config", "main": "./missing.json"}"#,
5365        )
5366        .unwrap();
5367        // But a recognized config name is present for the fallback scan
5368        std::fs::write(
5369            pkg_dir.join(".fallowrc.json"),
5370            r#"{"rules": {"unused-files": "warn"}}"#,
5371        )
5372        .unwrap();
5373
5374        std::fs::write(
5375            dir.path().join(".fallowrc.json"),
5376            r#"{"extends": "npm:my-config"}"#,
5377        )
5378        .unwrap();
5379
5380        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5381        assert_eq!(config.rules.unused_files, Severity::Warn);
5382    }
5383
5384    // ------------------------------------------------------------------
5385    // find_config_in_npm_package: exports present but exports-pointed file
5386    // does not exist, falls through to main then config name
5387    // ------------------------------------------------------------------
5388
5389    #[test]
5390    #[cfg_attr(miri, ignore)]
5391    fn extends_npm_exports_nonexistent_falls_through_to_main() {
5392        let dir = test_dir("npm-exports-missing-file");
5393        let pkg_dir = dir.path().join("node_modules/cfg-pkg");
5394        std::fs::create_dir_all(&pkg_dir).unwrap();
5395        // exports points to a file that does not exist; main is valid
5396        std::fs::write(
5397            pkg_dir.join("package.json"),
5398            r#"{"name": "cfg-pkg", "exports": "./missing-exports.json", "main": "./real.json"}"#,
5399        )
5400        .unwrap();
5401        std::fs::write(
5402            pkg_dir.join("real.json"),
5403            r#"{"rules": {"unused-types": "off"}}"#,
5404        )
5405        .unwrap();
5406
5407        std::fs::write(
5408            dir.path().join(".fallowrc.json"),
5409            r#"{"extends": "npm:cfg-pkg"}"#,
5410        )
5411        .unwrap();
5412
5413        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
5414        assert_eq!(config.rules.unused_types, Severity::Off);
5415    }
5416
5417    // ------------------------------------------------------------------
5418    // normalize_url_for_dedup: URL with no "://" scheme falls back to raw
5419    // ------------------------------------------------------------------
5420
5421    #[test]
5422    fn normalize_url_no_scheme_returns_raw() {
5423        // A string without "://" must come back unchanged
5424        assert_eq!(normalize_url_for_dedup("not-a-url"), "not-a-url");
5425        assert_eq!(normalize_url_for_dedup("/absolute/path"), "/absolute/path");
5426    }
5427
5428    // ------------------------------------------------------------------
5429    // normalize_url_for_dedup: query before fragment (fragment then query)
5430    // ------------------------------------------------------------------
5431
5432    #[test]
5433    fn normalize_url_fragment_only_stripped() {
5434        // Fragment-only URL (no query)
5435        assert_eq!(
5436            normalize_url_for_dedup("https://example.com/file.json#anchor"),
5437            "https://example.com/file.json"
5438        );
5439    }
5440
5441    // ------------------------------------------------------------------
5442    // url_timeout: env var override
5443    // ------------------------------------------------------------------
5444
5445    // These exercise the pure `url_timeout_from` parser rather than mutating the
5446    // process-global env var, so they stay deterministic under parallel test
5447    // execution (an env-mutating version raced and failed on Windows CI).
5448    #[test]
5449    fn url_timeout_uses_env_var_when_set() {
5450        assert_eq!(url_timeout_from(Some("15")).as_secs(), 15);
5451    }
5452
5453    #[test]
5454    fn url_timeout_zero_falls_back_to_default() {
5455        assert_eq!(
5456            url_timeout_from(Some("0")),
5457            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5458            "zero should fall back to the hardcoded default"
5459        );
5460    }
5461
5462    #[test]
5463    fn url_timeout_non_numeric_falls_back_to_default() {
5464        assert_eq!(
5465            url_timeout_from(Some("not-a-number")),
5466            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
5467            "non-numeric value should fall back to the hardcoded default"
5468        );
5469    }
5470
5471    #[test]
5472    fn url_timeout_absent_uses_default() {
5473        assert_eq!(
5474            url_timeout_from(None),
5475            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS)
5476        );
5477    }
5478
5479    // ------------------------------------------------------------------
5480    // resolve_url_extends: depth limit reached
5481    // ------------------------------------------------------------------
5482
5483    #[test]
5484    fn resolve_url_extends_depth_limit_error() {
5485        let mut visited = FxHashSet::default();
5486        let result = resolve_url_extends(
5487            "https://example.invalid/config.json",
5488            &mut visited,
5489            MAX_EXTENDS_DEPTH, // at the limit
5490        );
5491        assert!(result.is_err());
5492        let err = result.unwrap_err().to_string();
5493        assert!(
5494            err.contains("too deep"),
5495            "error should mention depth limit: {err}"
5496        );
5497    }
5498
5499    #[test]
5500    #[cfg_attr(miri, ignore)]
5501    fn local_source_files_follow_the_local_extends_chain() {
5502        let dir = test_dir("local-sources");
5503        let root = dunce::canonicalize(dir.path()).unwrap();
5504        std::fs::create_dir_all(root.join("shared")).unwrap();
5505        std::fs::write(
5506            root.join(".fallowrc.json"),
5507            r#"{"extends": ["./shared/base.json", "https://example.com/remote.json"]}"#,
5508        )
5509        .unwrap();
5510        std::fs::write(
5511            root.join("shared/base.json"),
5512            r#"{"extends": "./leaf.json", "entry": []}"#,
5513        )
5514        .unwrap();
5515        std::fs::write(root.join("shared/leaf.json"), r#"{"entry": []}"#).unwrap();
5516
5517        assert_eq!(
5518            FallowConfig::local_source_files(&root.join(".fallowrc.json")),
5519            vec![
5520                root.join(".fallowrc.json"),
5521                root.join("shared/base.json"),
5522                root.join("shared/leaf.json"),
5523            ],
5524            "the list holds the config and each local extends target, and no remote one"
5525        );
5526    }
5527
5528    #[test]
5529    #[cfg_attr(miri, ignore)]
5530    fn local_source_files_stop_at_a_missing_extends_target() {
5531        let dir = test_dir("local-sources-missing");
5532        let root = dunce::canonicalize(dir.path()).unwrap();
5533        std::fs::write(root.join("fallow.json"), r#"{"extends": "./gone.json"}"#).unwrap();
5534
5535        assert_eq!(
5536            FallowConfig::local_source_files(&root.join("fallow.json")),
5537            vec![root.join("fallow.json")]
5538        );
5539        assert!(FallowConfig::local_source_files(&root.join("absent.json")).is_empty());
5540    }
5541
5542    // ------------------------------------------------------------------
5543    // resolve_extends_file: depth limit reached
5544    // ------------------------------------------------------------------
5545
5546    #[test]
5547    #[cfg_attr(miri, ignore)]
5548    fn resolve_extends_file_depth_limit_error() {
5549        let dir = test_dir("extends-file-depth");
5550        let path = dir.path().join(".fallowrc.json");
5551        std::fs::write(&path, r#"{"entry": []}"#).unwrap();
5552
5553        let mut visited = FxHashSet::default();
5554        let result = resolve_extends(&path, &mut visited, MAX_EXTENDS_DEPTH);
5555        assert!(result.is_err());
5556        let err = result.unwrap_err().to_string();
5557        assert!(
5558            err.contains("too deep"),
5559            "error should mention depth limit: {err}"
5560        );
5561    }
5562
5563    // ------------------------------------------------------------------
5564    // resolve_extends_file_entry: http:// in file-sourced extends
5565    // ------------------------------------------------------------------
5566
5567    #[test]
5568    #[cfg_attr(miri, ignore)]
5569    fn extends_http_url_in_file_extends_rejected() {
5570        let dir = test_dir("file-extends-http");
5571        std::fs::write(
5572            dir.path().join(".fallowrc.json"),
5573            r#"{"extends": ["http://example.com/config.json"]}"#,
5574        )
5575        .unwrap();
5576
5577        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
5578        assert!(result.is_err());
5579        let err = result.unwrap_err().to_string();
5580        assert!(
5581            err.contains("https://"),
5582            "error should suggest https: {err}"
5583        );
5584    }
5585
5586    // ------------------------------------------------------------------
5587    // sealed_config_dir: when sealed = true canonicalization runs
5588    // ------------------------------------------------------------------
5589
5590    #[test]
5591    #[cfg_attr(miri, ignore)]
5592    fn sealed_config_dir_returns_some_when_sealed() {
5593        let dir = test_dir("sealed-dir");
5594        let result = sealed_config_dir(dir.path(), true);
5595        assert!(result.is_ok());
5596        assert!(
5597            result.unwrap().is_some(),
5598            "sealed=true must return Some(canonicalized path)"
5599        );
5600    }
5601
5602    #[test]
5603    fn sealed_config_dir_returns_none_when_not_sealed() {
5604        let result = sealed_config_dir(Path::new("/nonexistent/path"), false);
5605        assert!(result.is_ok());
5606        assert!(result.unwrap().is_none(), "sealed=false must return None");
5607    }
5608
5609    // ------------------------------------------------------------------
5610    // collect_unknown_rule_keys: overrides entry without a rules key
5611    // (the inner `if let Some(rules)` branch is not taken)
5612    // ------------------------------------------------------------------
5613
5614    #[test]
5615    fn collect_unknown_rule_keys_override_without_rules_key() {
5616        let merged = serde_json::json!({
5617            "overrides": [
5618                {
5619                    "files": ["src/**/*.ts"]
5620                    // no "rules" key here
5621                },
5622                {
5623                    "files": ["tests/**"],
5624                    "rules": {
5625                        "unsued-exports": "off"
5626                    }
5627                }
5628            ]
5629        });
5630        let findings = collect_unknown_rule_keys(&merged);
5631        assert_eq!(
5632            findings.len(),
5633            1,
5634            "only the entry with rules should produce a finding"
5635        );
5636        assert_eq!(findings[0].context, "overrides[1].rules");
5637    }
5638
5639    // ------------------------------------------------------------------
5640    // FallowConfig::load: deserialization failure
5641    // ------------------------------------------------------------------
5642
5643    #[test]
5644    #[cfg_attr(miri, ignore)]
5645    fn load_fails_on_deserialization_error() {
5646        let dir = test_dir("deser-error");
5647        let path = dir.path().join(".fallowrc.json");
5648        // Valid JSON but contains a field value with the wrong type for the schema
5649        std::fs::write(&path, r#"{"entry": "not-an-array"}"#).unwrap();
5650
5651        let result = FallowConfig::load(&path);
5652        assert!(result.is_err());
5653        let err = result.unwrap_err().to_string();
5654        assert!(
5655            err.contains("invalid type") && err.contains(&path.display().to_string()),
5656            "error should lead with the reason and name the config file: {err}"
5657        );
5658    }
5659
5660    #[test]
5661    #[cfg_attr(miri, ignore)]
5662    fn unknown_override_field_error_names_path_once_and_hints_at_jsonc() {
5663        let dir = test_dir("override-unknown-field");
5664        let path = dir.path().join(".fallowrc.json");
5665        std::fs::write(
5666            &path,
5667            r#"{"overrides": [{"files": ["src/**"], "reason": "legacy"}]}"#,
5668        )
5669        .unwrap();
5670
5671        let err = FallowConfig::find_and_load(dir.path())
5672            .expect_err("unknown override field must be rejected");
5673        let path_str = path.display().to_string();
5674        assert!(
5675            err.contains("unknown field `reason`"),
5676            "error should lead with the serde reason: {err}"
5677        );
5678        assert_eq!(
5679            err.matches(&path_str).count(),
5680            1,
5681            "path must appear exactly once: {err}"
5682        );
5683        assert!(
5684            err.contains("// comment") && err.contains("JSONC"),
5685            "error should point annotations at JSONC comments: {err}"
5686        );
5687    }
5688
5689    // ------------------------------------------------------------------
5690    // FallowConfig::load: threshold override validation failure
5691    // (covers lines 921-927)
5692    // ------------------------------------------------------------------
5693
5694    #[test]
5695    #[cfg_attr(miri, ignore)]
5696    fn load_rejects_threshold_override_with_empty_files() {
5697        let dir = test_dir("threshold-empty-files");
5698        let path = dir.path().join(".fallowrc.json");
5699        std::fs::write(
5700            &path,
5701            r#"{
5702                "health": {
5703                    "thresholdOverrides": [
5704                        {"files": [], "maxCyclomatic": 30}
5705                    ]
5706                }
5707            }"#,
5708        )
5709        .unwrap();
5710
5711        let result = FallowConfig::load(&path);
5712        assert!(result.is_err());
5713        let err = result.unwrap_err().to_string();
5714        assert!(
5715            err.contains("thresholdOverrides"),
5716            "error should mention thresholdOverrides: {err}"
5717        );
5718        assert!(
5719            err.contains("files"),
5720            "error should name the files field: {err}"
5721        );
5722    }
5723
5724    #[test]
5725    #[cfg_attr(miri, ignore)]
5726    fn load_rejects_threshold_override_with_no_threshold_set() {
5727        let dir = test_dir("threshold-no-threshold");
5728        let path = dir.path().join(".fallowrc.json");
5729        std::fs::write(
5730            &path,
5731            r#"{
5732                "health": {
5733                    "thresholdOverrides": [
5734                        {"files": ["src/legacy.ts"]}
5735                    ]
5736                }
5737            }"#,
5738        )
5739        .unwrap();
5740
5741        let result = FallowConfig::load(&path);
5742        assert!(result.is_err());
5743        let err = result.unwrap_err().to_string();
5744        assert!(
5745            err.contains("maxCyclomatic")
5746                || err.contains("maxCognitive")
5747                || err.contains("maxCrap"),
5748            "error should name at least one threshold field: {err}"
5749        );
5750    }
5751
5752    // ------------------------------------------------------------------
5753    // validate_ignore_rule_globs: ignoreCatalogReferences consumer glob
5754    // validation (covers lines 1026-1032)
5755    // ------------------------------------------------------------------
5756
5757    #[test]
5758    #[cfg_attr(miri, ignore)]
5759    fn load_rejects_invalid_ignore_catalog_references_consumer_glob() {
5760        let dir = test_dir("invalid-catalog-consumer-glob");
5761        let path = dir.path().join(".fallowrc.json");
5762        std::fs::write(
5763            &path,
5764            r#"{
5765                "ignoreCatalogReferences": [
5766                    {"package": "react", "consumer": "[invalid-glob"}
5767                ]
5768            }"#,
5769        )
5770        .unwrap();
5771
5772        let result = FallowConfig::load(&path);
5773        assert!(result.is_err());
5774        let err = result.unwrap_err().to_string();
5775        assert!(
5776            err.contains("ignoreCatalogReferences"),
5777            "error should mention the field: {err}"
5778        );
5779    }
5780
5781    #[test]
5782    #[cfg_attr(miri, ignore)]
5783    fn load_accepts_ignore_catalog_references_without_consumer() {
5784        let dir = test_dir("catalog-ref-no-consumer");
5785        let path = dir.path().join(".fallowrc.json");
5786        std::fs::write(
5787            &path,
5788            r#"{"ignoreCatalogReferences": [{"package": "react"}]}"#,
5789        )
5790        .unwrap();
5791
5792        let config = FallowConfig::load(&path).unwrap();
5793        assert_eq!(config.ignore_catalog_references.len(), 1);
5794        assert!(config.ignore_catalog_references[0].consumer.is_none());
5795    }
5796
5797    #[test]
5798    #[cfg_attr(miri, ignore)]
5799    fn load_accepts_unused_component_props_ignore_pattern() {
5800        let dir = test_dir("unused-component-props-ignore-pattern");
5801        let path = dir.path().join(".fallowrc.json");
5802        std::fs::write(
5803            &path,
5804            r#"{"unusedComponentProps": {"ignorePattern": "^_"}}"#,
5805        )
5806        .unwrap();
5807
5808        let config = FallowConfig::load(&path).unwrap();
5809        assert_eq!(
5810            config.unused_component_props.ignore_pattern.as_deref(),
5811            Some("^_")
5812        );
5813    }
5814
5815    #[test]
5816    #[cfg_attr(miri, ignore)]
5817    fn load_rejects_invalid_unused_component_props_ignore_pattern() {
5818        let dir = test_dir("unused-component-props-bad-regex");
5819        let path = dir.path().join(".fallowrc.json");
5820        // `[` opens an unterminated character class: invalid regex.
5821        std::fs::write(&path, r#"{"unusedComponentProps": {"ignorePattern": "["}}"#).unwrap();
5822
5823        let result = FallowConfig::load(&path);
5824        assert!(result.is_err());
5825        let err = result.unwrap_err().to_string();
5826        assert!(
5827            err.contains("unusedComponentProps.ignorePattern"),
5828            "error should mention the field: {err}"
5829        );
5830    }
5831
5832    #[test]
5833    #[cfg_attr(miri, ignore)]
5834    fn load_rejects_unknown_unused_component_props_field() {
5835        let dir = test_dir("unused-component-props-unknown-field");
5836        let path = dir.path().join(".fallowrc.json");
5837        std::fs::write(
5838            &path,
5839            r#"{"unusedComponentProps": {"ignorePatterns": "^_"}}"#,
5840        )
5841        .unwrap();
5842
5843        // `deny_unknown_fields` rejects the plural typo.
5844        assert!(FallowConfig::load(&path).is_err());
5845    }
5846
5847    #[test]
5848    #[cfg_attr(miri, ignore)]
5849    fn load_rejects_unknown_override_entry_key() {
5850        let dir = test_dir("override-entry-unknown-key");
5851        let path = dir.path().join(".fallowrc.json");
5852        std::fs::write(
5853            &path,
5854            r#"{"overrides": [{"files": ["src/**"], "rule": {"unused-files": "off"}}]}"#,
5855        )
5856        .unwrap();
5857
5858        // `deny_unknown_fields` rejects the `rule` typo instead of silently
5859        // producing an override with empty rules.
5860        assert!(FallowConfig::load(&path).is_err());
5861    }
5862
5863    #[test]
5864    #[cfg_attr(miri, ignore)]
5865    fn load_rejects_unknown_ignore_exports_entry_key() {
5866        let dir = test_dir("ignore-exports-entry-unknown-key");
5867        let path = dir.path().join(".fallowrc.json");
5868        std::fs::write(
5869            &path,
5870            r#"{"ignoreExports": [{"file": "src/a.ts", "exports": ["*"], "reason": "legacy"}]}"#,
5871        )
5872        .unwrap();
5873
5874        assert!(FallowConfig::load(&path).is_err());
5875    }
5876
5877    // ------------------------------------------------------------------
5878    // validate_user_globs: framework plugin invalid glob triggers error path
5879    // (covers lines 970-974)
5880    // ------------------------------------------------------------------
5881
5882    #[test]
5883    fn validate_user_globs_framework_plugin_invalid_entry_glob() {
5884        use crate::ExternalPluginDef;
5885        use crate::external_plugin::EntryPointRole;
5886        let config = FallowConfig {
5887            framework: vec![ExternalPluginDef {
5888                schema: None,
5889                name: "test-plugin".to_owned(),
5890                detection: None,
5891                enablers: vec![],
5892                entry_points: vec!["[invalid-glob".to_owned()],
5893                entry_point_role: EntryPointRole::Support,
5894                manifest_entries: vec![],
5895                config_patterns: vec![],
5896                always_used: vec![],
5897                tooling_dependencies: vec![],
5898                used_exports: vec![],
5899                used_class_members: vec![],
5900            }],
5901            ..FallowConfig::default()
5902        };
5903
5904        let result = config.validate_user_globs();
5905        assert!(
5906            result.is_err(),
5907            "invalid entry_points glob should fail validation"
5908        );
5909        let errors = result.unwrap_err();
5910        assert!(!errors.is_empty());
5911    }
5912
5913    #[test]
5914    fn validate_user_globs_rejects_invalid_ignore_dependencies_glob() {
5915        let config = FallowConfig {
5916            ignore_dependencies: vec![
5917                "lodash".to_owned(),
5918                "@acme/*".to_owned(),
5919                "@broken/[".to_owned(),
5920            ],
5921            ..FallowConfig::default()
5922        };
5923
5924        let errors = config
5925            .validate_user_globs()
5926            .expect_err("an unclosed character class is not a valid glob");
5927        assert_eq!(errors.len(), 1);
5928        let message = errors[0].to_string();
5929        assert!(message.contains("ignoreDependencies"), "{message}");
5930        assert!(message.contains("@broken/["), "{message}");
5931    }
5932
5933    // ------------------------------------------------------------------
5934    // shadowed_config_names: no lower-precedence names after the last index
5935    // ------------------------------------------------------------------
5936
5937    #[test]
5938    #[cfg_attr(miri, ignore)]
5939    fn shadowed_config_names_empty_when_last_config_wins() {
5940        let dir = test_dir("shadow-last");
5941        std::fs::write(dir.path().join(".fallow.toml"), "").unwrap();
5942        // chosen_index = 3 (last), so skip+1 = 4, nothing to check
5943        assert!(shadowed_config_names(dir.path(), 3).is_empty());
5944    }
5945
5946    // ------------------------------------------------------------------
5947    // warn_on_coexisting_configs: path without filename (edge branch)
5948    // shadowed is empty -> early return without recording
5949    // ------------------------------------------------------------------
5950
5951    #[test]
5952    fn warn_on_coexisting_configs_empty_shadowed_is_silent() {
5953        let ((), captured) = capture_coexisting_config_warnings(|| {
5954            warn_on_coexisting_configs(Path::new(".fallowrc.json"), &[]);
5955        });
5956        assert!(
5957            captured.is_empty(),
5958            "empty shadowed list must produce no warning"
5959        );
5960    }
5961
5962    // ------------------------------------------------------------------
5963    // extract_extends: malformed values fail loud instead of silently
5964    // skipping the base config (the key is removed before deserialization,
5965    // so deny_unknown_fields cannot catch these)
5966    // ------------------------------------------------------------------
5967
5968    #[test]
5969    fn extract_extends_rejects_number_value() {
5970        let mut value = serde_json::json!({"extends": 42});
5971        let err = extract_extends(&mut value, "test.json").unwrap_err();
5972        assert!(
5973            err.to_string()
5974                .contains("extends must be a string or an array of strings, got a number"),
5975            "unexpected error: {err}"
5976        );
5977    }
5978
5979    #[test]
5980    fn extract_extends_rejects_object_value() {
5981        let mut value = serde_json::json!({"extends": {"path": "./base.json"}});
5982        let err = extract_extends(&mut value, "test.json").unwrap_err();
5983        assert!(
5984            err.to_string()
5985                .contains("extends must be a string or an array of strings, got an object"),
5986            "unexpected error: {err}"
5987        );
5988    }
5989
5990    #[test]
5991    fn extract_extends_rejects_non_string_array_entry() {
5992        let mut value = serde_json::json!({"extends": ["a.json", 42]});
5993        let err = extract_extends(&mut value, "test.json").unwrap_err();
5994        assert!(
5995            err.to_string()
5996                .contains("extends entries must be strings, got a number"),
5997            "unexpected error: {err}"
5998        );
5999    }
6000
6001    #[test]
6002    #[cfg_attr(miri, ignore)]
6003    fn load_rejects_malformed_extends_value() {
6004        let dir = test_dir("malformed-extends");
6005        let path = dir.path().join(".fallowrc.json");
6006        std::fs::write(&path, r#"{"extends": 42}"#).unwrap();
6007
6008        let err = FallowConfig::load(&path).unwrap_err();
6009        assert!(
6010            err.to_string()
6011                .contains("extends must be a string or an array of strings"),
6012            "unexpected error: {err}"
6013        );
6014    }
6015
6016    // ------------------------------------------------------------------
6017    // Typed resource identities keep local and remote namespaces disjoint.
6018    // ------------------------------------------------------------------
6019
6020    #[test]
6021    #[cfg_attr(miri, ignore)]
6022    fn config_resource_identity_cannot_collide_across_kinds() {
6023        let dir = test_dir("visit-circular");
6024        let path = dir.path().join("config.json");
6025        std::fs::write(&path, "{}").unwrap();
6026
6027        let canonical = dunce::canonicalize(path).unwrap();
6028        let local = ConfigResourceId::Local(canonical.clone());
6029        let remote = ConfigResourceId::Remote(canonical.to_string_lossy().into_owned());
6030        assert_ne!(local, remote);
6031    }
6032
6033    // ------------------------------------------------------------------
6034    // merge_order stays bounded by the distinct sources of a lattice.
6035    // ------------------------------------------------------------------
6036
6037    #[test]
6038    #[cfg_attr(miri, ignore)]
6039    fn merge_order_lists_each_source_of_an_extends_lattice_once() {
6040        const LEVELS: usize = 4;
6041        const WIDTH: usize = 3;
6042        let dir = test_dir("merge-order-lattice");
6043        let name = |level: usize, index: usize| format!("l{level}-{index}.json");
6044        for level in 0..LEVELS {
6045            for index in 0..WIDTH {
6046                let extends: Vec<String> = if level == 0 {
6047                    Vec::new()
6048                } else {
6049                    (0..WIDTH)
6050                        .map(|below| format!("./{}", name(level - 1, below)))
6051                        .collect()
6052                };
6053                let body = serde_json::json!({ "extends": extends });
6054                std::fs::write(dir.path().join(name(level, index)), body.to_string()).unwrap();
6055            }
6056        }
6057        let root = dir.path().join(".fallowrc.json");
6058        let top: Vec<String> = (0..WIDTH)
6059            .map(|index| format!("./{}", name(LEVELS - 1, index)))
6060            .collect();
6061        std::fs::write(&root, serde_json::json!({ "extends": top }).to_string()).unwrap();
6062
6063        let order = FallowConfig::merge_order(&root).expect("lattice resolves");
6064
6065        let distinct: FxHashSet<&Option<PathBuf>> = order.iter().collect();
6066        assert_eq!(distinct.len(), order.len(), "each source appears once");
6067        assert_eq!(order.len(), LEVELS * WIDTH + 1);
6068        assert_eq!(
6069            order.last(),
6070            Some(&Some(dunce::canonicalize(&root).unwrap())),
6071            "the root config merges last"
6072        );
6073    }
6074
6075    // ------------------------------------------------------------------
6076    // find_and_load: stops at .svn dir (is_repo_root branch)
6077    // ------------------------------------------------------------------
6078
6079    #[test]
6080    #[cfg_attr(miri, ignore)]
6081    fn find_config_path_stops_at_svn_dir() {
6082        let dir = test_dir("find-path-svn");
6083        let sub = dir.path().join("sub");
6084        std::fs::create_dir(&sub).unwrap();
6085        std::fs::create_dir(dir.path().join(".svn")).unwrap();
6086
6087        let path = FallowConfig::find_config_path(&sub);
6088        assert!(path.is_none(), "svn root should stop config search");
6089    }
6090
6091    // ------------------------------------------------------------------
6092    // deep_merge: array over object replaces
6093    // ------------------------------------------------------------------
6094
6095    #[test]
6096    fn deep_merge_array_over_object_replaces() {
6097        let mut base = serde_json::json!({"key": "value"});
6098        deep_merge_json(&mut base, serde_json::json!(["a", "b"]));
6099        assert_eq!(base, serde_json::json!(["a", "b"]));
6100    }
6101
6102    // ------------------------------------------------------------------
6103    // find_and_load: returns an error when config parses but glob validation fails
6104    // ------------------------------------------------------------------
6105
6106    #[test]
6107    #[cfg_attr(miri, ignore)]
6108    fn find_and_load_returns_error_for_invalid_glob_in_config() {
6109        let dir = test_dir("find-invalid-glob");
6110        std::fs::create_dir(dir.path().join(".git")).unwrap();
6111        std::fs::write(
6112            dir.path().join(".fallowrc.json"),
6113            r#"{"entry": ["[invalid-glob"]}"#,
6114        )
6115        .unwrap();
6116
6117        let result = FallowConfig::find_and_load(dir.path());
6118        assert!(
6119            result.is_err(),
6120            "invalid glob should surface as an error from find_and_load"
6121        );
6122    }
6123
6124    #[test]
6125    #[cfg_attr(miri, ignore)]
6126    fn find_and_load_rejects_bare_finding_ignore_negation() {
6127        let dir = test_dir("find-bare-finding-ignore-negation");
6128        std::fs::create_dir(dir.path().join(".git")).unwrap();
6129        std::fs::write(
6130            dir.path().join(".fallowrc.json"),
6131            r#"{"ignoreFindings": ["!"]}"#,
6132        )
6133        .unwrap();
6134
6135        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
6136        assert!(error.contains("ignoreFindings"), "error: {error}");
6137        assert!(error.contains("requires a pattern"), "error: {error}");
6138    }
6139
6140    #[test]
6141    #[cfg_attr(miri, ignore)]
6142    fn find_and_load_rejects_finding_ignore_set_that_exceeds_matcher_limits() {
6143        let dir = test_dir("find-oversized-finding-ignore-set");
6144        std::fs::create_dir(dir.path().join(".git")).unwrap();
6145        let alternatives = (0..50_000)
6146            .map(|index| format!("name{index}"))
6147            .collect::<Vec<_>>()
6148            .join(",");
6149        let config = serde_json::json!({
6150            "ignoreFindings": [format!("**/{{{alternatives}}}.ts")]
6151        });
6152        std::fs::write(
6153            dir.path().join(".fallowrc.json"),
6154            serde_json::to_vec(&config).unwrap(),
6155        )
6156        .unwrap();
6157
6158        let error = FallowConfig::find_and_load(dir.path()).unwrap_err();
6159        assert!(error.contains("ignoreFindings"), "error: {error}");
6160        assert!(
6161            error.contains("cannot be compiled together"),
6162            "error: {error}"
6163        );
6164    }
6165
6166    // ------------------------------------------------------------------
6167    // resolve_package_exports: Object map with "." key that is not a
6168    // string or object returns None (the `_ => None` arm)
6169    // ------------------------------------------------------------------
6170
6171    #[test]
6172    fn resolve_package_exports_dot_key_array_returns_none() {
6173        // "." value is an array, which is neither String nor Object
6174        let pkg = serde_json::json!({
6175            "exports": {".": ["array-value"]}
6176        });
6177        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
6178        assert!(result.is_none(), "array dot-export should return None");
6179    }
6180
6181    #[test]
6182    fn resolve_package_exports_exports_is_array_returns_none() {
6183        // top-level "exports" is an array (not String or Object)
6184        let pkg = serde_json::json!({
6185            "exports": ["./index.js"]
6186        });
6187        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
6188        assert!(result.is_none(), "array-form exports should return None");
6189    }
6190
6191    #[test]
6192    fn resolve_package_exports_object_no_dot_key_returns_none() {
6193        // Object exports without "." key
6194        let pkg = serde_json::json!({
6195            "exports": {"./sub": "./sub.js"}
6196        });
6197        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
6198        assert!(result.is_none(), "no dot key should return None");
6199    }
6200
6201    #[test]
6202    fn resolve_package_exports_conditions_without_known_key_returns_none() {
6203        // "." is an Object but none of the known condition keys are present
6204        let pkg = serde_json::json!({
6205            "exports": {".": {"browser": "./browser.js"}}
6206        });
6207        let result = resolve_package_exports(&pkg, Path::new("/tmp"));
6208        assert!(result.is_none(), "unknown condition key should return None");
6209    }
6210
6211    // ------------------------------------------------------------------
6212    // npm package: exports condition "import" key (one of the priority keys)
6213    // ------------------------------------------------------------------
6214
6215    #[test]
6216    #[cfg_attr(miri, ignore)]
6217    fn extends_npm_exports_import_condition() {
6218        let dir = test_dir("npm-import-cond");
6219        let pkg_dir = dir.path().join("node_modules/import-config");
6220        std::fs::create_dir_all(&pkg_dir).unwrap();
6221        std::fs::write(
6222            pkg_dir.join("package.json"),
6223            r#"{"name": "import-config", "exports": {".": {"import": "./esm.json"}}}"#,
6224        )
6225        .unwrap();
6226        std::fs::write(
6227            pkg_dir.join("esm.json"),
6228            r#"{"rules": {"unused-types": "warn"}}"#,
6229        )
6230        .unwrap();
6231
6232        std::fs::write(
6233            dir.path().join(".fallowrc.json"),
6234            r#"{"extends": "npm:import-config"}"#,
6235        )
6236        .unwrap();
6237
6238        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
6239        assert_eq!(config.rules.unused_types, Severity::Warn);
6240    }
6241
6242    // ------------------------------------------------------------------
6243    // npm package: exports condition "require" key
6244    // ------------------------------------------------------------------
6245
6246    #[test]
6247    #[cfg_attr(miri, ignore)]
6248    fn extends_npm_exports_require_condition() {
6249        let dir = test_dir("npm-require-cond");
6250        let pkg_dir = dir.path().join("node_modules/require-config");
6251        std::fs::create_dir_all(&pkg_dir).unwrap();
6252        std::fs::write(
6253            pkg_dir.join("package.json"),
6254            r#"{"name": "require-config", "exports": {".": {"require": "./cjs.json"}}}"#,
6255        )
6256        .unwrap();
6257        std::fs::write(
6258            pkg_dir.join("cjs.json"),
6259            r#"{"rules": {"unused-class-members": "warn"}}"#,
6260        )
6261        .unwrap();
6262
6263        std::fs::write(
6264            dir.path().join(".fallowrc.json"),
6265            r#"{"extends": "npm:require-config"}"#,
6266        )
6267        .unwrap();
6268
6269        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
6270        assert_eq!(config.rules.unused_class_members, Severity::Warn);
6271    }
6272}