Skip to main content

fallow_config/config/
parsing.rs

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