Skip to main content

fallow_config/config/
parsing.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rustc_hash::FxHashSet;
6
7use super::FallowConfig;
8
9/// Supported config file names in priority order.
10///
11/// `find_and_load` checks these names in order within each directory,
12/// returning the first match found.
13pub(super) const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
14
15pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
16
17/// Prefix for npm package specifiers in the `extends` field.
18const NPM_PREFIX: &str = "npm:";
19
20/// Prefix for HTTPS URL specifiers in the `extends` field.
21const HTTPS_PREFIX: &str = "https://";
22
23/// Prefix for HTTP URL specifiers (rejected with a clear error).
24const HTTP_PREFIX: &str = "http://";
25
26/// Default timeout for fetching remote configs via URL extends.
27const DEFAULT_URL_TIMEOUT_SECS: u64 = 5;
28
29/// Detect config format from file extension.
30pub(super) enum ConfigFormat {
31    Toml,
32    Json,
33}
34
35impl ConfigFormat {
36    pub(super) fn from_path(path: &Path) -> Self {
37        match path.extension().and_then(|e| e.to_str()) {
38            Some("json") => Self::Json,
39            _ => Self::Toml,
40        }
41    }
42}
43
44/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
45/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
46pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
47    match (base, overlay) {
48        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
49            for (key, value) in overlay_map {
50                if let Some(base_value) = base_map.get_mut(&key) {
51                    deep_merge_json(base_value, value);
52                } else {
53                    base_map.insert(key, value);
54                }
55            }
56        }
57        (base, overlay) => {
58            *base = overlay;
59        }
60    }
61}
62
63pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
64    let content = std::fs::read_to_string(path)
65        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
66
67    match ConfigFormat::from_path(path) {
68        ConfigFormat::Toml => {
69            let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
70                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
71            })?;
72            serde_json::to_value(toml_value).map_err(|e| {
73                miette::miette!(
74                    "Failed to convert TOML to JSON for {}: {}",
75                    path.display(),
76                    e
77                )
78            })
79        }
80        ConfigFormat::Json => {
81            let mut stripped = String::new();
82            json_comments::StripComments::new(content.as_bytes())
83                .read_to_string(&mut stripped)
84                .map_err(|e| {
85                    miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
86                })?;
87            serde_json::from_str(&stripped).map_err(|e| {
88                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
89            })
90        }
91    }
92}
93
94/// Return `true` if `dir` contains a VCS marker indicating a repository root.
95///
96/// Used as the walk-up stop condition for config discovery. Matches `.git`
97/// (directory for normal repos, file for git submodules/worktrees), `.hg`
98/// (Mercurial), and `.svn` (Subversion). We intentionally do NOT treat
99/// `package.json` as a stop boundary so monorepo sub-packages can inherit a
100/// root config. This matches Prettier/ESLint/Biome behavior.
101fn is_repo_root(dir: &Path) -> bool {
102    dir.join(".git").exists() || dir.join(".hg").exists() || dir.join(".svn").exists()
103}
104
105/// Verify that `resolved` stays within `base_dir` after canonicalization.
106///
107/// Prevents path traversal attacks where a subpath or `package.json` field
108/// like `../../etc/passwd` escapes the intended directory.
109fn resolve_confined(
110    base_dir: &Path,
111    resolved: &Path,
112    context: &str,
113    source_config: &Path,
114) -> Result<PathBuf, miette::Report> {
115    let canonical_base = dunce::canonicalize(base_dir)
116        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
117    let canonical_file = dunce::canonicalize(resolved).map_err(|e| {
118        miette::miette!(
119            "Config file not found: {} ({}, referenced from {}): {}",
120            resolved.display(),
121            context,
122            source_config.display(),
123            e
124        )
125    })?;
126    if !canonical_file.starts_with(&canonical_base) {
127        return Err(miette::miette!(
128            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
129            resolved.display(),
130            base_dir.display(),
131            context,
132            source_config.display()
133        ));
134    }
135    Ok(canonical_file)
136}
137
138/// Validate that a parsed package name is a legal npm package name.
139fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
140    if name.starts_with('@') && !name.contains('/') {
141        return Err(miette::miette!(
142            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
143            name,
144            source_config.display()
145        ));
146    }
147    if name.split('/').any(|c| c == ".." || c == ".") {
148        return Err(miette::miette!(
149            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
150            name,
151            source_config.display()
152        ));
153    }
154    Ok(())
155}
156
157/// Parse an npm specifier into `(package_name, optional_subpath)`.
158///
159/// Scoped: `@scope/name` → `("@scope/name", None)`,
160///         `@scope/name/strict.json` → `("@scope/name", Some("strict.json"))`.
161/// Unscoped: `name` → `("name", None)`,
162///           `name/strict.json` → `("name", Some("strict.json"))`.
163fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
164    if specifier.starts_with('@') {
165        // Scoped: @scope/name[/subpath]
166        // Find the second '/' which separates name from subpath.
167        let mut slashes = 0;
168        for (i, ch) in specifier.char_indices() {
169            if ch == '/' {
170                slashes += 1;
171                if slashes == 2 {
172                    return (&specifier[..i], Some(&specifier[i + 1..]));
173                }
174            }
175        }
176        // No subpath — entire string is the package name.
177        (specifier, None)
178    } else if let Some(slash) = specifier.find('/') {
179        (&specifier[..slash], Some(&specifier[slash + 1..]))
180    } else {
181        (specifier, None)
182    }
183}
184
185/// Resolve the default export path from a `package.json` `exports` field.
186///
187/// Handles the common patterns:
188/// - `"exports": "./config.json"` (string shorthand)
189/// - `"exports": {".": "./config.json"}` (object with default entry point)
190/// - `"exports": {".": {"default": "./config.json"}}` (conditional exports)
191fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
192    let exports = pkg.get("exports")?;
193    match exports {
194        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
195        serde_json::Value::Object(map) => {
196            let dot_export = map.get(".")?;
197            match dot_export {
198                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
199                serde_json::Value::Object(conditions) => {
200                    for key in ["default", "node", "import", "require"] {
201                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
202                            return Some(package_dir.join(s.as_str()));
203                        }
204                    }
205                    None
206                }
207                _ => None,
208            }
209        }
210        // Array export fallback form (e.g., `[\"./config.json\", null]`) is not supported;
211        // falls through to main/config name scan.
212        _ => None,
213    }
214}
215
216/// Find a fallow config file inside an npm package directory.
217///
218/// Resolution order:
219/// 1. `package.json` `exports` field (default entry point)
220/// 2. `package.json` `main` field
221/// 3. Standard config file names (`.fallowrc.json`, `fallow.toml`, `.fallow.toml`)
222///
223/// Paths from `exports`/`main` are confined to the package directory to prevent
224/// path traversal attacks from malicious packages.
225fn find_config_in_npm_package(
226    package_dir: &Path,
227    source_config: &Path,
228) -> Result<PathBuf, miette::Report> {
229    let pkg_json_path = package_dir.join("package.json");
230    if pkg_json_path.exists() {
231        let content = std::fs::read_to_string(&pkg_json_path)
232            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
233        let pkg: serde_json::Value = serde_json::from_str(&content)
234            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
235        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
236            && config_path.exists()
237        {
238            return resolve_confined(
239                package_dir,
240                &config_path,
241                "package.json exports",
242                source_config,
243            );
244        }
245        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
246            let main_path = package_dir.join(main);
247            if main_path.exists() {
248                return resolve_confined(
249                    package_dir,
250                    &main_path,
251                    "package.json main",
252                    source_config,
253                );
254            }
255        }
256    }
257
258    for config_name in CONFIG_NAMES {
259        let config_path = package_dir.join(config_name);
260        if config_path.exists() {
261            return resolve_confined(
262                package_dir,
263                &config_path,
264                "config name fallback",
265                source_config,
266            );
267        }
268    }
269
270    Err(miette::miette!(
271        "No fallow config found in npm package at {}. \
272         Expected package.json with main/exports pointing to a config file, \
273         or one of: {}",
274        package_dir.display(),
275        CONFIG_NAMES.join(", ")
276    ))
277}
278
279/// Resolve an npm package specifier to a config file path.
280///
281/// Walks up from `config_dir` looking for `node_modules/<package_name>`.
282/// If a subpath is given (e.g., `@scope/name/strict.json`), resolves that file directly.
283/// Otherwise, finds the config file inside the package via [`find_config_in_npm_package`].
284fn resolve_npm_package(
285    config_dir: &Path,
286    specifier: &str,
287    source_config: &Path,
288) -> Result<PathBuf, miette::Report> {
289    let specifier = specifier.trim();
290    if specifier.is_empty() {
291        return Err(miette::miette!(
292            "Empty npm specifier in extends (in {})",
293            source_config.display()
294        ));
295    }
296
297    let (package_name, subpath) = parse_npm_specifier(specifier);
298    validate_npm_package_name(package_name, source_config)?;
299
300    let mut dir = Some(config_dir);
301    while let Some(d) = dir {
302        let candidate = d.join("node_modules").join(package_name);
303        if candidate.is_dir() {
304            return if let Some(sub) = subpath {
305                let file = candidate.join(sub);
306                if file.exists() {
307                    resolve_confined(
308                        &candidate,
309                        &file,
310                        &format!("subpath '{sub}'"),
311                        source_config,
312                    )
313                } else {
314                    Err(miette::miette!(
315                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
316                        file.display(),
317                        sub,
318                        candidate.display(),
319                        source_config.display()
320                    ))
321                }
322            } else {
323                find_config_in_npm_package(&candidate, source_config)
324            };
325        }
326        dir = d.parent();
327    }
328
329    Err(miette::miette!(
330        "npm package '{}' not found. \
331         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
332         If this package should be available, install it and ensure it is listed in your project's dependencies",
333        package_name,
334        package_name,
335        config_dir.display(),
336        source_config.display()
337    ))
338}
339
340/// Normalize a URL for deduplication.
341///
342/// - Lowercase scheme and host (path casing is preserved — it's server-dependent).
343/// - Strip fragment (`#...`) and query string (`?...`).
344/// - Strip trailing slash from path.
345/// - Normalize default HTTPS port (`:443` → omitted).
346fn normalize_url_for_dedup(url: &str) -> String {
347    // Split at the first `://` to get scheme, then find host boundary.
348    let Some((scheme, rest)) = url.split_once("://") else {
349        return url.to_string();
350    };
351    let scheme = scheme.to_ascii_lowercase();
352
353    // Split host from path at the first `/` after the authority.
354    let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
355    let authority = authority.to_ascii_lowercase();
356
357    // Strip default HTTPS port.
358    let authority = authority.strip_suffix(":443").unwrap_or(&authority);
359
360    // Strip fragment and query string from path, then trailing slash.
361    let path = path.split_once('#').map_or(path, |(p, _)| p);
362    let path = path.split_once('?').map_or(path, |(p, _)| p);
363    let path = path.strip_suffix('/').unwrap_or(path);
364
365    if path.is_empty() {
366        format!("{scheme}://{authority}")
367    } else {
368        format!("{scheme}://{authority}/{path}")
369    }
370}
371
372/// Read the `FALLOW_EXTENDS_TIMEOUT_SECS` env var, falling back to [`DEFAULT_URL_TIMEOUT_SECS`].
373///
374/// A value of `0` is treated as invalid and falls back to the default (a zero-duration
375/// timeout would make every request fail immediately with an opaque timeout error).
376fn url_timeout() -> Duration {
377    std::env::var("FALLOW_EXTENDS_TIMEOUT_SECS")
378        .ok()
379        .and_then(|v| v.parse::<u64>().ok().filter(|&n| n > 0))
380        .map_or(
381            Duration::from_secs(DEFAULT_URL_TIMEOUT_SECS),
382            Duration::from_secs,
383        )
384}
385
386/// Maximum response body size for fetched config files (1 MB).
387/// Config files are never legitimately larger than a few kilobytes.
388const MAX_URL_CONFIG_BYTES: u64 = 1024 * 1024;
389
390/// Fetch a remote JSON config from an HTTPS URL.
391///
392/// Returns the parsed `serde_json::Value`. Only JSON (with optional JSONC comments) is
393/// supported for URL-sourced configs — TOML cannot be detected without a file extension.
394fn fetch_url_config(url: &str, source: &str) -> Result<serde_json::Value, miette::Report> {
395    let timeout = url_timeout();
396    let agent = ureq::Agent::config_builder()
397        .timeout_global(Some(timeout))
398        .https_only(true)
399        .build()
400        .new_agent();
401
402    let mut response = agent.get(url).call().map_err(|e| {
403        miette::miette!(
404            "Failed to fetch remote config from {url} (referenced from {source}): {e}. \
405             If this URL is unavailable, use a local path or npm: specifier instead"
406        )
407    })?;
408
409    let body = response
410        .body_mut()
411        .with_config()
412        .limit(MAX_URL_CONFIG_BYTES)
413        .read_to_string()
414        .map_err(|e| {
415            miette::miette!(
416                "Failed to read response body from {url} (referenced from {source}): {e}"
417            )
418        })?;
419
420    // Strip JSONC comments before parsing.
421    let mut stripped = String::new();
422    json_comments::StripComments::new(body.as_bytes())
423        .read_to_string(&mut stripped)
424        .map_err(|e| {
425            miette::miette!(
426                "Failed to strip comments from remote config {url} (referenced from {source}): {e}"
427            )
428        })?;
429
430    serde_json::from_str(&stripped).map_err(|e| {
431        miette::miette!(
432            "Failed to parse remote config as JSON from {url} (referenced from {source}): {e}. \
433             Only JSON/JSONC is supported for URL-sourced configs"
434        )
435    })
436}
437
438/// Extract the `extends` array from a parsed JSON config value, removing it from the object.
439fn extract_extends(value: &mut serde_json::Value) -> Vec<String> {
440    value
441        .as_object_mut()
442        .and_then(|obj| obj.remove("extends"))
443        .and_then(|v| match v {
444            serde_json::Value::Array(arr) => Some(
445                arr.into_iter()
446                    .filter_map(|v| v.as_str().map(String::from))
447                    .collect::<Vec<_>>(),
448            ),
449            serde_json::Value::String(s) => Some(vec![s]),
450            _ => None,
451        })
452        .unwrap_or_default()
453}
454
455/// Resolve extends entries from a URL-sourced config.
456///
457/// URL-sourced configs may extend other URLs or `npm:` packages, but NOT relative
458/// paths (there is no filesystem base directory for a URL).
459fn resolve_url_extends(
460    url: &str,
461    visited: &mut FxHashSet<String>,
462    depth: usize,
463) -> Result<serde_json::Value, miette::Report> {
464    if depth >= MAX_EXTENDS_DEPTH {
465        return Err(miette::miette!(
466            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {url}"
467        ));
468    }
469
470    let normalized = normalize_url_for_dedup(url);
471    if !visited.insert(normalized) {
472        return Err(miette::miette!(
473            "Circular extends detected: {url} was already visited in the extends chain"
474        ));
475    }
476
477    let mut value = fetch_url_config(url, url)?;
478    let extends = extract_extends(&mut value);
479
480    if extends.is_empty() {
481        return Ok(value);
482    }
483
484    let mut merged = serde_json::Value::Object(serde_json::Map::new());
485
486    for entry in &extends {
487        let base = if entry.starts_with(HTTPS_PREFIX) {
488            resolve_url_extends(entry, visited, depth + 1)?
489        } else if entry.starts_with(HTTP_PREFIX) {
490            return Err(miette::miette!(
491                "URL extends must use https://, got http:// URL '{}' (in remote config {}). \
492                 Change the URL to use https:// instead",
493                entry,
494                url
495            ));
496        } else if let Some(npm_specifier) = entry.strip_prefix(NPM_PREFIX) {
497            // npm: from URL context — no config_dir to walk up from, so we use the cwd.
498            // This is a best-effort fallback; the npm package must be available in the
499            // working directory's node_modules tree.
500            let cwd = std::env::current_dir().map_err(|e| {
501                miette::miette!(
502                    "Cannot resolve npm: specifier from URL-sourced config: \
503                     failed to determine current directory: {e}"
504                )
505            })?;
506            tracing::warn!(
507                "Resolving npm:{npm_specifier} from URL-sourced config ({url}) using the \
508                 current working directory for node_modules lookup"
509            );
510            let path_placeholder = PathBuf::from(url);
511            let npm_path = resolve_npm_package(&cwd, npm_specifier, &path_placeholder)?;
512            resolve_extends_file(&npm_path, visited, depth + 1)?
513        } else {
514            return Err(miette::miette!(
515                "Relative paths in 'extends' are not supported when the base config was \
516                 fetched from a URL ('{url}'). Use another https:// URL or npm: reference \
517                 instead. Got: '{entry}'"
518            ));
519        };
520        deep_merge_json(&mut merged, base);
521    }
522
523    deep_merge_json(&mut merged, value);
524    Ok(merged)
525}
526
527/// Resolve extends from a local config file.
528///
529/// This is the main recursive resolver for file-based configs. It reads the file,
530/// extracts `extends`, and recursively resolves each entry (relative paths, npm
531/// packages, or HTTPS URLs).
532fn resolve_extends_file(
533    path: &Path,
534    visited: &mut FxHashSet<String>,
535    depth: usize,
536) -> Result<serde_json::Value, miette::Report> {
537    if depth >= MAX_EXTENDS_DEPTH {
538        return Err(miette::miette!(
539            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
540            path.display()
541        ));
542    }
543
544    let canonical = dunce::canonicalize(path).map_err(|e| {
545        miette::miette!(
546            "Config file not found or unresolvable: {}: {}",
547            path.display(),
548            e
549        )
550    })?;
551
552    if !visited.insert(canonical.to_string_lossy().into_owned()) {
553        return Err(miette::miette!(
554            "Circular extends detected: {} was already visited in the extends chain",
555            path.display()
556        ));
557    }
558
559    let mut value = parse_config_to_value(path)?;
560    let extends = extract_extends(&mut value);
561
562    if extends.is_empty() {
563        return Ok(value);
564    }
565
566    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
567    let sealed = value
568        .get("sealed")
569        .and_then(serde_json::Value::as_bool)
570        .unwrap_or(false);
571    // Canonicalize the config directory once when sealed; reused inside the
572    // loop for each `extends` confinement check.
573    let sealed_dir_canonical = if sealed {
574        Some(dunce::canonicalize(config_dir).map_err(|e| {
575            miette::miette!(
576                "Sealed config directory '{}' could not be canonicalized: {e}",
577                config_dir.display()
578            )
579        })?)
580    } else {
581        None
582    };
583    let mut merged = serde_json::Value::Object(serde_json::Map::new());
584
585    for extend_path_str in &extends {
586        let base = if extend_path_str.starts_with(HTTPS_PREFIX) {
587            if sealed {
588                return Err(miette::miette!(
589                    "'sealed: true' config at {} rejects URL extends '{}'. \
590                     Sealed configs only allow file-relative extends within \
591                     the config's directory",
592                    path.display(),
593                    extend_path_str
594                ));
595            }
596            resolve_url_extends(extend_path_str, visited, depth + 1)?
597        } else if extend_path_str.starts_with(HTTP_PREFIX) {
598            return Err(miette::miette!(
599                "URL extends must use https://, got http:// URL '{}' (in {}). \
600                 Change the URL to use https:// instead",
601                extend_path_str,
602                path.display()
603            ));
604        } else if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
605            if sealed {
606                return Err(miette::miette!(
607                    "'sealed: true' config at {} rejects npm extends '{}'. \
608                     Sealed configs only allow file-relative extends within \
609                     the config's directory",
610                    path.display(),
611                    extend_path_str
612                ));
613            }
614            let npm_path = resolve_npm_package(config_dir, npm_specifier, path)?;
615            resolve_extends_file(&npm_path, visited, depth + 1)?
616        } else {
617            if Path::new(extend_path_str).is_absolute() {
618                return Err(miette::miette!(
619                    "extends paths must be relative, got absolute path: {} (in {})",
620                    extend_path_str,
621                    path.display()
622                ));
623            }
624            let p = config_dir.join(extend_path_str);
625            if !p.exists() {
626                return Err(miette::miette!(
627                    "Extended config file not found: {} (referenced from {})",
628                    p.display(),
629                    path.display()
630                ));
631            }
632            if let Some(dir_canonical) = &sealed_dir_canonical {
633                let p_canonical = dunce::canonicalize(&p).map_err(|e| {
634                    miette::miette!(
635                        "Sealed config extends path '{}' could not be canonicalized: {e}",
636                        p.display()
637                    )
638                })?;
639                if !p_canonical.starts_with(dir_canonical) {
640                    return Err(miette::miette!(
641                        "'sealed: true' config at {} rejects extends '{}' which resolves \
642                         outside the config's directory ({}). Sealed configs only allow \
643                         extends within the config's directory",
644                        path.display(),
645                        extend_path_str,
646                        p_canonical.display()
647                    ));
648                }
649            }
650            resolve_extends_file(&p, visited, depth + 1)?
651        };
652        deep_merge_json(&mut merged, base);
653    }
654
655    deep_merge_json(&mut merged, value);
656    Ok(merged)
657}
658
659/// Public entry point: resolve a config file with all its extends chain.
660///
661/// Delegates to [`resolve_extends_file`] with a fresh visited set.
662pub(super) fn resolve_extends(
663    path: &Path,
664    visited: &mut FxHashSet<String>,
665    depth: usize,
666) -> Result<serde_json::Value, miette::Report> {
667    resolve_extends_file(path, visited, depth)
668}
669
670impl FallowConfig {
671    /// Load config from a fallow config file (TOML or JSON/JSONC).
672    ///
673    /// The format is detected from the file extension:
674    /// - `.toml` → TOML
675    /// - `.json` → JSON (with JSONC comment stripping)
676    ///
677    /// Supports `extends` for config inheritance. Extended configs are loaded
678    /// and deep-merged before this config's values are applied.
679    ///
680    /// # Errors
681    ///
682    /// Returns an error when the config file cannot be read, merged, or deserialized.
683    pub fn load(path: &Path) -> Result<Self, miette::Report> {
684        let mut visited = FxHashSet::default();
685        let merged = resolve_extends(path, &mut visited, 0)?;
686
687        serde_json::from_value(merged).map_err(|e| {
688            miette::miette!(
689                "Failed to deserialize config from {}: {}",
690                path.display(),
691                e
692            )
693        })
694    }
695
696    /// Find the config file path without loading it.
697    /// Searches the same locations as `find_and_load`.
698    #[must_use]
699    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
700        let mut dir = start;
701        loop {
702            for name in CONFIG_NAMES {
703                let candidate = dir.join(name);
704                if candidate.exists() {
705                    return Some(candidate);
706                }
707            }
708            if is_repo_root(dir) {
709                break;
710            }
711            dir = dir.parent()?;
712        }
713        None
714    }
715
716    /// Find and load config, searching from `start` up to the project root.
717    ///
718    /// # Errors
719    ///
720    /// Returns an error if a config file is found but cannot be read or parsed.
721    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
722        let mut dir = start;
723        loop {
724            for name in CONFIG_NAMES {
725                let candidate = dir.join(name);
726                if candidate.exists() {
727                    match Self::load(&candidate) {
728                        Ok(config) => return Ok(Some((config, candidate))),
729                        Err(e) => {
730                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
731                        }
732                    }
733                }
734            }
735            // Stop at project root indicators (VCS markers). We intentionally
736            // do NOT stop at `package.json` so that monorepo sub-packages
737            // inherit a root config placed alongside the workspace root.
738            if is_repo_root(dir) {
739                break;
740            }
741            dir = match dir.parent() {
742                Some(parent) => parent,
743                None => break,
744            };
745        }
746        Ok(None)
747    }
748
749    /// Generate JSON Schema for the configuration format.
750    #[must_use]
751    pub fn json_schema() -> serde_json::Value {
752        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use std::io::Read as _;
759
760    use super::*;
761    use crate::PackageJson;
762    use crate::config::format::OutputFormat;
763    use crate::config::rules::Severity;
764
765    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
766    fn test_dir(_name: &str) -> tempfile::TempDir {
767        tempfile::tempdir().expect("create temp dir")
768    }
769
770    #[test]
771    fn fallow_config_deserialize_minimal() {
772        let toml_str = r#"
773entry = ["src/main.ts"]
774"#;
775        let config: FallowConfig = toml::from_str(toml_str).unwrap();
776        assert_eq!(config.entry, vec!["src/main.ts"]);
777        assert!(config.ignore_patterns.is_empty());
778    }
779
780    #[test]
781    fn fallow_config_deserialize_ignore_exports() {
782        let toml_str = r#"
783[[ignoreExports]]
784file = "src/types/*.ts"
785exports = ["*"]
786
787[[ignoreExports]]
788file = "src/constants.ts"
789exports = ["FOO", "BAR"]
790"#;
791        let config: FallowConfig = toml::from_str(toml_str).unwrap();
792        assert_eq!(config.ignore_exports.len(), 2);
793        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
794        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
795        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
796    }
797
798    #[test]
799    fn fallow_config_deserialize_ignore_dependencies() {
800        let toml_str = r#"
801ignoreDependencies = ["autoprefixer", "postcss"]
802"#;
803        let config: FallowConfig = toml::from_str(toml_str).unwrap();
804        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
805    }
806
807    #[test]
808    fn fallow_config_resolve_default_ignores() {
809        let config = FallowConfig::default();
810        let resolved = config.resolve(
811            PathBuf::from("/tmp/test"),
812            OutputFormat::Human,
813            4,
814            true,
815            true,
816        );
817
818        // Default ignores should be compiled
819        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
820        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
821        assert!(resolved.ignore_patterns.is_match("build/output.js"));
822        assert!(resolved.ignore_patterns.is_match(".git/config"));
823        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
824        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
825        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
826    }
827
828    #[test]
829    fn fallow_config_resolve_custom_ignores() {
830        let config = FallowConfig {
831            entry: vec!["src/**/*.ts".to_string()],
832            ignore_patterns: vec!["**/*.generated.ts".to_string()],
833            ..Default::default()
834        };
835        let resolved = config.resolve(
836            PathBuf::from("/tmp/test"),
837            OutputFormat::Json,
838            4,
839            false,
840            true,
841        );
842
843        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
844        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
845        assert!(matches!(resolved.output, OutputFormat::Json));
846        assert!(!resolved.no_cache);
847    }
848
849    #[test]
850    fn fallow_config_resolve_cache_dir() {
851        let config = FallowConfig::default();
852        let resolved = config.resolve(
853            PathBuf::from("/tmp/project"),
854            OutputFormat::Human,
855            4,
856            true,
857            true,
858        );
859        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
860        assert!(resolved.no_cache);
861    }
862
863    #[test]
864    fn package_json_entry_points_main() {
865        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
866        let entries = pkg.entry_points();
867        assert!(entries.contains(&"dist/index.js".to_string()));
868    }
869
870    #[test]
871    fn package_json_entry_points_module() {
872        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
873        let entries = pkg.entry_points();
874        assert!(entries.contains(&"dist/index.mjs".to_string()));
875    }
876
877    #[test]
878    fn package_json_entry_points_types() {
879        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
880        let entries = pkg.entry_points();
881        assert!(entries.contains(&"dist/index.d.ts".to_string()));
882    }
883
884    #[test]
885    fn package_json_entry_points_bin_string() {
886        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
887        let entries = pkg.entry_points();
888        assert!(entries.contains(&"bin/cli.js".to_string()));
889    }
890
891    #[test]
892    fn package_json_entry_points_bin_object() {
893        let pkg: PackageJson =
894            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
895                .unwrap();
896        let entries = pkg.entry_points();
897        assert!(entries.contains(&"bin/cli.js".to_string()));
898        assert!(entries.contains(&"bin/serve.js".to_string()));
899    }
900
901    #[test]
902    fn package_json_entry_points_exports_string() {
903        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
904        let entries = pkg.entry_points();
905        assert!(entries.contains(&"./dist/index.js".to_string()));
906    }
907
908    #[test]
909    fn package_json_entry_points_exports_object() {
910        let pkg: PackageJson = serde_json::from_str(
911            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
912        )
913        .unwrap();
914        let entries = pkg.entry_points();
915        assert!(entries.contains(&"./dist/index.mjs".to_string()));
916        assert!(entries.contains(&"./dist/index.cjs".to_string()));
917    }
918
919    #[test]
920    fn package_json_dependency_names() {
921        let pkg: PackageJson = serde_json::from_str(
922            r#"{
923            "dependencies": {"react": "^18", "lodash": "^4"},
924            "devDependencies": {"typescript": "^5"},
925            "peerDependencies": {"react-dom": "^18"}
926        }"#,
927        )
928        .unwrap();
929
930        let all = pkg.all_dependency_names();
931        assert!(all.contains(&"react".to_string()));
932        assert!(all.contains(&"lodash".to_string()));
933        assert!(all.contains(&"typescript".to_string()));
934        assert!(all.contains(&"react-dom".to_string()));
935
936        let prod = pkg.production_dependency_names();
937        assert!(prod.contains(&"react".to_string()));
938        assert!(!prod.contains(&"typescript".to_string()));
939
940        let dev = pkg.dev_dependency_names();
941        assert!(dev.contains(&"typescript".to_string()));
942        assert!(!dev.contains(&"react".to_string()));
943    }
944
945    #[test]
946    fn package_json_no_dependencies() {
947        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
948        assert!(pkg.all_dependency_names().is_empty());
949        assert!(pkg.production_dependency_names().is_empty());
950        assert!(pkg.dev_dependency_names().is_empty());
951        assert!(pkg.entry_points().is_empty());
952    }
953
954    #[test]
955    fn rules_deserialize_toml_kebab_case() {
956        let toml_str = r#"
957[rules]
958unused-files = "error"
959unused-exports = "warn"
960unused-types = "off"
961"#;
962        let config: FallowConfig = toml::from_str(toml_str).unwrap();
963        assert_eq!(config.rules.unused_files, Severity::Error);
964        assert_eq!(config.rules.unused_exports, Severity::Warn);
965        assert_eq!(config.rules.unused_types, Severity::Off);
966        // Unset fields default to error
967        assert_eq!(config.rules.unresolved_imports, Severity::Error);
968    }
969
970    #[test]
971    fn config_without_rules_defaults_to_error() {
972        let toml_str = r#"
973entry = ["src/main.ts"]
974"#;
975        let config: FallowConfig = toml::from_str(toml_str).unwrap();
976        assert_eq!(config.rules.unused_files, Severity::Error);
977        assert_eq!(config.rules.unused_exports, Severity::Error);
978    }
979
980    #[test]
981    fn fallow_config_denies_unknown_fields() {
982        let toml_str = r"
983unknown_field = true
984";
985        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
986        assert!(result.is_err());
987    }
988
989    #[test]
990    fn fallow_config_deserialize_json() {
991        let json_str = r#"{"entry": ["src/main.ts"]}"#;
992        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
993        assert_eq!(config.entry, vec!["src/main.ts"]);
994    }
995
996    #[test]
997    fn fallow_config_deserialize_jsonc() {
998        let jsonc_str = r#"{
999            // This is a comment
1000            "entry": ["src/main.ts"],
1001            "rules": {
1002                "unused-files": "warn"
1003            }
1004        }"#;
1005        let mut stripped = String::new();
1006        json_comments::StripComments::new(jsonc_str.as_bytes())
1007            .read_to_string(&mut stripped)
1008            .unwrap();
1009        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1010        assert_eq!(config.entry, vec!["src/main.ts"]);
1011        assert_eq!(config.rules.unused_files, Severity::Warn);
1012    }
1013
1014    #[test]
1015    fn fallow_config_json_with_schema_field() {
1016        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1017        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1018        assert_eq!(config.entry, vec!["src/main.ts"]);
1019    }
1020
1021    #[test]
1022    fn fallow_config_json_schema_generation() {
1023        let schema = FallowConfig::json_schema();
1024        assert!(schema.is_object());
1025        let obj = schema.as_object().unwrap();
1026        assert!(obj.contains_key("properties"));
1027    }
1028
1029    #[test]
1030    fn config_format_detection() {
1031        assert!(matches!(
1032            ConfigFormat::from_path(Path::new("fallow.toml")),
1033            ConfigFormat::Toml
1034        ));
1035        assert!(matches!(
1036            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1037            ConfigFormat::Json
1038        ));
1039        assert!(matches!(
1040            ConfigFormat::from_path(Path::new(".fallow.toml")),
1041            ConfigFormat::Toml
1042        ));
1043    }
1044
1045    #[test]
1046    fn config_names_priority_order() {
1047        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1048        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
1049        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
1050    }
1051
1052    #[test]
1053    fn load_json_config_file() {
1054        let dir = test_dir("json-config");
1055        let config_path = dir.path().join(".fallowrc.json");
1056        std::fs::write(
1057            &config_path,
1058            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1059        )
1060        .unwrap();
1061
1062        let config = FallowConfig::load(&config_path).unwrap();
1063        assert_eq!(config.entry, vec!["src/index.ts"]);
1064        assert_eq!(config.rules.unused_exports, Severity::Warn);
1065    }
1066
1067    #[test]
1068    fn load_jsonc_config_file() {
1069        let dir = test_dir("jsonc-config");
1070        let config_path = dir.path().join(".fallowrc.json");
1071        std::fs::write(
1072            &config_path,
1073            r#"{
1074                // Entry points for analysis
1075                "entry": ["src/index.ts"],
1076                /* Block comment */
1077                "rules": {
1078                    "unused-exports": "warn"
1079                }
1080            }"#,
1081        )
1082        .unwrap();
1083
1084        let config = FallowConfig::load(&config_path).unwrap();
1085        assert_eq!(config.entry, vec!["src/index.ts"]);
1086        assert_eq!(config.rules.unused_exports, Severity::Warn);
1087    }
1088
1089    #[test]
1090    fn json_config_ignore_dependencies_camel_case() {
1091        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1092        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1093        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1094    }
1095
1096    #[test]
1097    fn json_config_all_fields() {
1098        let json_str = r#"{
1099            "ignoreDependencies": ["lodash"],
1100            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1101            "rules": {
1102                "unused-files": "off",
1103                "unused-exports": "warn",
1104                "unused-dependencies": "error",
1105                "unused-dev-dependencies": "off",
1106                "unused-types": "warn",
1107                "unused-enum-members": "error",
1108                "unused-class-members": "off",
1109                "unresolved-imports": "warn",
1110                "unlisted-dependencies": "error",
1111                "duplicate-exports": "off"
1112            },
1113            "duplicates": {
1114                "minTokens": 100,
1115                "minLines": 10,
1116                "skipLocal": true
1117            }
1118        }"#;
1119        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1120        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1121        assert_eq!(config.rules.unused_files, Severity::Off);
1122        assert_eq!(config.rules.unused_exports, Severity::Warn);
1123        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1124        assert_eq!(config.duplicates.min_tokens, 100);
1125        assert_eq!(config.duplicates.min_lines, 10);
1126        assert!(config.duplicates.skip_local);
1127    }
1128
1129    // ── extends tests ──────────────────────────────────────────────
1130
1131    #[test]
1132    fn extends_single_base() {
1133        let dir = test_dir("extends-single");
1134
1135        std::fs::write(
1136            dir.path().join("base.json"),
1137            r#"{"rules": {"unused-files": "warn"}}"#,
1138        )
1139        .unwrap();
1140        std::fs::write(
1141            dir.path().join(".fallowrc.json"),
1142            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1143        )
1144        .unwrap();
1145
1146        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1147        assert_eq!(config.rules.unused_files, Severity::Warn);
1148        assert_eq!(config.entry, vec!["src/index.ts"]);
1149        // Unset fields from base still default
1150        assert_eq!(config.rules.unused_exports, Severity::Error);
1151    }
1152
1153    #[test]
1154    fn extends_overlay_overrides_base() {
1155        let dir = test_dir("extends-overlay");
1156
1157        std::fs::write(
1158            dir.path().join("base.json"),
1159            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1160        )
1161        .unwrap();
1162        std::fs::write(
1163            dir.path().join(".fallowrc.json"),
1164            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1165        )
1166        .unwrap();
1167
1168        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1169        // Overlay overrides base
1170        assert_eq!(config.rules.unused_files, Severity::Error);
1171        // Base value preserved when not overridden
1172        assert_eq!(config.rules.unused_exports, Severity::Off);
1173    }
1174
1175    #[test]
1176    fn extends_chained() {
1177        let dir = test_dir("extends-chained");
1178
1179        std::fs::write(
1180            dir.path().join("grandparent.json"),
1181            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1182        )
1183        .unwrap();
1184        std::fs::write(
1185            dir.path().join("parent.json"),
1186            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1187        )
1188        .unwrap();
1189        std::fs::write(
1190            dir.path().join(".fallowrc.json"),
1191            r#"{"extends": ["parent.json"]}"#,
1192        )
1193        .unwrap();
1194
1195        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1196        // grandparent: off -> parent: warn -> child: inherits warn
1197        assert_eq!(config.rules.unused_files, Severity::Warn);
1198        // grandparent: warn, not overridden
1199        assert_eq!(config.rules.unused_exports, Severity::Warn);
1200    }
1201
1202    #[test]
1203    fn extends_circular_detected() {
1204        let dir = test_dir("extends-circular");
1205
1206        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1207        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1208
1209        let result = FallowConfig::load(&dir.path().join("a.json"));
1210        assert!(result.is_err());
1211        let err_msg = format!("{}", result.unwrap_err());
1212        assert!(
1213            err_msg.contains("Circular extends"),
1214            "Expected circular error, got: {err_msg}"
1215        );
1216    }
1217
1218    #[test]
1219    fn extends_missing_file_errors() {
1220        let dir = test_dir("extends-missing");
1221
1222        std::fs::write(
1223            dir.path().join(".fallowrc.json"),
1224            r#"{"extends": ["nonexistent.json"]}"#,
1225        )
1226        .unwrap();
1227
1228        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1229        assert!(result.is_err());
1230        let err_msg = format!("{}", result.unwrap_err());
1231        assert!(
1232            err_msg.contains("not found"),
1233            "Expected not found error, got: {err_msg}"
1234        );
1235    }
1236
1237    // ── sealed: true tests ──────────────────────────────────────────
1238
1239    #[test]
1240    fn sealed_allows_in_directory_extends() {
1241        let dir = test_dir("sealed-allows-local");
1242        std::fs::write(
1243            dir.path().join("base.json"),
1244            r#"{"ignorePatterns": ["gen/**"]}"#,
1245        )
1246        .unwrap();
1247        std::fs::write(
1248            dir.path().join(".fallowrc.json"),
1249            r#"{"sealed": true, "extends": ["./base.json"]}"#,
1250        )
1251        .unwrap();
1252
1253        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1254        assert!(config.sealed);
1255        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1256    }
1257
1258    #[test]
1259    fn sealed_rejects_extends_escaping_directory() {
1260        let dir = test_dir("sealed-rejects-escape");
1261        let sub = dir.path().join("packages").join("app");
1262        std::fs::create_dir_all(&sub).unwrap();
1263
1264        // Base config above the sealed config's directory
1265        std::fs::write(
1266            dir.path().join("base.json"),
1267            r#"{"ignorePatterns": ["dist/**"]}"#,
1268        )
1269        .unwrap();
1270        std::fs::write(
1271            sub.join(".fallowrc.json"),
1272            r#"{"sealed": true, "extends": ["../../base.json"]}"#,
1273        )
1274        .unwrap();
1275
1276        let result = FallowConfig::load(&sub.join(".fallowrc.json"));
1277        assert!(
1278            result.is_err(),
1279            "Expected sealed config to reject escaping extends"
1280        );
1281        let err_msg = format!("{}", result.unwrap_err());
1282        assert!(
1283            err_msg.contains("sealed"),
1284            "Error must mention sealed: {err_msg}"
1285        );
1286        assert!(
1287            err_msg.contains("outside the config's directory"),
1288            "Error must explain the constraint: {err_msg}"
1289        );
1290    }
1291
1292    #[test]
1293    fn sealed_rejects_https_extends() {
1294        let dir = test_dir("sealed-rejects-https");
1295        std::fs::write(
1296            dir.path().join(".fallowrc.json"),
1297            r#"{"sealed": true, "extends": ["https://example.com/base.json"]}"#,
1298        )
1299        .unwrap();
1300
1301        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1302        assert!(result.is_err());
1303        let err_msg = format!("{}", result.unwrap_err());
1304        assert!(
1305            err_msg.contains("sealed"),
1306            "Error must mention sealed: {err_msg}"
1307        );
1308        assert!(
1309            err_msg.contains("URL extends"),
1310            "Error must mention URL: {err_msg}"
1311        );
1312    }
1313
1314    #[test]
1315    fn sealed_rejects_npm_extends() {
1316        let dir = test_dir("sealed-rejects-npm");
1317        std::fs::write(
1318            dir.path().join(".fallowrc.json"),
1319            r#"{"sealed": true, "extends": ["npm:@scope/config"]}"#,
1320        )
1321        .unwrap();
1322
1323        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1324        assert!(result.is_err());
1325        let err_msg = format!("{}", result.unwrap_err());
1326        assert!(
1327            err_msg.contains("sealed"),
1328            "Error must mention sealed: {err_msg}"
1329        );
1330        assert!(
1331            err_msg.contains("npm extends"),
1332            "Error must mention npm: {err_msg}"
1333        );
1334    }
1335
1336    #[test]
1337    fn sealed_default_is_false() {
1338        let dir = test_dir("sealed-default");
1339        std::fs::write(dir.path().join(".fallowrc.json"), "{}").unwrap();
1340        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1341        assert!(!config.sealed);
1342    }
1343
1344    #[test]
1345    fn sealed_false_allows_escaping_extends() {
1346        // Without sealed (or sealed: false), escaping extends works fine
1347        let dir = test_dir("sealed-false-allows");
1348        let sub = dir.path().join("packages").join("app");
1349        std::fs::create_dir_all(&sub).unwrap();
1350
1351        std::fs::write(
1352            dir.path().join("base.json"),
1353            r#"{"ignorePatterns": ["dist/**"]}"#,
1354        )
1355        .unwrap();
1356        std::fs::write(
1357            sub.join(".fallowrc.json"),
1358            r#"{"extends": ["../../base.json"]}"#,
1359        )
1360        .unwrap();
1361
1362        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1363        assert!(!config.sealed);
1364        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1365    }
1366
1367    #[test]
1368    fn extends_string_sugar() {
1369        let dir = test_dir("extends-string");
1370
1371        std::fs::write(
1372            dir.path().join("base.json"),
1373            r#"{"ignorePatterns": ["gen/**"]}"#,
1374        )
1375        .unwrap();
1376        // String form instead of array
1377        std::fs::write(
1378            dir.path().join(".fallowrc.json"),
1379            r#"{"extends": "base.json"}"#,
1380        )
1381        .unwrap();
1382
1383        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1384        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1385    }
1386
1387    #[test]
1388    fn extends_deep_merge_preserves_arrays() {
1389        let dir = test_dir("extends-array");
1390
1391        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1392        std::fs::write(
1393            dir.path().join(".fallowrc.json"),
1394            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1395        )
1396        .unwrap();
1397
1398        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1399        // Arrays are replaced, not merged (overlay replaces base)
1400        assert_eq!(config.entry, vec!["src/b.ts"]);
1401    }
1402
1403    // ── npm extends tests ────────────────────────────────────────────
1404
1405    /// Set up a fake npm package in `node_modules/<name>` under `root`.
1406    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1407        let pkg_dir = root.join("node_modules").join(name);
1408        std::fs::create_dir_all(&pkg_dir).unwrap();
1409        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1410    }
1411
1412    /// Set up a fake npm package with `package.json` `main` field.
1413    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1414        let pkg_dir = root.join("node_modules").join(name);
1415        std::fs::create_dir_all(&pkg_dir).unwrap();
1416        std::fs::write(
1417            pkg_dir.join("package.json"),
1418            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1419        )
1420        .unwrap();
1421        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1422    }
1423
1424    #[test]
1425    fn extends_npm_basic_unscoped() {
1426        let dir = test_dir("npm-basic");
1427        create_npm_package(
1428            dir.path(),
1429            "fallow-config-acme",
1430            r#"{"rules": {"unused-files": "warn"}}"#,
1431        );
1432        std::fs::write(
1433            dir.path().join(".fallowrc.json"),
1434            r#"{"extends": "npm:fallow-config-acme"}"#,
1435        )
1436        .unwrap();
1437
1438        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1439        assert_eq!(config.rules.unused_files, Severity::Warn);
1440    }
1441
1442    #[test]
1443    fn extends_npm_scoped_package() {
1444        let dir = test_dir("npm-scoped");
1445        create_npm_package(
1446            dir.path(),
1447            "@company/fallow-config",
1448            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1449        );
1450        std::fs::write(
1451            dir.path().join(".fallowrc.json"),
1452            r#"{"extends": "npm:@company/fallow-config"}"#,
1453        )
1454        .unwrap();
1455
1456        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1457        assert_eq!(config.rules.unused_exports, Severity::Off);
1458        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1459    }
1460
1461    #[test]
1462    fn extends_npm_with_subpath() {
1463        let dir = test_dir("npm-subpath");
1464        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1465        std::fs::create_dir_all(&pkg_dir).unwrap();
1466        std::fs::write(
1467            pkg_dir.join("strict.json"),
1468            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1469        )
1470        .unwrap();
1471
1472        std::fs::write(
1473            dir.path().join(".fallowrc.json"),
1474            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1475        )
1476        .unwrap();
1477
1478        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1479        assert_eq!(config.rules.unused_files, Severity::Error);
1480        assert_eq!(config.rules.unused_exports, Severity::Error);
1481    }
1482
1483    #[test]
1484    fn extends_npm_package_json_main() {
1485        let dir = test_dir("npm-main");
1486        create_npm_package_with_main(
1487            dir.path(),
1488            "fallow-config-acme",
1489            "config.json",
1490            r#"{"rules": {"unused-types": "off"}}"#,
1491        );
1492        std::fs::write(
1493            dir.path().join(".fallowrc.json"),
1494            r#"{"extends": "npm:fallow-config-acme"}"#,
1495        )
1496        .unwrap();
1497
1498        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1499        assert_eq!(config.rules.unused_types, Severity::Off);
1500    }
1501
1502    #[test]
1503    fn extends_npm_package_json_exports_string() {
1504        let dir = test_dir("npm-exports-str");
1505        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1506        std::fs::create_dir_all(&pkg_dir).unwrap();
1507        std::fs::write(
1508            pkg_dir.join("package.json"),
1509            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1510        )
1511        .unwrap();
1512        std::fs::write(
1513            pkg_dir.join("base.json"),
1514            r#"{"rules": {"circular-dependencies": "warn"}}"#,
1515        )
1516        .unwrap();
1517
1518        std::fs::write(
1519            dir.path().join(".fallowrc.json"),
1520            r#"{"extends": "npm:fallow-config-co"}"#,
1521        )
1522        .unwrap();
1523
1524        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1525        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1526    }
1527
1528    #[test]
1529    fn extends_npm_package_json_exports_object() {
1530        let dir = test_dir("npm-exports-obj");
1531        let pkg_dir = dir.path().join("node_modules/@co/cfg");
1532        std::fs::create_dir_all(&pkg_dir).unwrap();
1533        std::fs::write(
1534            pkg_dir.join("package.json"),
1535            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1536        )
1537        .unwrap();
1538        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1539
1540        std::fs::write(
1541            dir.path().join(".fallowrc.json"),
1542            r#"{"extends": "npm:@co/cfg"}"#,
1543        )
1544        .unwrap();
1545
1546        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1547        assert_eq!(config.entry, vec!["src/app.ts"]);
1548    }
1549
1550    #[test]
1551    fn extends_npm_exports_takes_priority_over_main() {
1552        let dir = test_dir("npm-exports-prio");
1553        let pkg_dir = dir.path().join("node_modules/my-config");
1554        std::fs::create_dir_all(&pkg_dir).unwrap();
1555        std::fs::write(
1556            pkg_dir.join("package.json"),
1557            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1558        )
1559        .unwrap();
1560        std::fs::write(
1561            pkg_dir.join("old.json"),
1562            r#"{"rules": {"unused-files": "off"}}"#,
1563        )
1564        .unwrap();
1565        std::fs::write(
1566            pkg_dir.join("new.json"),
1567            r#"{"rules": {"unused-files": "warn"}}"#,
1568        )
1569        .unwrap();
1570
1571        std::fs::write(
1572            dir.path().join(".fallowrc.json"),
1573            r#"{"extends": "npm:my-config"}"#,
1574        )
1575        .unwrap();
1576
1577        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1578        // exports takes priority over main
1579        assert_eq!(config.rules.unused_files, Severity::Warn);
1580    }
1581
1582    #[test]
1583    fn extends_npm_walk_up_directories() {
1584        let dir = test_dir("npm-walkup");
1585        // node_modules at root level
1586        create_npm_package(
1587            dir.path(),
1588            "shared-config",
1589            r#"{"rules": {"unused-files": "warn"}}"#,
1590        );
1591        // Config in a nested subdirectory
1592        let sub = dir.path().join("packages/app");
1593        std::fs::create_dir_all(&sub).unwrap();
1594        std::fs::write(
1595            sub.join(".fallowrc.json"),
1596            r#"{"extends": "npm:shared-config"}"#,
1597        )
1598        .unwrap();
1599
1600        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1601        assert_eq!(config.rules.unused_files, Severity::Warn);
1602    }
1603
1604    #[test]
1605    fn extends_npm_overlay_overrides_base() {
1606        let dir = test_dir("npm-overlay");
1607        create_npm_package(
1608            dir.path(),
1609            "@company/base",
1610            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1611        );
1612        std::fs::write(
1613            dir.path().join(".fallowrc.json"),
1614            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1615        )
1616        .unwrap();
1617
1618        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1619        assert_eq!(config.rules.unused_files, Severity::Error);
1620        assert_eq!(config.rules.unused_exports, Severity::Off);
1621        assert_eq!(config.entry, vec!["src/app.ts"]);
1622    }
1623
1624    #[test]
1625    fn extends_npm_chained_with_relative() {
1626        let dir = test_dir("npm-chained");
1627        // npm package extends a relative file inside itself
1628        let pkg_dir = dir.path().join("node_modules/my-config");
1629        std::fs::create_dir_all(&pkg_dir).unwrap();
1630        std::fs::write(
1631            pkg_dir.join("base.json"),
1632            r#"{"rules": {"unused-files": "warn"}}"#,
1633        )
1634        .unwrap();
1635        std::fs::write(
1636            pkg_dir.join(".fallowrc.json"),
1637            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1638        )
1639        .unwrap();
1640
1641        std::fs::write(
1642            dir.path().join(".fallowrc.json"),
1643            r#"{"extends": "npm:my-config"}"#,
1644        )
1645        .unwrap();
1646
1647        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1648        assert_eq!(config.rules.unused_files, Severity::Warn);
1649        assert_eq!(config.rules.unused_exports, Severity::Off);
1650    }
1651
1652    #[test]
1653    fn extends_npm_mixed_with_relative_paths() {
1654        let dir = test_dir("npm-mixed");
1655        create_npm_package(
1656            dir.path(),
1657            "shared-base",
1658            r#"{"rules": {"unused-files": "off"}}"#,
1659        );
1660        std::fs::write(
1661            dir.path().join("local-overrides.json"),
1662            r#"{"rules": {"unused-files": "warn"}}"#,
1663        )
1664        .unwrap();
1665        std::fs::write(
1666            dir.path().join(".fallowrc.json"),
1667            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1668        )
1669        .unwrap();
1670
1671        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1672        // local-overrides is later in the array, so it wins
1673        assert_eq!(config.rules.unused_files, Severity::Warn);
1674    }
1675
1676    #[test]
1677    fn extends_npm_missing_package_errors() {
1678        let dir = test_dir("npm-missing");
1679        std::fs::write(
1680            dir.path().join(".fallowrc.json"),
1681            r#"{"extends": "npm:nonexistent-package"}"#,
1682        )
1683        .unwrap();
1684
1685        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1686        assert!(result.is_err());
1687        let err_msg = format!("{}", result.unwrap_err());
1688        assert!(
1689            err_msg.contains("not found"),
1690            "Expected 'not found' error, got: {err_msg}"
1691        );
1692        assert!(
1693            err_msg.contains("nonexistent-package"),
1694            "Expected package name in error, got: {err_msg}"
1695        );
1696        assert!(
1697            err_msg.contains("install it"),
1698            "Expected install hint in error, got: {err_msg}"
1699        );
1700    }
1701
1702    #[test]
1703    fn extends_npm_no_config_in_package_errors() {
1704        let dir = test_dir("npm-no-config");
1705        let pkg_dir = dir.path().join("node_modules/empty-pkg");
1706        std::fs::create_dir_all(&pkg_dir).unwrap();
1707        // Package exists but has no config files and no package.json
1708        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1709
1710        std::fs::write(
1711            dir.path().join(".fallowrc.json"),
1712            r#"{"extends": "npm:empty-pkg"}"#,
1713        )
1714        .unwrap();
1715
1716        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1717        assert!(result.is_err());
1718        let err_msg = format!("{}", result.unwrap_err());
1719        assert!(
1720            err_msg.contains("No fallow config found"),
1721            "Expected 'No fallow config found' error, got: {err_msg}"
1722        );
1723    }
1724
1725    #[test]
1726    fn extends_npm_missing_subpath_errors() {
1727        let dir = test_dir("npm-missing-sub");
1728        let pkg_dir = dir.path().join("node_modules/@co/config");
1729        std::fs::create_dir_all(&pkg_dir).unwrap();
1730
1731        std::fs::write(
1732            dir.path().join(".fallowrc.json"),
1733            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1734        )
1735        .unwrap();
1736
1737        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1738        assert!(result.is_err());
1739        let err_msg = format!("{}", result.unwrap_err());
1740        assert!(
1741            err_msg.contains("nonexistent.json"),
1742            "Expected subpath in error, got: {err_msg}"
1743        );
1744    }
1745
1746    #[test]
1747    fn extends_npm_empty_specifier_errors() {
1748        let dir = test_dir("npm-empty");
1749        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1750
1751        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1752        assert!(result.is_err());
1753        let err_msg = format!("{}", result.unwrap_err());
1754        assert!(
1755            err_msg.contains("Empty npm specifier"),
1756            "Expected 'Empty npm specifier' error, got: {err_msg}"
1757        );
1758    }
1759
1760    #[test]
1761    fn extends_npm_space_after_colon_trimmed() {
1762        let dir = test_dir("npm-space");
1763        create_npm_package(
1764            dir.path(),
1765            "fallow-config-acme",
1766            r#"{"rules": {"unused-files": "warn"}}"#,
1767        );
1768        // Space after npm: — should be trimmed and resolve correctly
1769        std::fs::write(
1770            dir.path().join(".fallowrc.json"),
1771            r#"{"extends": "npm: fallow-config-acme"}"#,
1772        )
1773        .unwrap();
1774
1775        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1776        assert_eq!(config.rules.unused_files, Severity::Warn);
1777    }
1778
1779    #[test]
1780    fn extends_npm_exports_node_condition() {
1781        let dir = test_dir("npm-node-cond");
1782        let pkg_dir = dir.path().join("node_modules/node-config");
1783        std::fs::create_dir_all(&pkg_dir).unwrap();
1784        std::fs::write(
1785            pkg_dir.join("package.json"),
1786            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1787        )
1788        .unwrap();
1789        std::fs::write(
1790            pkg_dir.join("node.json"),
1791            r#"{"rules": {"unused-files": "off"}}"#,
1792        )
1793        .unwrap();
1794
1795        std::fs::write(
1796            dir.path().join(".fallowrc.json"),
1797            r#"{"extends": "npm:node-config"}"#,
1798        )
1799        .unwrap();
1800
1801        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1802        assert_eq!(config.rules.unused_files, Severity::Off);
1803    }
1804
1805    // ── parse_npm_specifier unit tests ──────────────────────────────
1806
1807    #[test]
1808    fn parse_npm_specifier_unscoped() {
1809        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1810    }
1811
1812    #[test]
1813    fn parse_npm_specifier_unscoped_with_subpath() {
1814        assert_eq!(
1815            parse_npm_specifier("my-config/strict.json"),
1816            ("my-config", Some("strict.json"))
1817        );
1818    }
1819
1820    #[test]
1821    fn parse_npm_specifier_scoped() {
1822        assert_eq!(
1823            parse_npm_specifier("@company/fallow-config"),
1824            ("@company/fallow-config", None)
1825        );
1826    }
1827
1828    #[test]
1829    fn parse_npm_specifier_scoped_with_subpath() {
1830        assert_eq!(
1831            parse_npm_specifier("@company/fallow-config/strict.json"),
1832            ("@company/fallow-config", Some("strict.json"))
1833        );
1834    }
1835
1836    #[test]
1837    fn parse_npm_specifier_scoped_with_nested_subpath() {
1838        assert_eq!(
1839            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1840            ("@company/fallow-config", Some("presets/strict.json"))
1841        );
1842    }
1843
1844    // ── npm extends security tests ──────────────────────────────────
1845
1846    #[test]
1847    fn extends_npm_subpath_traversal_rejected() {
1848        let dir = test_dir("npm-traversal-sub");
1849        let pkg_dir = dir.path().join("node_modules/evil-pkg");
1850        std::fs::create_dir_all(&pkg_dir).unwrap();
1851        // Create a file outside the package that the traversal would reach
1852        std::fs::write(
1853            dir.path().join("secret.json"),
1854            r#"{"entry": ["stolen.ts"]}"#,
1855        )
1856        .unwrap();
1857
1858        std::fs::write(
1859            dir.path().join(".fallowrc.json"),
1860            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1861        )
1862        .unwrap();
1863
1864        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1865        assert!(result.is_err());
1866        let err_msg = format!("{}", result.unwrap_err());
1867        assert!(
1868            err_msg.contains("traversal") || err_msg.contains("not found"),
1869            "Expected traversal or not-found error, got: {err_msg}"
1870        );
1871    }
1872
1873    #[test]
1874    fn extends_npm_dotdot_package_name_rejected() {
1875        let dir = test_dir("npm-dotdot-name");
1876        std::fs::write(
1877            dir.path().join(".fallowrc.json"),
1878            r#"{"extends": "npm:../relative"}"#,
1879        )
1880        .unwrap();
1881
1882        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1883        assert!(result.is_err());
1884        let err_msg = format!("{}", result.unwrap_err());
1885        assert!(
1886            err_msg.contains("path traversal"),
1887            "Expected 'path traversal' error, got: {err_msg}"
1888        );
1889    }
1890
1891    #[test]
1892    fn extends_npm_scoped_without_name_rejected() {
1893        let dir = test_dir("npm-scope-only");
1894        std::fs::write(
1895            dir.path().join(".fallowrc.json"),
1896            r#"{"extends": "npm:@scope"}"#,
1897        )
1898        .unwrap();
1899
1900        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1901        assert!(result.is_err());
1902        let err_msg = format!("{}", result.unwrap_err());
1903        assert!(
1904            err_msg.contains("@scope/name"),
1905            "Expected scoped name format error, got: {err_msg}"
1906        );
1907    }
1908
1909    #[test]
1910    fn extends_npm_malformed_package_json_errors() {
1911        let dir = test_dir("npm-bad-pkgjson");
1912        let pkg_dir = dir.path().join("node_modules/bad-pkg");
1913        std::fs::create_dir_all(&pkg_dir).unwrap();
1914        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1915
1916        std::fs::write(
1917            dir.path().join(".fallowrc.json"),
1918            r#"{"extends": "npm:bad-pkg"}"#,
1919        )
1920        .unwrap();
1921
1922        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1923        assert!(result.is_err());
1924        let err_msg = format!("{}", result.unwrap_err());
1925        assert!(
1926            err_msg.contains("Failed to parse"),
1927            "Expected parse error, got: {err_msg}"
1928        );
1929    }
1930
1931    #[test]
1932    fn extends_npm_exports_traversal_rejected() {
1933        let dir = test_dir("npm-exports-escape");
1934        let pkg_dir = dir.path().join("node_modules/evil-exports");
1935        std::fs::create_dir_all(&pkg_dir).unwrap();
1936        std::fs::write(
1937            pkg_dir.join("package.json"),
1938            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1939        )
1940        .unwrap();
1941        // Create the target file outside the package
1942        std::fs::write(
1943            dir.path().join("secret.json"),
1944            r#"{"entry": ["stolen.ts"]}"#,
1945        )
1946        .unwrap();
1947
1948        std::fs::write(
1949            dir.path().join(".fallowrc.json"),
1950            r#"{"extends": "npm:evil-exports"}"#,
1951        )
1952        .unwrap();
1953
1954        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1955        assert!(result.is_err());
1956        let err_msg = format!("{}", result.unwrap_err());
1957        assert!(
1958            err_msg.contains("traversal"),
1959            "Expected traversal error, got: {err_msg}"
1960        );
1961    }
1962
1963    // ── deep_merge_json unit tests ───────────────────────────────────
1964
1965    #[test]
1966    fn deep_merge_scalar_overlay_replaces_base() {
1967        let mut base = serde_json::json!("hello");
1968        deep_merge_json(&mut base, serde_json::json!("world"));
1969        assert_eq!(base, serde_json::json!("world"));
1970    }
1971
1972    #[test]
1973    fn deep_merge_array_overlay_replaces_base() {
1974        let mut base = serde_json::json!(["a", "b"]);
1975        deep_merge_json(&mut base, serde_json::json!(["c"]));
1976        assert_eq!(base, serde_json::json!(["c"]));
1977    }
1978
1979    #[test]
1980    fn deep_merge_nested_object_merge() {
1981        let mut base = serde_json::json!({
1982            "level1": {
1983                "level2": {
1984                    "a": 1,
1985                    "b": 2
1986                }
1987            }
1988        });
1989        let overlay = serde_json::json!({
1990            "level1": {
1991                "level2": {
1992                    "b": 99,
1993                    "c": 3
1994                }
1995            }
1996        });
1997        deep_merge_json(&mut base, overlay);
1998        assert_eq!(base["level1"]["level2"]["a"], 1);
1999        assert_eq!(base["level1"]["level2"]["b"], 99);
2000        assert_eq!(base["level1"]["level2"]["c"], 3);
2001    }
2002
2003    #[test]
2004    fn deep_merge_overlay_adds_new_fields() {
2005        let mut base = serde_json::json!({"existing": true});
2006        let overlay = serde_json::json!({"new_field": "added", "another": 42});
2007        deep_merge_json(&mut base, overlay);
2008        assert_eq!(base["existing"], true);
2009        assert_eq!(base["new_field"], "added");
2010        assert_eq!(base["another"], 42);
2011    }
2012
2013    #[test]
2014    fn deep_merge_null_overlay_replaces_object() {
2015        let mut base = serde_json::json!({"key": "value"});
2016        deep_merge_json(&mut base, serde_json::json!(null));
2017        assert_eq!(base, serde_json::json!(null));
2018    }
2019
2020    #[test]
2021    fn deep_merge_empty_object_overlay_preserves_base() {
2022        let mut base = serde_json::json!({"a": 1, "b": 2});
2023        deep_merge_json(&mut base, serde_json::json!({}));
2024        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2025    }
2026
2027    // ── rule severity parsing via JSON config ────────────────────────
2028
2029    #[test]
2030    fn rules_severity_error_warn_off_from_json() {
2031        let json_str = r#"{
2032            "rules": {
2033                "unused-files": "error",
2034                "unused-exports": "warn",
2035                "unused-types": "off"
2036            }
2037        }"#;
2038        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2039        assert_eq!(config.rules.unused_files, Severity::Error);
2040        assert_eq!(config.rules.unused_exports, Severity::Warn);
2041        assert_eq!(config.rules.unused_types, Severity::Off);
2042    }
2043
2044    #[test]
2045    fn rules_omitted_default_to_error() {
2046        let json_str = r#"{
2047            "rules": {
2048                "unused-files": "warn"
2049            }
2050        }"#;
2051        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2052        assert_eq!(config.rules.unused_files, Severity::Warn);
2053        // All other rules default to error
2054        assert_eq!(config.rules.unused_exports, Severity::Error);
2055        assert_eq!(config.rules.unused_types, Severity::Error);
2056        assert_eq!(config.rules.unused_dependencies, Severity::Error);
2057        assert_eq!(config.rules.unresolved_imports, Severity::Error);
2058        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
2059        assert_eq!(config.rules.duplicate_exports, Severity::Error);
2060        assert_eq!(config.rules.circular_dependencies, Severity::Error);
2061        // type_only_dependencies defaults to warn, not error
2062        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
2063    }
2064
2065    // ── find_and_load tests ───────────────────────────────────────
2066
2067    #[test]
2068    fn find_and_load_returns_none_when_no_config() {
2069        let dir = test_dir("find-none");
2070        // Create a .git dir so it stops searching
2071        std::fs::create_dir(dir.path().join(".git")).unwrap();
2072
2073        let result = FallowConfig::find_and_load(dir.path()).unwrap();
2074        assert!(result.is_none());
2075    }
2076
2077    #[test]
2078    fn find_and_load_finds_fallowrc_json() {
2079        let dir = test_dir("find-json");
2080        std::fs::create_dir(dir.path().join(".git")).unwrap();
2081        std::fs::write(
2082            dir.path().join(".fallowrc.json"),
2083            r#"{"entry": ["src/main.ts"]}"#,
2084        )
2085        .unwrap();
2086
2087        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2088        assert_eq!(config.entry, vec!["src/main.ts"]);
2089        assert!(path.ends_with(".fallowrc.json"));
2090    }
2091
2092    #[test]
2093    fn find_and_load_prefers_fallowrc_json_over_toml() {
2094        let dir = test_dir("find-priority");
2095        std::fs::create_dir(dir.path().join(".git")).unwrap();
2096        std::fs::write(
2097            dir.path().join(".fallowrc.json"),
2098            r#"{"entry": ["from-json.ts"]}"#,
2099        )
2100        .unwrap();
2101        std::fs::write(
2102            dir.path().join("fallow.toml"),
2103            "entry = [\"from-toml.ts\"]\n",
2104        )
2105        .unwrap();
2106
2107        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2108        assert_eq!(config.entry, vec!["from-json.ts"]);
2109        assert!(path.ends_with(".fallowrc.json"));
2110    }
2111
2112    #[test]
2113    fn find_and_load_finds_fallow_toml() {
2114        let dir = test_dir("find-toml");
2115        std::fs::create_dir(dir.path().join(".git")).unwrap();
2116        std::fs::write(
2117            dir.path().join("fallow.toml"),
2118            "entry = [\"src/index.ts\"]\n",
2119        )
2120        .unwrap();
2121
2122        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
2123        assert_eq!(config.entry, vec!["src/index.ts"]);
2124    }
2125
2126    #[test]
2127    fn find_and_load_stops_at_git_dir() {
2128        let dir = test_dir("find-git-stop");
2129        let sub = dir.path().join("sub");
2130        std::fs::create_dir(&sub).unwrap();
2131        // .git marker in root stops search
2132        std::fs::create_dir(dir.path().join(".git")).unwrap();
2133        // Config file above .git should not be found from sub
2134        // (sub has no .git or package.json, so it keeps searching up to parent)
2135        // But parent has .git, so it stops there without finding config
2136        let result = FallowConfig::find_and_load(&sub).unwrap();
2137        assert!(result.is_none());
2138    }
2139
2140    #[test]
2141    fn find_and_load_walks_past_package_json_in_monorepo() {
2142        // Simulate a pnpm/npm/yarn workspace: root has `.git` + `.fallowrc.json`,
2143        // sub-package has its own `package.json`. Config search from the
2144        // sub-package must walk past its `package.json` and find the root config.
2145        let dir = test_dir("find-monorepo");
2146        std::fs::create_dir(dir.path().join(".git")).unwrap();
2147        std::fs::write(
2148            dir.path().join(".fallowrc.json"),
2149            r#"{"entry": ["src/index.ts"]}"#,
2150        )
2151        .unwrap();
2152
2153        let sub = dir.path().join("packages").join("app");
2154        std::fs::create_dir_all(&sub).unwrap();
2155        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2156
2157        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2158        assert_eq!(config.entry, vec!["src/index.ts"]);
2159        assert_eq!(path, dir.path().join(".fallowrc.json"));
2160    }
2161
2162    #[test]
2163    fn find_and_load_sub_package_config_wins_over_root() {
2164        // Regression guard: if a monorepo sub-package has its own config,
2165        // it must be preferred over the root config (first-match-wins).
2166        let dir = test_dir("find-monorepo-override");
2167        std::fs::create_dir(dir.path().join(".git")).unwrap();
2168        std::fs::write(
2169            dir.path().join(".fallowrc.json"),
2170            r#"{"entry": ["src/root.ts"]}"#,
2171        )
2172        .unwrap();
2173
2174        let sub = dir.path().join("packages").join("app");
2175        std::fs::create_dir_all(&sub).unwrap();
2176        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2177        std::fs::write(sub.join(".fallowrc.json"), r#"{"entry": ["src/sub.ts"]}"#).unwrap();
2178
2179        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2180        assert_eq!(config.entry, vec!["src/sub.ts"]);
2181        assert_eq!(path, sub.join(".fallowrc.json"));
2182    }
2183
2184    #[test]
2185    fn find_and_load_stops_at_git_file_submodule() {
2186        // Git submodules / worktrees have `.git` as a file (not a directory)
2187        // pointing to the real gitdir. `.exists()` matches both, so submodule
2188        // roots correctly stop the walk — config in the parent repo should
2189        // NOT leak into a vendored submodule.
2190        let dir = test_dir("find-git-file");
2191        std::fs::create_dir(dir.path().join(".git")).unwrap();
2192        std::fs::write(
2193            dir.path().join(".fallowrc.json"),
2194            r#"{"entry": ["src/parent.ts"]}"#,
2195        )
2196        .unwrap();
2197
2198        let submodule = dir.path().join("vendor").join("lib");
2199        std::fs::create_dir_all(&submodule).unwrap();
2200        // Simulate submodule: `.git` as a file pointing to parent's .git/modules
2201        std::fs::write(submodule.join(".git"), "gitdir: ../../.git/modules/lib\n").unwrap();
2202
2203        let result = FallowConfig::find_and_load(&submodule).unwrap();
2204        assert!(
2205            result.is_none(),
2206            "submodule boundary should stop config walk",
2207        );
2208    }
2209
2210    #[test]
2211    fn find_and_load_stops_at_hg_dir() {
2212        let dir = test_dir("find-hg-stop");
2213        let sub = dir.path().join("sub");
2214        std::fs::create_dir(&sub).unwrap();
2215        std::fs::create_dir(dir.path().join(".hg")).unwrap();
2216
2217        let result = FallowConfig::find_and_load(&sub).unwrap();
2218        assert!(result.is_none());
2219    }
2220
2221    #[test]
2222    fn find_and_load_returns_error_for_invalid_config() {
2223        let dir = test_dir("find-invalid");
2224        std::fs::create_dir(dir.path().join(".git")).unwrap();
2225        std::fs::write(
2226            dir.path().join(".fallowrc.json"),
2227            r"{ this is not valid json }",
2228        )
2229        .unwrap();
2230
2231        let result = FallowConfig::find_and_load(dir.path());
2232        assert!(result.is_err());
2233    }
2234
2235    // ── load TOML config file ────────────────────────────────────
2236
2237    #[test]
2238    fn load_toml_config_file() {
2239        let dir = test_dir("toml-config");
2240        let config_path = dir.path().join("fallow.toml");
2241        std::fs::write(
2242            &config_path,
2243            r#"
2244entry = ["src/index.ts"]
2245ignorePatterns = ["dist/**"]
2246
2247[rules]
2248unused-files = "warn"
2249
2250[duplicates]
2251minTokens = 100
2252"#,
2253        )
2254        .unwrap();
2255
2256        let config = FallowConfig::load(&config_path).unwrap();
2257        assert_eq!(config.entry, vec!["src/index.ts"]);
2258        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
2259        assert_eq!(config.rules.unused_files, Severity::Warn);
2260        assert_eq!(config.duplicates.min_tokens, 100);
2261    }
2262
2263    // ── extends absolute path rejection ──────────────────────────
2264
2265    #[test]
2266    fn extends_absolute_path_rejected() {
2267        let dir = test_dir("extends-absolute");
2268
2269        // Use a platform-appropriate absolute path
2270        #[cfg(unix)]
2271        let abs_path = "/absolute/path/config.json";
2272        #[cfg(windows)]
2273        let abs_path = "C:\\absolute\\path\\config.json";
2274
2275        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
2276        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
2277
2278        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2279        assert!(result.is_err());
2280        let err_msg = format!("{}", result.unwrap_err());
2281        assert!(
2282            err_msg.contains("must be relative"),
2283            "Expected 'must be relative' error, got: {err_msg}"
2284        );
2285    }
2286
2287    // ── resolve production mode ─────────────────────────────────
2288
2289    #[test]
2290    fn resolve_production_mode_disables_dev_deps() {
2291        let config = FallowConfig {
2292            production: true,
2293            ..Default::default()
2294        };
2295        let resolved = config.resolve(
2296            PathBuf::from("/tmp/test"),
2297            OutputFormat::Human,
2298            4,
2299            false,
2300            true,
2301        );
2302        assert!(resolved.production);
2303        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
2304        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
2305        // Other rules should remain at default (Error)
2306        assert_eq!(resolved.rules.unused_files, Severity::Error);
2307        assert_eq!(resolved.rules.unused_exports, Severity::Error);
2308    }
2309
2310    // ── config format fallback to TOML for unknown extensions ───
2311
2312    #[test]
2313    fn config_format_defaults_to_toml_for_unknown() {
2314        assert!(matches!(
2315            ConfigFormat::from_path(Path::new("config.yaml")),
2316            ConfigFormat::Toml
2317        ));
2318        assert!(matches!(
2319            ConfigFormat::from_path(Path::new("config")),
2320            ConfigFormat::Toml
2321        ));
2322    }
2323
2324    // ── deep_merge type coercion ─────────────────────────────────
2325
2326    #[test]
2327    fn deep_merge_object_over_scalar_replaces() {
2328        let mut base = serde_json::json!("just a string");
2329        let overlay = serde_json::json!({"key": "value"});
2330        deep_merge_json(&mut base, overlay);
2331        assert_eq!(base, serde_json::json!({"key": "value"}));
2332    }
2333
2334    #[test]
2335    fn deep_merge_scalar_over_object_replaces() {
2336        let mut base = serde_json::json!({"key": "value"});
2337        let overlay = serde_json::json!(42);
2338        deep_merge_json(&mut base, overlay);
2339        assert_eq!(base, serde_json::json!(42));
2340    }
2341
2342    // ── extends with non-string/array extends field ──────────────
2343
2344    #[test]
2345    fn extends_non_string_non_array_ignored() {
2346        let dir = test_dir("extends-numeric");
2347        std::fs::write(
2348            dir.path().join(".fallowrc.json"),
2349            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
2350        )
2351        .unwrap();
2352
2353        // extends=42 is neither string nor array, so it's treated as no extends
2354        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2355        assert_eq!(config.entry, vec!["src/index.ts"]);
2356    }
2357
2358    // ── extends with multiple bases (later overrides earlier) ────
2359
2360    #[test]
2361    fn extends_multiple_bases_later_wins() {
2362        let dir = test_dir("extends-multi-base");
2363
2364        std::fs::write(
2365            dir.path().join("base-a.json"),
2366            r#"{"rules": {"unused-files": "warn"}}"#,
2367        )
2368        .unwrap();
2369        std::fs::write(
2370            dir.path().join("base-b.json"),
2371            r#"{"rules": {"unused-files": "off"}}"#,
2372        )
2373        .unwrap();
2374        std::fs::write(
2375            dir.path().join(".fallowrc.json"),
2376            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
2377        )
2378        .unwrap();
2379
2380        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
2381        // base-b is later in the array, so its value should win
2382        assert_eq!(config.rules.unused_files, Severity::Off);
2383    }
2384
2385    // ── config with production flag ──────────────────────────────
2386
2387    #[test]
2388    fn fallow_config_deserialize_production() {
2389        let json_str = r#"{"production": true}"#;
2390        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
2391        assert!(config.production);
2392    }
2393
2394    #[test]
2395    fn fallow_config_production_defaults_false() {
2396        let config: FallowConfig = serde_json::from_str("{}").unwrap();
2397        assert!(!config.production);
2398    }
2399
2400    // ── optional dependency names ────────────────────────────────
2401
2402    #[test]
2403    fn package_json_optional_dependency_names() {
2404        let pkg: PackageJson = serde_json::from_str(
2405            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
2406        )
2407        .unwrap();
2408        let opt = pkg.optional_dependency_names();
2409        assert_eq!(opt.len(), 2);
2410        assert!(opt.contains(&"fsevents".to_string()));
2411        assert!(opt.contains(&"chokidar".to_string()));
2412    }
2413
2414    #[test]
2415    fn package_json_optional_deps_empty_when_missing() {
2416        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2417        assert!(pkg.optional_dependency_names().is_empty());
2418    }
2419
2420    // ── find_config_path ────────────────────────────────────────────
2421
2422    #[test]
2423    fn find_config_path_returns_fallowrc_json() {
2424        let dir = test_dir("find-path-json");
2425        std::fs::create_dir(dir.path().join(".git")).unwrap();
2426        std::fs::write(
2427            dir.path().join(".fallowrc.json"),
2428            r#"{"entry": ["src/main.ts"]}"#,
2429        )
2430        .unwrap();
2431
2432        let path = FallowConfig::find_config_path(dir.path());
2433        assert!(path.is_some());
2434        assert!(path.unwrap().ends_with(".fallowrc.json"));
2435    }
2436
2437    #[test]
2438    fn find_config_path_returns_fallow_toml() {
2439        let dir = test_dir("find-path-toml");
2440        std::fs::create_dir(dir.path().join(".git")).unwrap();
2441        std::fs::write(
2442            dir.path().join("fallow.toml"),
2443            "entry = [\"src/main.ts\"]\n",
2444        )
2445        .unwrap();
2446
2447        let path = FallowConfig::find_config_path(dir.path());
2448        assert!(path.is_some());
2449        assert!(path.unwrap().ends_with("fallow.toml"));
2450    }
2451
2452    #[test]
2453    fn find_config_path_returns_dot_fallow_toml() {
2454        let dir = test_dir("find-path-dot-toml");
2455        std::fs::create_dir(dir.path().join(".git")).unwrap();
2456        std::fs::write(
2457            dir.path().join(".fallow.toml"),
2458            "entry = [\"src/main.ts\"]\n",
2459        )
2460        .unwrap();
2461
2462        let path = FallowConfig::find_config_path(dir.path());
2463        assert!(path.is_some());
2464        assert!(path.unwrap().ends_with(".fallow.toml"));
2465    }
2466
2467    #[test]
2468    fn find_config_path_prefers_json_over_toml() {
2469        let dir = test_dir("find-path-priority");
2470        std::fs::create_dir(dir.path().join(".git")).unwrap();
2471        std::fs::write(
2472            dir.path().join(".fallowrc.json"),
2473            r#"{"entry": ["json.ts"]}"#,
2474        )
2475        .unwrap();
2476        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2477
2478        let path = FallowConfig::find_config_path(dir.path());
2479        assert!(path.unwrap().ends_with(".fallowrc.json"));
2480    }
2481
2482    #[test]
2483    fn find_config_path_none_when_no_config() {
2484        let dir = test_dir("find-path-none");
2485        std::fs::create_dir(dir.path().join(".git")).unwrap();
2486
2487        let path = FallowConfig::find_config_path(dir.path());
2488        assert!(path.is_none());
2489    }
2490
2491    #[test]
2492    fn find_config_path_walks_past_package_json_in_monorepo() {
2493        let dir = test_dir("find-path-monorepo");
2494        std::fs::create_dir(dir.path().join(".git")).unwrap();
2495        std::fs::write(
2496            dir.path().join(".fallowrc.json"),
2497            r#"{"entry": ["src/index.ts"]}"#,
2498        )
2499        .unwrap();
2500
2501        let sub = dir.path().join("packages").join("app");
2502        std::fs::create_dir_all(&sub).unwrap();
2503        std::fs::write(sub.join("package.json"), r#"{"name": "@scope/app"}"#).unwrap();
2504
2505        let path = FallowConfig::find_config_path(&sub).unwrap();
2506        assert_eq!(path, dir.path().join(".fallowrc.json"));
2507    }
2508
2509    // ── TOML extends support ────────────────────────────────────────
2510
2511    #[test]
2512    fn extends_toml_base() {
2513        let dir = test_dir("extends-toml");
2514
2515        std::fs::write(
2516            dir.path().join("base.json"),
2517            r#"{"rules": {"unused-files": "warn"}}"#,
2518        )
2519        .unwrap();
2520        std::fs::write(
2521            dir.path().join("fallow.toml"),
2522            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2523        )
2524        .unwrap();
2525
2526        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2527        assert_eq!(config.rules.unused_files, Severity::Warn);
2528        assert_eq!(config.entry, vec!["src/index.ts"]);
2529    }
2530
2531    // ── deep_merge_json edge cases ──────────────────────────────────
2532
2533    #[test]
2534    fn deep_merge_boolean_overlay() {
2535        let mut base = serde_json::json!(true);
2536        deep_merge_json(&mut base, serde_json::json!(false));
2537        assert_eq!(base, serde_json::json!(false));
2538    }
2539
2540    #[test]
2541    fn deep_merge_number_overlay() {
2542        let mut base = serde_json::json!(42);
2543        deep_merge_json(&mut base, serde_json::json!(99));
2544        assert_eq!(base, serde_json::json!(99));
2545    }
2546
2547    #[test]
2548    fn deep_merge_disjoint_objects() {
2549        let mut base = serde_json::json!({"a": 1});
2550        let overlay = serde_json::json!({"b": 2});
2551        deep_merge_json(&mut base, overlay);
2552        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2553    }
2554
2555    // ── MAX_EXTENDS_DEPTH constant ──────────────────────────────────
2556
2557    #[test]
2558    fn max_extends_depth_is_reasonable() {
2559        assert_eq!(MAX_EXTENDS_DEPTH, 10);
2560    }
2561
2562    // ── Config names constant ───────────────────────────────────────
2563
2564    #[test]
2565    fn config_names_has_three_entries() {
2566        assert_eq!(CONFIG_NAMES.len(), 3);
2567        // All names should start with "." or "fallow"
2568        for name in CONFIG_NAMES {
2569            assert!(
2570                name.starts_with('.') || name.starts_with("fallow"),
2571                "unexpected config name: {name}"
2572            );
2573        }
2574    }
2575
2576    // ── package.json peer dependency names ───────────────────────────
2577
2578    #[test]
2579    fn package_json_peer_dependency_names() {
2580        let pkg: PackageJson = serde_json::from_str(
2581            r#"{
2582            "dependencies": {"react": "^18"},
2583            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2584        }"#,
2585        )
2586        .unwrap();
2587        let all = pkg.all_dependency_names();
2588        assert!(all.contains(&"react".to_string()));
2589        assert!(all.contains(&"react-dom".to_string()));
2590        assert!(all.contains(&"react-native".to_string()));
2591    }
2592
2593    // ── package.json scripts field ──────────────────────────────────
2594
2595    #[test]
2596    fn package_json_scripts_field() {
2597        let pkg: PackageJson = serde_json::from_str(
2598            r#"{
2599            "scripts": {
2600                "build": "tsc",
2601                "test": "vitest",
2602                "lint": "fallow check"
2603            }
2604        }"#,
2605        )
2606        .unwrap();
2607        let scripts = pkg.scripts.unwrap();
2608        assert_eq!(scripts.len(), 3);
2609        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2610        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2611    }
2612
2613    // ── Extends with TOML-to-TOML chain ─────────────────────────────
2614
2615    #[test]
2616    fn extends_toml_chain() {
2617        let dir = test_dir("extends-toml-chain");
2618
2619        std::fs::write(
2620            dir.path().join("base.json"),
2621            r#"{"entry": ["src/base.ts"]}"#,
2622        )
2623        .unwrap();
2624        std::fs::write(
2625            dir.path().join("middle.json"),
2626            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2627        )
2628        .unwrap();
2629        std::fs::write(
2630            dir.path().join("fallow.toml"),
2631            "extends = [\"middle.json\"]\n",
2632        )
2633        .unwrap();
2634
2635        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2636        assert_eq!(config.entry, vec!["src/base.ts"]);
2637        assert_eq!(config.rules.unused_files, Severity::Off);
2638    }
2639
2640    // ── find_and_load walks up to parent ────────────────────────────
2641
2642    #[test]
2643    fn find_and_load_walks_up_directories() {
2644        let dir = test_dir("find-walk-up");
2645        let sub = dir.path().join("src").join("deep");
2646        std::fs::create_dir_all(&sub).unwrap();
2647        std::fs::write(
2648            dir.path().join(".fallowrc.json"),
2649            r#"{"entry": ["src/main.ts"]}"#,
2650        )
2651        .unwrap();
2652        // Create .git in root to stop search there
2653        std::fs::create_dir(dir.path().join(".git")).unwrap();
2654
2655        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2656        assert_eq!(config.entry, vec!["src/main.ts"]);
2657        assert!(path.ends_with(".fallowrc.json"));
2658    }
2659
2660    // ── JSON schema generation ──────────────────────────────────────
2661
2662    #[test]
2663    fn json_schema_contains_entry_field() {
2664        let schema = FallowConfig::json_schema();
2665        let obj = schema.as_object().unwrap();
2666        let props = obj.get("properties").and_then(|v| v.as_object());
2667        assert!(props.is_some(), "schema should have properties");
2668        assert!(
2669            props.unwrap().contains_key("entry"),
2670            "schema should contain entry property"
2671        );
2672    }
2673
2674    // ── Duplicates config via JSON in FallowConfig ──────────────────
2675
2676    #[test]
2677    fn fallow_config_json_duplicates_all_fields() {
2678        let json = r#"{
2679            "duplicates": {
2680                "enabled": true,
2681                "mode": "semantic",
2682                "minTokens": 200,
2683                "minLines": 20,
2684                "threshold": 10.5,
2685                "ignore": ["**/*.test.ts"],
2686                "skipLocal": true,
2687                "crossLanguage": true,
2688                "normalization": {
2689                    "ignoreIdentifiers": true,
2690                    "ignoreStringValues": false
2691                }
2692            }
2693        }"#;
2694        let config: FallowConfig = serde_json::from_str(json).unwrap();
2695        assert!(config.duplicates.enabled);
2696        assert_eq!(
2697            config.duplicates.mode,
2698            crate::config::DetectionMode::Semantic
2699        );
2700        assert_eq!(config.duplicates.min_tokens, 200);
2701        assert_eq!(config.duplicates.min_lines, 20);
2702        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2703        assert!(config.duplicates.skip_local);
2704        assert!(config.duplicates.cross_language);
2705        assert_eq!(
2706            config.duplicates.normalization.ignore_identifiers,
2707            Some(true)
2708        );
2709        assert_eq!(
2710            config.duplicates.normalization.ignore_string_values,
2711            Some(false)
2712        );
2713    }
2714
2715    // ── URL extends tests ───────────────────────────────────────────
2716
2717    #[test]
2718    fn normalize_url_basic() {
2719        assert_eq!(
2720            normalize_url_for_dedup("https://example.com/config.json"),
2721            "https://example.com/config.json"
2722        );
2723    }
2724
2725    #[test]
2726    fn normalize_url_trailing_slash() {
2727        assert_eq!(
2728            normalize_url_for_dedup("https://example.com/config/"),
2729            "https://example.com/config"
2730        );
2731    }
2732
2733    #[test]
2734    fn normalize_url_uppercase_scheme_and_host() {
2735        assert_eq!(
2736            normalize_url_for_dedup("HTTPS://Example.COM/Config.json"),
2737            "https://example.com/Config.json"
2738        );
2739    }
2740
2741    #[test]
2742    fn normalize_url_root_path() {
2743        assert_eq!(
2744            normalize_url_for_dedup("https://example.com/"),
2745            "https://example.com"
2746        );
2747        assert_eq!(
2748            normalize_url_for_dedup("https://example.com"),
2749            "https://example.com"
2750        );
2751    }
2752
2753    #[test]
2754    fn normalize_url_preserves_path_case() {
2755        // Path component casing is significant (server-dependent), only scheme+host lowercase.
2756        assert_eq!(
2757            normalize_url_for_dedup("https://GitHub.COM/Org/Repo/Fallow.json"),
2758            "https://github.com/Org/Repo/Fallow.json"
2759        );
2760    }
2761
2762    #[test]
2763    fn normalize_url_strips_query_string() {
2764        assert_eq!(
2765            normalize_url_for_dedup("https://example.com/config.json?v=1"),
2766            "https://example.com/config.json"
2767        );
2768    }
2769
2770    #[test]
2771    fn normalize_url_strips_fragment() {
2772        assert_eq!(
2773            normalize_url_for_dedup("https://example.com/config.json#section"),
2774            "https://example.com/config.json"
2775        );
2776    }
2777
2778    #[test]
2779    fn normalize_url_strips_query_and_fragment() {
2780        assert_eq!(
2781            normalize_url_for_dedup("https://example.com/config.json?v=1#section"),
2782            "https://example.com/config.json"
2783        );
2784    }
2785
2786    #[test]
2787    fn normalize_url_default_https_port() {
2788        assert_eq!(
2789            normalize_url_for_dedup("https://example.com:443/config.json"),
2790            "https://example.com/config.json"
2791        );
2792        // Non-default port is preserved.
2793        assert_eq!(
2794            normalize_url_for_dedup("https://example.com:8443/config.json"),
2795            "https://example.com:8443/config.json"
2796        );
2797    }
2798
2799    #[test]
2800    fn extends_http_rejected() {
2801        let dir = test_dir("http-rejected");
2802        std::fs::write(
2803            dir.path().join(".fallowrc.json"),
2804            r#"{"extends": "http://example.com/config.json"}"#,
2805        )
2806        .unwrap();
2807
2808        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2809        assert!(result.is_err());
2810        let err_msg = format!("{}", result.unwrap_err());
2811        assert!(
2812            err_msg.contains("https://"),
2813            "Expected https hint in error, got: {err_msg}"
2814        );
2815        assert!(
2816            err_msg.contains("http://"),
2817            "Expected http:// mention in error, got: {err_msg}"
2818        );
2819    }
2820
2821    #[test]
2822    fn extends_url_circular_detection() {
2823        // Verify that the same URL appearing twice in the visited set is detected.
2824        let mut visited = FxHashSet::default();
2825        let url = "https://example.com/config.json";
2826        let normalized = normalize_url_for_dedup(url);
2827        visited.insert(normalized.clone());
2828
2829        // Inserting the same normalized URL should return false.
2830        assert!(
2831            !visited.insert(normalized),
2832            "Same URL should be detected as duplicate"
2833        );
2834    }
2835
2836    #[test]
2837    fn extends_url_circular_case_insensitive() {
2838        // URLs differing only in scheme/host casing should be detected as circular.
2839        let mut visited = FxHashSet::default();
2840        visited.insert(normalize_url_for_dedup("https://Example.COM/config.json"));
2841
2842        let normalized = normalize_url_for_dedup("HTTPS://example.com/config.json");
2843        assert!(
2844            !visited.insert(normalized),
2845            "Case-different URLs should normalize to the same key"
2846        );
2847    }
2848
2849    #[test]
2850    fn extract_extends_array() {
2851        let mut value = serde_json::json!({
2852            "extends": ["a.json", "b.json"],
2853            "entry": ["src/index.ts"]
2854        });
2855        let extends = extract_extends(&mut value);
2856        assert_eq!(extends, vec!["a.json", "b.json"]);
2857        // extends should be removed from the value.
2858        assert!(value.get("extends").is_none());
2859        assert!(value.get("entry").is_some());
2860    }
2861
2862    #[test]
2863    fn extract_extends_string_sugar() {
2864        let mut value = serde_json::json!({
2865            "extends": "base.json",
2866            "entry": ["src/index.ts"]
2867        });
2868        let extends = extract_extends(&mut value);
2869        assert_eq!(extends, vec!["base.json"]);
2870    }
2871
2872    #[test]
2873    fn extract_extends_none() {
2874        let mut value = serde_json::json!({"entry": ["src/index.ts"]});
2875        let extends = extract_extends(&mut value);
2876        assert!(extends.is_empty());
2877    }
2878
2879    #[test]
2880    fn url_timeout_default() {
2881        // Without the env var set, should return the default.
2882        let timeout = url_timeout();
2883        // We can't assert exact value since the env var might be set in the test environment,
2884        // but we can assert it's a reasonable duration.
2885        assert!(timeout.as_secs() <= 300, "Timeout should be reasonable");
2886    }
2887
2888    #[test]
2889    fn extends_url_mixed_with_file_and_npm() {
2890        // Test that a config with a mix of file, npm, and URL extends parses correctly
2891        // for the non-URL parts, and produces a clear error for the URL part (no server).
2892        let dir = test_dir("url-mixed");
2893        std::fs::write(
2894            dir.path().join("local.json"),
2895            r#"{"rules": {"unused-files": "warn"}}"#,
2896        )
2897        .unwrap();
2898        std::fs::write(
2899            dir.path().join(".fallowrc.json"),
2900            r#"{"extends": ["local.json", "https://unreachable.invalid/config.json"]}"#,
2901        )
2902        .unwrap();
2903
2904        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2905        assert!(result.is_err());
2906        let err_msg = format!("{}", result.unwrap_err());
2907        assert!(
2908            err_msg.contains("unreachable.invalid"),
2909            "Expected URL in error message, got: {err_msg}"
2910        );
2911    }
2912
2913    #[test]
2914    fn extends_https_url_unreachable_errors() {
2915        let dir = test_dir("url-unreachable");
2916        std::fs::write(
2917            dir.path().join(".fallowrc.json"),
2918            r#"{"extends": "https://unreachable.invalid/config.json"}"#,
2919        )
2920        .unwrap();
2921
2922        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
2923        assert!(result.is_err());
2924        let err_msg = format!("{}", result.unwrap_err());
2925        assert!(
2926            err_msg.contains("unreachable.invalid"),
2927            "Expected URL in error, got: {err_msg}"
2928        );
2929        assert!(
2930            err_msg.contains("local path or npm:"),
2931            "Expected remediation hint, got: {err_msg}"
2932        );
2933    }
2934}