Skip to main content

fallow_config/config/
parsing.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::FxHashSet;
5
6use super::FallowConfig;
7
8/// Supported config file names in priority order.
9///
10/// `find_and_load` checks these names in order within each directory,
11/// returning the first match found.
12pub(super) const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
13
14pub(super) const MAX_EXTENDS_DEPTH: usize = 10;
15
16/// Prefix for npm package specifiers in the `extends` field.
17const NPM_PREFIX: &str = "npm:";
18
19/// Detect config format from file extension.
20pub(super) enum ConfigFormat {
21    Toml,
22    Json,
23}
24
25impl ConfigFormat {
26    pub(super) fn from_path(path: &Path) -> Self {
27        match path.extension().and_then(|e| e.to_str()) {
28            Some("json") => Self::Json,
29            _ => Self::Toml,
30        }
31    }
32}
33
34/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
35/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
36pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
37    match (base, overlay) {
38        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
39            for (key, value) in overlay_map {
40                if let Some(base_value) = base_map.get_mut(&key) {
41                    deep_merge_json(base_value, value);
42                } else {
43                    base_map.insert(key, value);
44                }
45            }
46        }
47        (base, overlay) => {
48            *base = overlay;
49        }
50    }
51}
52
53pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
54    let content = std::fs::read_to_string(path)
55        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
56
57    match ConfigFormat::from_path(path) {
58        ConfigFormat::Toml => {
59            let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
60                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
61            })?;
62            serde_json::to_value(toml_value).map_err(|e| {
63                miette::miette!(
64                    "Failed to convert TOML to JSON for {}: {}",
65                    path.display(),
66                    e
67                )
68            })
69        }
70        ConfigFormat::Json => {
71            let mut stripped = String::new();
72            json_comments::StripComments::new(content.as_bytes())
73                .read_to_string(&mut stripped)
74                .map_err(|e| {
75                    miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
76                })?;
77            serde_json::from_str(&stripped).map_err(|e| {
78                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
79            })
80        }
81    }
82}
83
84/// Verify that `resolved` stays within `base_dir` after canonicalization.
85///
86/// Prevents path traversal attacks where a subpath or `package.json` field
87/// like `../../etc/passwd` escapes the intended directory.
88fn resolve_confined(
89    base_dir: &Path,
90    resolved: &Path,
91    context: &str,
92    source_config: &Path,
93) -> Result<PathBuf, miette::Report> {
94    let canonical_base = base_dir
95        .canonicalize()
96        .map_err(|e| miette::miette!("Failed to resolve base dir {}: {}", base_dir.display(), e))?;
97    let canonical_file = resolved.canonicalize().map_err(|e| {
98        miette::miette!(
99            "Config file not found: {} ({}, referenced from {}): {}",
100            resolved.display(),
101            context,
102            source_config.display(),
103            e
104        )
105    })?;
106    if !canonical_file.starts_with(&canonical_base) {
107        return Err(miette::miette!(
108            "Path traversal detected: {} escapes package directory {} ({}, referenced from {})",
109            resolved.display(),
110            base_dir.display(),
111            context,
112            source_config.display()
113        ));
114    }
115    Ok(canonical_file)
116}
117
118/// Validate that a parsed package name is a legal npm package name.
119fn validate_npm_package_name(name: &str, source_config: &Path) -> Result<(), miette::Report> {
120    if name.starts_with('@') && !name.contains('/') {
121        return Err(miette::miette!(
122            "Invalid scoped npm package name '{}': must be '@scope/name' (referenced from {})",
123            name,
124            source_config.display()
125        ));
126    }
127    if name.split('/').any(|c| c == ".." || c == ".") {
128        return Err(miette::miette!(
129            "Invalid npm package name '{}': path traversal components not allowed (referenced from {})",
130            name,
131            source_config.display()
132        ));
133    }
134    Ok(())
135}
136
137/// Parse an npm specifier into `(package_name, optional_subpath)`.
138///
139/// Scoped: `@scope/name` → `("@scope/name", None)`,
140///         `@scope/name/strict.json` → `("@scope/name", Some("strict.json"))`.
141/// Unscoped: `name` → `("name", None)`,
142///           `name/strict.json` → `("name", Some("strict.json"))`.
143fn parse_npm_specifier(specifier: &str) -> (&str, Option<&str>) {
144    if specifier.starts_with('@') {
145        // Scoped: @scope/name[/subpath]
146        // Find the second '/' which separates name from subpath.
147        let mut slashes = 0;
148        for (i, ch) in specifier.char_indices() {
149            if ch == '/' {
150                slashes += 1;
151                if slashes == 2 {
152                    return (&specifier[..i], Some(&specifier[i + 1..]));
153                }
154            }
155        }
156        // No subpath — entire string is the package name.
157        (specifier, None)
158    } else if let Some(slash) = specifier.find('/') {
159        (&specifier[..slash], Some(&specifier[slash + 1..]))
160    } else {
161        (specifier, None)
162    }
163}
164
165/// Resolve the default export path from a `package.json` `exports` field.
166///
167/// Handles the common patterns:
168/// - `"exports": "./config.json"` (string shorthand)
169/// - `"exports": {".": "./config.json"}` (object with default entry point)
170/// - `"exports": {".": {"default": "./config.json"}}` (conditional exports)
171fn resolve_package_exports(pkg: &serde_json::Value, package_dir: &Path) -> Option<PathBuf> {
172    let exports = pkg.get("exports")?;
173    match exports {
174        serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
175        serde_json::Value::Object(map) => {
176            let dot_export = map.get(".")?;
177            match dot_export {
178                serde_json::Value::String(s) => Some(package_dir.join(s.as_str())),
179                serde_json::Value::Object(conditions) => {
180                    for key in ["default", "node", "import", "require"] {
181                        if let Some(serde_json::Value::String(s)) = conditions.get(key) {
182                            return Some(package_dir.join(s.as_str()));
183                        }
184                    }
185                    None
186                }
187                _ => None,
188            }
189        }
190        // Array export fallback form (e.g., `[\"./config.json\", null]`) is not supported;
191        // falls through to main/config name scan.
192        _ => None,
193    }
194}
195
196/// Find a fallow config file inside an npm package directory.
197///
198/// Resolution order:
199/// 1. `package.json` `exports` field (default entry point)
200/// 2. `package.json` `main` field
201/// 3. Standard config file names (`.fallowrc.json`, `fallow.toml`, `.fallow.toml`)
202///
203/// Paths from `exports`/`main` are confined to the package directory to prevent
204/// path traversal attacks from malicious packages.
205fn find_config_in_npm_package(
206    package_dir: &Path,
207    source_config: &Path,
208) -> Result<PathBuf, miette::Report> {
209    let pkg_json_path = package_dir.join("package.json");
210    if pkg_json_path.exists() {
211        let content = std::fs::read_to_string(&pkg_json_path)
212            .map_err(|e| miette::miette!("Failed to read {}: {}", pkg_json_path.display(), e))?;
213        let pkg: serde_json::Value = serde_json::from_str(&content)
214            .map_err(|e| miette::miette!("Failed to parse {}: {}", pkg_json_path.display(), e))?;
215        if let Some(config_path) = resolve_package_exports(&pkg, package_dir)
216            && config_path.exists()
217        {
218            return resolve_confined(
219                package_dir,
220                &config_path,
221                "package.json exports",
222                source_config,
223            );
224        }
225        if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
226            let main_path = package_dir.join(main);
227            if main_path.exists() {
228                return resolve_confined(
229                    package_dir,
230                    &main_path,
231                    "package.json main",
232                    source_config,
233                );
234            }
235        }
236    }
237
238    for config_name in CONFIG_NAMES {
239        let config_path = package_dir.join(config_name);
240        if config_path.exists() {
241            return resolve_confined(
242                package_dir,
243                &config_path,
244                "config name fallback",
245                source_config,
246            );
247        }
248    }
249
250    Err(miette::miette!(
251        "No fallow config found in npm package at {}. \
252         Expected package.json with main/exports pointing to a config file, \
253         or one of: {}",
254        package_dir.display(),
255        CONFIG_NAMES.join(", ")
256    ))
257}
258
259/// Resolve an npm package specifier to a config file path.
260///
261/// Walks up from `config_dir` looking for `node_modules/<package_name>`.
262/// If a subpath is given (e.g., `@scope/name/strict.json`), resolves that file directly.
263/// Otherwise, finds the config file inside the package via [`find_config_in_npm_package`].
264fn resolve_npm_package(
265    config_dir: &Path,
266    specifier: &str,
267    source_config: &Path,
268) -> Result<PathBuf, miette::Report> {
269    let specifier = specifier.trim();
270    if specifier.is_empty() {
271        return Err(miette::miette!(
272            "Empty npm specifier in extends (in {})",
273            source_config.display()
274        ));
275    }
276
277    let (package_name, subpath) = parse_npm_specifier(specifier);
278    validate_npm_package_name(package_name, source_config)?;
279
280    let mut dir = Some(config_dir);
281    while let Some(d) = dir {
282        let candidate = d.join("node_modules").join(package_name);
283        if candidate.is_dir() {
284            return if let Some(sub) = subpath {
285                let file = candidate.join(sub);
286                if file.exists() {
287                    resolve_confined(
288                        &candidate,
289                        &file,
290                        &format!("subpath '{sub}'"),
291                        source_config,
292                    )
293                } else {
294                    Err(miette::miette!(
295                        "File not found in npm package: {} (looked for '{}' in {}, referenced from {})",
296                        file.display(),
297                        sub,
298                        candidate.display(),
299                        source_config.display()
300                    ))
301                }
302            } else {
303                find_config_in_npm_package(&candidate, source_config)
304            };
305        }
306        dir = d.parent();
307    }
308
309    Err(miette::miette!(
310        "npm package '{}' not found. \
311         Searched for node_modules/{} in ancestor directories of {} (referenced from {}). \
312         If this package should be available, install it and ensure it is listed in your project's dependencies",
313        package_name,
314        package_name,
315        config_dir.display(),
316        source_config.display()
317    ))
318}
319
320pub(super) fn resolve_extends(
321    path: &Path,
322    visited: &mut FxHashSet<PathBuf>,
323    depth: usize,
324) -> Result<serde_json::Value, miette::Report> {
325    if depth >= MAX_EXTENDS_DEPTH {
326        return Err(miette::miette!(
327            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
328            path.display()
329        ));
330    }
331
332    let canonical = path.canonicalize().map_err(|e| {
333        miette::miette!(
334            "Config file not found or unresolvable: {}: {}",
335            path.display(),
336            e
337        )
338    })?;
339
340    if !visited.insert(canonical) {
341        return Err(miette::miette!(
342            "Circular extends detected: {} was already visited in the extends chain",
343            path.display()
344        ));
345    }
346
347    let mut value = parse_config_to_value(path)?;
348
349    let extends = value
350        .as_object_mut()
351        .and_then(|obj| obj.remove("extends"))
352        .and_then(|v| match v {
353            serde_json::Value::Array(arr) => Some(
354                arr.into_iter()
355                    .filter_map(|v| v.as_str().map(String::from))
356                    .collect::<Vec<_>>(),
357            ),
358            serde_json::Value::String(s) => Some(vec![s]),
359            _ => None,
360        })
361        .unwrap_or_default();
362
363    if extends.is_empty() {
364        return Ok(value);
365    }
366
367    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
368    let mut merged = serde_json::Value::Object(serde_json::Map::new());
369
370    for extend_path_str in &extends {
371        let extend_path = if let Some(npm_specifier) = extend_path_str.strip_prefix(NPM_PREFIX) {
372            resolve_npm_package(config_dir, npm_specifier, path)?
373        } else {
374            if Path::new(extend_path_str).is_absolute() {
375                return Err(miette::miette!(
376                    "extends paths must be relative, got absolute path: {} (in {})",
377                    extend_path_str,
378                    path.display()
379                ));
380            }
381            let p = config_dir.join(extend_path_str);
382            if !p.exists() {
383                return Err(miette::miette!(
384                    "Extended config file not found: {} (referenced from {})",
385                    p.display(),
386                    path.display()
387                ));
388            }
389            p
390        };
391        let base = resolve_extends(&extend_path, visited, depth + 1)?;
392        deep_merge_json(&mut merged, base);
393    }
394
395    deep_merge_json(&mut merged, value);
396    Ok(merged)
397}
398
399impl FallowConfig {
400    /// Load config from a fallow config file (TOML or JSON/JSONC).
401    ///
402    /// The format is detected from the file extension:
403    /// - `.toml` → TOML
404    /// - `.json` → JSON (with JSONC comment stripping)
405    ///
406    /// Supports `extends` for config inheritance. Extended configs are loaded
407    /// and deep-merged before this config's values are applied.
408    ///
409    /// # Errors
410    ///
411    /// Returns an error when the config file cannot be read, merged, or deserialized.
412    pub fn load(path: &Path) -> Result<Self, miette::Report> {
413        let mut visited = FxHashSet::default();
414        let merged = resolve_extends(path, &mut visited, 0)?;
415
416        serde_json::from_value(merged).map_err(|e| {
417            miette::miette!(
418                "Failed to deserialize config from {}: {}",
419                path.display(),
420                e
421            )
422        })
423    }
424
425    /// Find the config file path without loading it.
426    /// Searches the same locations as `find_and_load`.
427    #[must_use]
428    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
429        let mut dir = start;
430        loop {
431            for name in CONFIG_NAMES {
432                let candidate = dir.join(name);
433                if candidate.exists() {
434                    return Some(candidate);
435                }
436            }
437            if dir.join(".git").exists() || dir.join("package.json").exists() {
438                break;
439            }
440            dir = dir.parent()?;
441        }
442        None
443    }
444
445    /// Find and load config, searching from `start` up to the project root.
446    ///
447    /// # Errors
448    ///
449    /// Returns an error if a config file is found but cannot be read or parsed.
450    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
451        let mut dir = start;
452        loop {
453            for name in CONFIG_NAMES {
454                let candidate = dir.join(name);
455                if candidate.exists() {
456                    match Self::load(&candidate) {
457                        Ok(config) => return Ok(Some((config, candidate))),
458                        Err(e) => {
459                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
460                        }
461                    }
462                }
463            }
464            // Stop at project root indicators
465            if dir.join(".git").exists() || dir.join("package.json").exists() {
466                break;
467            }
468            dir = match dir.parent() {
469                Some(parent) => parent,
470                None => break,
471            };
472        }
473        Ok(None)
474    }
475
476    /// Generate JSON Schema for the configuration format.
477    #[must_use]
478    pub fn json_schema() -> serde_json::Value {
479        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use std::io::Read as _;
486
487    use super::*;
488    use crate::PackageJson;
489    use crate::config::boundaries::BoundaryConfig;
490    use crate::config::duplicates_config::DuplicatesConfig;
491    use crate::config::format::OutputFormat;
492    use crate::config::health::HealthConfig;
493    use crate::config::rules::{RulesConfig, Severity};
494
495    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
496    fn test_dir(_name: &str) -> tempfile::TempDir {
497        tempfile::tempdir().expect("create temp dir")
498    }
499
500    #[test]
501    fn fallow_config_deserialize_minimal() {
502        let toml_str = r#"
503entry = ["src/main.ts"]
504"#;
505        let config: FallowConfig = toml::from_str(toml_str).unwrap();
506        assert_eq!(config.entry, vec!["src/main.ts"]);
507        assert!(config.ignore_patterns.is_empty());
508    }
509
510    #[test]
511    fn fallow_config_deserialize_ignore_exports() {
512        let toml_str = r#"
513[[ignoreExports]]
514file = "src/types/*.ts"
515exports = ["*"]
516
517[[ignoreExports]]
518file = "src/constants.ts"
519exports = ["FOO", "BAR"]
520"#;
521        let config: FallowConfig = toml::from_str(toml_str).unwrap();
522        assert_eq!(config.ignore_exports.len(), 2);
523        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
524        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
525        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
526    }
527
528    #[test]
529    fn fallow_config_deserialize_ignore_dependencies() {
530        let toml_str = r#"
531ignoreDependencies = ["autoprefixer", "postcss"]
532"#;
533        let config: FallowConfig = toml::from_str(toml_str).unwrap();
534        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
535    }
536
537    #[test]
538    fn fallow_config_resolve_default_ignores() {
539        let config = FallowConfig {
540            schema: None,
541            extends: vec![],
542            entry: vec![],
543            ignore_patterns: vec![],
544            framework: vec![],
545            workspaces: None,
546            ignore_dependencies: vec![],
547            ignore_exports: vec![],
548            duplicates: DuplicatesConfig::default(),
549            health: HealthConfig::default(),
550            rules: RulesConfig::default(),
551            boundaries: BoundaryConfig::default(),
552            production: false,
553            plugins: vec![],
554            overrides: vec![],
555            regression: None,
556        };
557        let resolved = config.resolve(
558            PathBuf::from("/tmp/test"),
559            OutputFormat::Human,
560            4,
561            true,
562            true,
563        );
564
565        // Default ignores should be compiled
566        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
567        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
568        assert!(resolved.ignore_patterns.is_match("build/output.js"));
569        assert!(resolved.ignore_patterns.is_match(".git/config"));
570        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
571        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
572        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
573    }
574
575    #[test]
576    fn fallow_config_resolve_custom_ignores() {
577        let config = FallowConfig {
578            schema: None,
579            extends: vec![],
580            entry: vec!["src/**/*.ts".to_string()],
581            ignore_patterns: vec!["**/*.generated.ts".to_string()],
582            framework: vec![],
583            workspaces: None,
584            ignore_dependencies: vec![],
585            ignore_exports: vec![],
586            duplicates: DuplicatesConfig::default(),
587            health: HealthConfig::default(),
588            rules: RulesConfig::default(),
589            boundaries: BoundaryConfig::default(),
590            production: false,
591            plugins: vec![],
592            overrides: vec![],
593            regression: None,
594        };
595        let resolved = config.resolve(
596            PathBuf::from("/tmp/test"),
597            OutputFormat::Json,
598            4,
599            false,
600            true,
601        );
602
603        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
604        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
605        assert!(matches!(resolved.output, OutputFormat::Json));
606        assert!(!resolved.no_cache);
607    }
608
609    #[test]
610    fn fallow_config_resolve_cache_dir() {
611        let config = FallowConfig {
612            schema: None,
613            extends: vec![],
614            entry: vec![],
615            ignore_patterns: vec![],
616            framework: vec![],
617            workspaces: None,
618            ignore_dependencies: vec![],
619            ignore_exports: vec![],
620            duplicates: DuplicatesConfig::default(),
621            health: HealthConfig::default(),
622            rules: RulesConfig::default(),
623            boundaries: BoundaryConfig::default(),
624            production: false,
625            plugins: vec![],
626            overrides: vec![],
627            regression: None,
628        };
629        let resolved = config.resolve(
630            PathBuf::from("/tmp/project"),
631            OutputFormat::Human,
632            4,
633            true,
634            true,
635        );
636        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
637        assert!(resolved.no_cache);
638    }
639
640    #[test]
641    fn package_json_entry_points_main() {
642        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
643        let entries = pkg.entry_points();
644        assert!(entries.contains(&"dist/index.js".to_string()));
645    }
646
647    #[test]
648    fn package_json_entry_points_module() {
649        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
650        let entries = pkg.entry_points();
651        assert!(entries.contains(&"dist/index.mjs".to_string()));
652    }
653
654    #[test]
655    fn package_json_entry_points_types() {
656        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
657        let entries = pkg.entry_points();
658        assert!(entries.contains(&"dist/index.d.ts".to_string()));
659    }
660
661    #[test]
662    fn package_json_entry_points_bin_string() {
663        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
664        let entries = pkg.entry_points();
665        assert!(entries.contains(&"bin/cli.js".to_string()));
666    }
667
668    #[test]
669    fn package_json_entry_points_bin_object() {
670        let pkg: PackageJson =
671            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
672                .unwrap();
673        let entries = pkg.entry_points();
674        assert!(entries.contains(&"bin/cli.js".to_string()));
675        assert!(entries.contains(&"bin/serve.js".to_string()));
676    }
677
678    #[test]
679    fn package_json_entry_points_exports_string() {
680        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
681        let entries = pkg.entry_points();
682        assert!(entries.contains(&"./dist/index.js".to_string()));
683    }
684
685    #[test]
686    fn package_json_entry_points_exports_object() {
687        let pkg: PackageJson = serde_json::from_str(
688            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
689        )
690        .unwrap();
691        let entries = pkg.entry_points();
692        assert!(entries.contains(&"./dist/index.mjs".to_string()));
693        assert!(entries.contains(&"./dist/index.cjs".to_string()));
694    }
695
696    #[test]
697    fn package_json_dependency_names() {
698        let pkg: PackageJson = serde_json::from_str(
699            r#"{
700            "dependencies": {"react": "^18", "lodash": "^4"},
701            "devDependencies": {"typescript": "^5"},
702            "peerDependencies": {"react-dom": "^18"}
703        }"#,
704        )
705        .unwrap();
706
707        let all = pkg.all_dependency_names();
708        assert!(all.contains(&"react".to_string()));
709        assert!(all.contains(&"lodash".to_string()));
710        assert!(all.contains(&"typescript".to_string()));
711        assert!(all.contains(&"react-dom".to_string()));
712
713        let prod = pkg.production_dependency_names();
714        assert!(prod.contains(&"react".to_string()));
715        assert!(!prod.contains(&"typescript".to_string()));
716
717        let dev = pkg.dev_dependency_names();
718        assert!(dev.contains(&"typescript".to_string()));
719        assert!(!dev.contains(&"react".to_string()));
720    }
721
722    #[test]
723    fn package_json_no_dependencies() {
724        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
725        assert!(pkg.all_dependency_names().is_empty());
726        assert!(pkg.production_dependency_names().is_empty());
727        assert!(pkg.dev_dependency_names().is_empty());
728        assert!(pkg.entry_points().is_empty());
729    }
730
731    #[test]
732    fn rules_deserialize_toml_kebab_case() {
733        let toml_str = r#"
734[rules]
735unused-files = "error"
736unused-exports = "warn"
737unused-types = "off"
738"#;
739        let config: FallowConfig = toml::from_str(toml_str).unwrap();
740        assert_eq!(config.rules.unused_files, Severity::Error);
741        assert_eq!(config.rules.unused_exports, Severity::Warn);
742        assert_eq!(config.rules.unused_types, Severity::Off);
743        // Unset fields default to error
744        assert_eq!(config.rules.unresolved_imports, Severity::Error);
745    }
746
747    #[test]
748    fn config_without_rules_defaults_to_error() {
749        let toml_str = r#"
750entry = ["src/main.ts"]
751"#;
752        let config: FallowConfig = toml::from_str(toml_str).unwrap();
753        assert_eq!(config.rules.unused_files, Severity::Error);
754        assert_eq!(config.rules.unused_exports, Severity::Error);
755    }
756
757    #[test]
758    fn fallow_config_denies_unknown_fields() {
759        let toml_str = r"
760unknown_field = true
761";
762        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
763        assert!(result.is_err());
764    }
765
766    #[test]
767    fn fallow_config_deserialize_json() {
768        let json_str = r#"{"entry": ["src/main.ts"]}"#;
769        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
770        assert_eq!(config.entry, vec!["src/main.ts"]);
771    }
772
773    #[test]
774    fn fallow_config_deserialize_jsonc() {
775        let jsonc_str = r#"{
776            // This is a comment
777            "entry": ["src/main.ts"],
778            "rules": {
779                "unused-files": "warn"
780            }
781        }"#;
782        let mut stripped = String::new();
783        json_comments::StripComments::new(jsonc_str.as_bytes())
784            .read_to_string(&mut stripped)
785            .unwrap();
786        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
787        assert_eq!(config.entry, vec!["src/main.ts"]);
788        assert_eq!(config.rules.unused_files, Severity::Warn);
789    }
790
791    #[test]
792    fn fallow_config_json_with_schema_field() {
793        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
794        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
795        assert_eq!(config.entry, vec!["src/main.ts"]);
796    }
797
798    #[test]
799    fn fallow_config_json_schema_generation() {
800        let schema = FallowConfig::json_schema();
801        assert!(schema.is_object());
802        let obj = schema.as_object().unwrap();
803        assert!(obj.contains_key("properties"));
804    }
805
806    #[test]
807    fn config_format_detection() {
808        assert!(matches!(
809            ConfigFormat::from_path(Path::new("fallow.toml")),
810            ConfigFormat::Toml
811        ));
812        assert!(matches!(
813            ConfigFormat::from_path(Path::new(".fallowrc.json")),
814            ConfigFormat::Json
815        ));
816        assert!(matches!(
817            ConfigFormat::from_path(Path::new(".fallow.toml")),
818            ConfigFormat::Toml
819        ));
820    }
821
822    #[test]
823    fn config_names_priority_order() {
824        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
825        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
826        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
827    }
828
829    #[test]
830    fn load_json_config_file() {
831        let dir = test_dir("json-config");
832        let config_path = dir.path().join(".fallowrc.json");
833        std::fs::write(
834            &config_path,
835            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
836        )
837        .unwrap();
838
839        let config = FallowConfig::load(&config_path).unwrap();
840        assert_eq!(config.entry, vec!["src/index.ts"]);
841        assert_eq!(config.rules.unused_exports, Severity::Warn);
842    }
843
844    #[test]
845    fn load_jsonc_config_file() {
846        let dir = test_dir("jsonc-config");
847        let config_path = dir.path().join(".fallowrc.json");
848        std::fs::write(
849            &config_path,
850            r#"{
851                // Entry points for analysis
852                "entry": ["src/index.ts"],
853                /* Block comment */
854                "rules": {
855                    "unused-exports": "warn"
856                }
857            }"#,
858        )
859        .unwrap();
860
861        let config = FallowConfig::load(&config_path).unwrap();
862        assert_eq!(config.entry, vec!["src/index.ts"]);
863        assert_eq!(config.rules.unused_exports, Severity::Warn);
864    }
865
866    #[test]
867    fn json_config_ignore_dependencies_camel_case() {
868        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
869        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
870        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
871    }
872
873    #[test]
874    fn json_config_all_fields() {
875        let json_str = r#"{
876            "ignoreDependencies": ["lodash"],
877            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
878            "rules": {
879                "unused-files": "off",
880                "unused-exports": "warn",
881                "unused-dependencies": "error",
882                "unused-dev-dependencies": "off",
883                "unused-types": "warn",
884                "unused-enum-members": "error",
885                "unused-class-members": "off",
886                "unresolved-imports": "warn",
887                "unlisted-dependencies": "error",
888                "duplicate-exports": "off"
889            },
890            "duplicates": {
891                "minTokens": 100,
892                "minLines": 10,
893                "skipLocal": true
894            }
895        }"#;
896        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
897        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
898        assert_eq!(config.rules.unused_files, Severity::Off);
899        assert_eq!(config.rules.unused_exports, Severity::Warn);
900        assert_eq!(config.rules.unused_dependencies, Severity::Error);
901        assert_eq!(config.duplicates.min_tokens, 100);
902        assert_eq!(config.duplicates.min_lines, 10);
903        assert!(config.duplicates.skip_local);
904    }
905
906    // ── extends tests ──────────────────────────────────────────────
907
908    #[test]
909    fn extends_single_base() {
910        let dir = test_dir("extends-single");
911
912        std::fs::write(
913            dir.path().join("base.json"),
914            r#"{"rules": {"unused-files": "warn"}}"#,
915        )
916        .unwrap();
917        std::fs::write(
918            dir.path().join(".fallowrc.json"),
919            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
920        )
921        .unwrap();
922
923        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
924        assert_eq!(config.rules.unused_files, Severity::Warn);
925        assert_eq!(config.entry, vec!["src/index.ts"]);
926        // Unset fields from base still default
927        assert_eq!(config.rules.unused_exports, Severity::Error);
928    }
929
930    #[test]
931    fn extends_overlay_overrides_base() {
932        let dir = test_dir("extends-overlay");
933
934        std::fs::write(
935            dir.path().join("base.json"),
936            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
937        )
938        .unwrap();
939        std::fs::write(
940            dir.path().join(".fallowrc.json"),
941            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
942        )
943        .unwrap();
944
945        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
946        // Overlay overrides base
947        assert_eq!(config.rules.unused_files, Severity::Error);
948        // Base value preserved when not overridden
949        assert_eq!(config.rules.unused_exports, Severity::Off);
950    }
951
952    #[test]
953    fn extends_chained() {
954        let dir = test_dir("extends-chained");
955
956        std::fs::write(
957            dir.path().join("grandparent.json"),
958            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
959        )
960        .unwrap();
961        std::fs::write(
962            dir.path().join("parent.json"),
963            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
964        )
965        .unwrap();
966        std::fs::write(
967            dir.path().join(".fallowrc.json"),
968            r#"{"extends": ["parent.json"]}"#,
969        )
970        .unwrap();
971
972        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
973        // grandparent: off -> parent: warn -> child: inherits warn
974        assert_eq!(config.rules.unused_files, Severity::Warn);
975        // grandparent: warn, not overridden
976        assert_eq!(config.rules.unused_exports, Severity::Warn);
977    }
978
979    #[test]
980    fn extends_circular_detected() {
981        let dir = test_dir("extends-circular");
982
983        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
984        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
985
986        let result = FallowConfig::load(&dir.path().join("a.json"));
987        assert!(result.is_err());
988        let err_msg = format!("{}", result.unwrap_err());
989        assert!(
990            err_msg.contains("Circular extends"),
991            "Expected circular error, got: {err_msg}"
992        );
993    }
994
995    #[test]
996    fn extends_missing_file_errors() {
997        let dir = test_dir("extends-missing");
998
999        std::fs::write(
1000            dir.path().join(".fallowrc.json"),
1001            r#"{"extends": ["nonexistent.json"]}"#,
1002        )
1003        .unwrap();
1004
1005        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1006        assert!(result.is_err());
1007        let err_msg = format!("{}", result.unwrap_err());
1008        assert!(
1009            err_msg.contains("not found"),
1010            "Expected not found error, got: {err_msg}"
1011        );
1012    }
1013
1014    #[test]
1015    fn extends_string_sugar() {
1016        let dir = test_dir("extends-string");
1017
1018        std::fs::write(
1019            dir.path().join("base.json"),
1020            r#"{"ignorePatterns": ["gen/**"]}"#,
1021        )
1022        .unwrap();
1023        // String form instead of array
1024        std::fs::write(
1025            dir.path().join(".fallowrc.json"),
1026            r#"{"extends": "base.json"}"#,
1027        )
1028        .unwrap();
1029
1030        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1031        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1032    }
1033
1034    #[test]
1035    fn extends_deep_merge_preserves_arrays() {
1036        let dir = test_dir("extends-array");
1037
1038        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1039        std::fs::write(
1040            dir.path().join(".fallowrc.json"),
1041            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1042        )
1043        .unwrap();
1044
1045        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1046        // Arrays are replaced, not merged (overlay replaces base)
1047        assert_eq!(config.entry, vec!["src/b.ts"]);
1048    }
1049
1050    // ── npm extends tests ────────────────────────────────────────────
1051
1052    /// Set up a fake npm package in `node_modules/<name>` under `root`.
1053    fn create_npm_package(root: &Path, name: &str, config_json: &str) {
1054        let pkg_dir = root.join("node_modules").join(name);
1055        std::fs::create_dir_all(&pkg_dir).unwrap();
1056        std::fs::write(pkg_dir.join(".fallowrc.json"), config_json).unwrap();
1057    }
1058
1059    /// Set up a fake npm package with `package.json` `main` field.
1060    fn create_npm_package_with_main(root: &Path, name: &str, main: &str, config_json: &str) {
1061        let pkg_dir = root.join("node_modules").join(name);
1062        std::fs::create_dir_all(&pkg_dir).unwrap();
1063        std::fs::write(
1064            pkg_dir.join("package.json"),
1065            format!(r#"{{"name": "{name}", "main": "{main}"}}"#),
1066        )
1067        .unwrap();
1068        std::fs::write(pkg_dir.join(main), config_json).unwrap();
1069    }
1070
1071    #[test]
1072    fn extends_npm_basic_unscoped() {
1073        let dir = test_dir("npm-basic");
1074        create_npm_package(
1075            dir.path(),
1076            "fallow-config-acme",
1077            r#"{"rules": {"unused-files": "warn"}}"#,
1078        );
1079        std::fs::write(
1080            dir.path().join(".fallowrc.json"),
1081            r#"{"extends": "npm:fallow-config-acme"}"#,
1082        )
1083        .unwrap();
1084
1085        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1086        assert_eq!(config.rules.unused_files, Severity::Warn);
1087    }
1088
1089    #[test]
1090    fn extends_npm_scoped_package() {
1091        let dir = test_dir("npm-scoped");
1092        create_npm_package(
1093            dir.path(),
1094            "@company/fallow-config",
1095            r#"{"rules": {"unused-exports": "off"}, "ignorePatterns": ["generated/**"]}"#,
1096        );
1097        std::fs::write(
1098            dir.path().join(".fallowrc.json"),
1099            r#"{"extends": "npm:@company/fallow-config"}"#,
1100        )
1101        .unwrap();
1102
1103        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1104        assert_eq!(config.rules.unused_exports, Severity::Off);
1105        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
1106    }
1107
1108    #[test]
1109    fn extends_npm_with_subpath() {
1110        let dir = test_dir("npm-subpath");
1111        let pkg_dir = dir.path().join("node_modules/@company/fallow-config");
1112        std::fs::create_dir_all(&pkg_dir).unwrap();
1113        std::fs::write(
1114            pkg_dir.join("strict.json"),
1115            r#"{"rules": {"unused-files": "error", "unused-exports": "error"}}"#,
1116        )
1117        .unwrap();
1118
1119        std::fs::write(
1120            dir.path().join(".fallowrc.json"),
1121            r#"{"extends": "npm:@company/fallow-config/strict.json"}"#,
1122        )
1123        .unwrap();
1124
1125        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1126        assert_eq!(config.rules.unused_files, Severity::Error);
1127        assert_eq!(config.rules.unused_exports, Severity::Error);
1128    }
1129
1130    #[test]
1131    fn extends_npm_package_json_main() {
1132        let dir = test_dir("npm-main");
1133        create_npm_package_with_main(
1134            dir.path(),
1135            "fallow-config-acme",
1136            "config.json",
1137            r#"{"rules": {"unused-types": "off"}}"#,
1138        );
1139        std::fs::write(
1140            dir.path().join(".fallowrc.json"),
1141            r#"{"extends": "npm:fallow-config-acme"}"#,
1142        )
1143        .unwrap();
1144
1145        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1146        assert_eq!(config.rules.unused_types, Severity::Off);
1147    }
1148
1149    #[test]
1150    fn extends_npm_package_json_exports_string() {
1151        let dir = test_dir("npm-exports-str");
1152        let pkg_dir = dir.path().join("node_modules/fallow-config-co");
1153        std::fs::create_dir_all(&pkg_dir).unwrap();
1154        std::fs::write(
1155            pkg_dir.join("package.json"),
1156            r#"{"name": "fallow-config-co", "exports": "./base.json"}"#,
1157        )
1158        .unwrap();
1159        std::fs::write(
1160            pkg_dir.join("base.json"),
1161            r#"{"rules": {"circular-dependencies": "warn"}}"#,
1162        )
1163        .unwrap();
1164
1165        std::fs::write(
1166            dir.path().join(".fallowrc.json"),
1167            r#"{"extends": "npm:fallow-config-co"}"#,
1168        )
1169        .unwrap();
1170
1171        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1172        assert_eq!(config.rules.circular_dependencies, Severity::Warn);
1173    }
1174
1175    #[test]
1176    fn extends_npm_package_json_exports_object() {
1177        let dir = test_dir("npm-exports-obj");
1178        let pkg_dir = dir.path().join("node_modules/@co/cfg");
1179        std::fs::create_dir_all(&pkg_dir).unwrap();
1180        std::fs::write(
1181            pkg_dir.join("package.json"),
1182            r#"{"name": "@co/cfg", "exports": {".": {"default": "./fallow.json"}}}"#,
1183        )
1184        .unwrap();
1185        std::fs::write(pkg_dir.join("fallow.json"), r#"{"entry": ["src/app.ts"]}"#).unwrap();
1186
1187        std::fs::write(
1188            dir.path().join(".fallowrc.json"),
1189            r#"{"extends": "npm:@co/cfg"}"#,
1190        )
1191        .unwrap();
1192
1193        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1194        assert_eq!(config.entry, vec!["src/app.ts"]);
1195    }
1196
1197    #[test]
1198    fn extends_npm_exports_takes_priority_over_main() {
1199        let dir = test_dir("npm-exports-prio");
1200        let pkg_dir = dir.path().join("node_modules/my-config");
1201        std::fs::create_dir_all(&pkg_dir).unwrap();
1202        std::fs::write(
1203            pkg_dir.join("package.json"),
1204            r#"{"name": "my-config", "main": "./old.json", "exports": "./new.json"}"#,
1205        )
1206        .unwrap();
1207        std::fs::write(
1208            pkg_dir.join("old.json"),
1209            r#"{"rules": {"unused-files": "off"}}"#,
1210        )
1211        .unwrap();
1212        std::fs::write(
1213            pkg_dir.join("new.json"),
1214            r#"{"rules": {"unused-files": "warn"}}"#,
1215        )
1216        .unwrap();
1217
1218        std::fs::write(
1219            dir.path().join(".fallowrc.json"),
1220            r#"{"extends": "npm:my-config"}"#,
1221        )
1222        .unwrap();
1223
1224        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1225        // exports takes priority over main
1226        assert_eq!(config.rules.unused_files, Severity::Warn);
1227    }
1228
1229    #[test]
1230    fn extends_npm_walk_up_directories() {
1231        let dir = test_dir("npm-walkup");
1232        // node_modules at root level
1233        create_npm_package(
1234            dir.path(),
1235            "shared-config",
1236            r#"{"rules": {"unused-files": "warn"}}"#,
1237        );
1238        // Config in a nested subdirectory
1239        let sub = dir.path().join("packages/app");
1240        std::fs::create_dir_all(&sub).unwrap();
1241        std::fs::write(
1242            sub.join(".fallowrc.json"),
1243            r#"{"extends": "npm:shared-config"}"#,
1244        )
1245        .unwrap();
1246
1247        let config = FallowConfig::load(&sub.join(".fallowrc.json")).unwrap();
1248        assert_eq!(config.rules.unused_files, Severity::Warn);
1249    }
1250
1251    #[test]
1252    fn extends_npm_overlay_overrides_base() {
1253        let dir = test_dir("npm-overlay");
1254        create_npm_package(
1255            dir.path(),
1256            "@company/base",
1257            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}, "entry": ["src/base.ts"]}"#,
1258        );
1259        std::fs::write(
1260            dir.path().join(".fallowrc.json"),
1261            r#"{"extends": "npm:@company/base", "rules": {"unused-files": "error"}, "entry": ["src/app.ts"]}"#,
1262        )
1263        .unwrap();
1264
1265        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1266        assert_eq!(config.rules.unused_files, Severity::Error);
1267        assert_eq!(config.rules.unused_exports, Severity::Off);
1268        assert_eq!(config.entry, vec!["src/app.ts"]);
1269    }
1270
1271    #[test]
1272    fn extends_npm_chained_with_relative() {
1273        let dir = test_dir("npm-chained");
1274        // npm package extends a relative file inside itself
1275        let pkg_dir = dir.path().join("node_modules/my-config");
1276        std::fs::create_dir_all(&pkg_dir).unwrap();
1277        std::fs::write(
1278            pkg_dir.join("base.json"),
1279            r#"{"rules": {"unused-files": "warn"}}"#,
1280        )
1281        .unwrap();
1282        std::fs::write(
1283            pkg_dir.join(".fallowrc.json"),
1284            r#"{"extends": ["base.json"], "rules": {"unused-exports": "off"}}"#,
1285        )
1286        .unwrap();
1287
1288        std::fs::write(
1289            dir.path().join(".fallowrc.json"),
1290            r#"{"extends": "npm:my-config"}"#,
1291        )
1292        .unwrap();
1293
1294        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1295        assert_eq!(config.rules.unused_files, Severity::Warn);
1296        assert_eq!(config.rules.unused_exports, Severity::Off);
1297    }
1298
1299    #[test]
1300    fn extends_npm_mixed_with_relative_paths() {
1301        let dir = test_dir("npm-mixed");
1302        create_npm_package(
1303            dir.path(),
1304            "shared-base",
1305            r#"{"rules": {"unused-files": "off"}}"#,
1306        );
1307        std::fs::write(
1308            dir.path().join("local-overrides.json"),
1309            r#"{"rules": {"unused-files": "warn"}}"#,
1310        )
1311        .unwrap();
1312        std::fs::write(
1313            dir.path().join(".fallowrc.json"),
1314            r#"{"extends": ["npm:shared-base", "local-overrides.json"]}"#,
1315        )
1316        .unwrap();
1317
1318        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1319        // local-overrides is later in the array, so it wins
1320        assert_eq!(config.rules.unused_files, Severity::Warn);
1321    }
1322
1323    #[test]
1324    fn extends_npm_missing_package_errors() {
1325        let dir = test_dir("npm-missing");
1326        std::fs::write(
1327            dir.path().join(".fallowrc.json"),
1328            r#"{"extends": "npm:nonexistent-package"}"#,
1329        )
1330        .unwrap();
1331
1332        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1333        assert!(result.is_err());
1334        let err_msg = format!("{}", result.unwrap_err());
1335        assert!(
1336            err_msg.contains("not found"),
1337            "Expected 'not found' error, got: {err_msg}"
1338        );
1339        assert!(
1340            err_msg.contains("nonexistent-package"),
1341            "Expected package name in error, got: {err_msg}"
1342        );
1343        assert!(
1344            err_msg.contains("install it"),
1345            "Expected install hint in error, got: {err_msg}"
1346        );
1347    }
1348
1349    #[test]
1350    fn extends_npm_no_config_in_package_errors() {
1351        let dir = test_dir("npm-no-config");
1352        let pkg_dir = dir.path().join("node_modules/empty-pkg");
1353        std::fs::create_dir_all(&pkg_dir).unwrap();
1354        // Package exists but has no config files and no package.json
1355        std::fs::write(pkg_dir.join("README.md"), "# empty").unwrap();
1356
1357        std::fs::write(
1358            dir.path().join(".fallowrc.json"),
1359            r#"{"extends": "npm:empty-pkg"}"#,
1360        )
1361        .unwrap();
1362
1363        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1364        assert!(result.is_err());
1365        let err_msg = format!("{}", result.unwrap_err());
1366        assert!(
1367            err_msg.contains("No fallow config found"),
1368            "Expected 'No fallow config found' error, got: {err_msg}"
1369        );
1370    }
1371
1372    #[test]
1373    fn extends_npm_missing_subpath_errors() {
1374        let dir = test_dir("npm-missing-sub");
1375        let pkg_dir = dir.path().join("node_modules/@co/config");
1376        std::fs::create_dir_all(&pkg_dir).unwrap();
1377
1378        std::fs::write(
1379            dir.path().join(".fallowrc.json"),
1380            r#"{"extends": "npm:@co/config/nonexistent.json"}"#,
1381        )
1382        .unwrap();
1383
1384        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1385        assert!(result.is_err());
1386        let err_msg = format!("{}", result.unwrap_err());
1387        assert!(
1388            err_msg.contains("nonexistent.json"),
1389            "Expected subpath in error, got: {err_msg}"
1390        );
1391    }
1392
1393    #[test]
1394    fn extends_npm_empty_specifier_errors() {
1395        let dir = test_dir("npm-empty");
1396        std::fs::write(dir.path().join(".fallowrc.json"), r#"{"extends": "npm:"}"#).unwrap();
1397
1398        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1399        assert!(result.is_err());
1400        let err_msg = format!("{}", result.unwrap_err());
1401        assert!(
1402            err_msg.contains("Empty npm specifier"),
1403            "Expected 'Empty npm specifier' error, got: {err_msg}"
1404        );
1405    }
1406
1407    #[test]
1408    fn extends_npm_space_after_colon_trimmed() {
1409        let dir = test_dir("npm-space");
1410        create_npm_package(
1411            dir.path(),
1412            "fallow-config-acme",
1413            r#"{"rules": {"unused-files": "warn"}}"#,
1414        );
1415        // Space after npm: — should be trimmed and resolve correctly
1416        std::fs::write(
1417            dir.path().join(".fallowrc.json"),
1418            r#"{"extends": "npm: fallow-config-acme"}"#,
1419        )
1420        .unwrap();
1421
1422        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1423        assert_eq!(config.rules.unused_files, Severity::Warn);
1424    }
1425
1426    #[test]
1427    fn extends_npm_exports_node_condition() {
1428        let dir = test_dir("npm-node-cond");
1429        let pkg_dir = dir.path().join("node_modules/node-config");
1430        std::fs::create_dir_all(&pkg_dir).unwrap();
1431        std::fs::write(
1432            pkg_dir.join("package.json"),
1433            r#"{"name": "node-config", "exports": {".": {"node": "./node.json"}}}"#,
1434        )
1435        .unwrap();
1436        std::fs::write(
1437            pkg_dir.join("node.json"),
1438            r#"{"rules": {"unused-files": "off"}}"#,
1439        )
1440        .unwrap();
1441
1442        std::fs::write(
1443            dir.path().join(".fallowrc.json"),
1444            r#"{"extends": "npm:node-config"}"#,
1445        )
1446        .unwrap();
1447
1448        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1449        assert_eq!(config.rules.unused_files, Severity::Off);
1450    }
1451
1452    // ── parse_npm_specifier unit tests ──────────────────────────────
1453
1454    #[test]
1455    fn parse_npm_specifier_unscoped() {
1456        assert_eq!(parse_npm_specifier("my-config"), ("my-config", None));
1457    }
1458
1459    #[test]
1460    fn parse_npm_specifier_unscoped_with_subpath() {
1461        assert_eq!(
1462            parse_npm_specifier("my-config/strict.json"),
1463            ("my-config", Some("strict.json"))
1464        );
1465    }
1466
1467    #[test]
1468    fn parse_npm_specifier_scoped() {
1469        assert_eq!(
1470            parse_npm_specifier("@company/fallow-config"),
1471            ("@company/fallow-config", None)
1472        );
1473    }
1474
1475    #[test]
1476    fn parse_npm_specifier_scoped_with_subpath() {
1477        assert_eq!(
1478            parse_npm_specifier("@company/fallow-config/strict.json"),
1479            ("@company/fallow-config", Some("strict.json"))
1480        );
1481    }
1482
1483    #[test]
1484    fn parse_npm_specifier_scoped_with_nested_subpath() {
1485        assert_eq!(
1486            parse_npm_specifier("@company/fallow-config/presets/strict.json"),
1487            ("@company/fallow-config", Some("presets/strict.json"))
1488        );
1489    }
1490
1491    // ── npm extends security tests ──────────────────────────────────
1492
1493    #[test]
1494    fn extends_npm_subpath_traversal_rejected() {
1495        let dir = test_dir("npm-traversal-sub");
1496        let pkg_dir = dir.path().join("node_modules/evil-pkg");
1497        std::fs::create_dir_all(&pkg_dir).unwrap();
1498        // Create a file outside the package that the traversal would reach
1499        std::fs::write(
1500            dir.path().join("secret.json"),
1501            r#"{"entry": ["stolen.ts"]}"#,
1502        )
1503        .unwrap();
1504
1505        std::fs::write(
1506            dir.path().join(".fallowrc.json"),
1507            r#"{"extends": "npm:evil-pkg/../../secret.json"}"#,
1508        )
1509        .unwrap();
1510
1511        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1512        assert!(result.is_err());
1513        let err_msg = format!("{}", result.unwrap_err());
1514        assert!(
1515            err_msg.contains("traversal") || err_msg.contains("not found"),
1516            "Expected traversal or not-found error, got: {err_msg}"
1517        );
1518    }
1519
1520    #[test]
1521    fn extends_npm_dotdot_package_name_rejected() {
1522        let dir = test_dir("npm-dotdot-name");
1523        std::fs::write(
1524            dir.path().join(".fallowrc.json"),
1525            r#"{"extends": "npm:../relative"}"#,
1526        )
1527        .unwrap();
1528
1529        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1530        assert!(result.is_err());
1531        let err_msg = format!("{}", result.unwrap_err());
1532        assert!(
1533            err_msg.contains("path traversal"),
1534            "Expected 'path traversal' error, got: {err_msg}"
1535        );
1536    }
1537
1538    #[test]
1539    fn extends_npm_scoped_without_name_rejected() {
1540        let dir = test_dir("npm-scope-only");
1541        std::fs::write(
1542            dir.path().join(".fallowrc.json"),
1543            r#"{"extends": "npm:@scope"}"#,
1544        )
1545        .unwrap();
1546
1547        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1548        assert!(result.is_err());
1549        let err_msg = format!("{}", result.unwrap_err());
1550        assert!(
1551            err_msg.contains("@scope/name"),
1552            "Expected scoped name format error, got: {err_msg}"
1553        );
1554    }
1555
1556    #[test]
1557    fn extends_npm_malformed_package_json_errors() {
1558        let dir = test_dir("npm-bad-pkgjson");
1559        let pkg_dir = dir.path().join("node_modules/bad-pkg");
1560        std::fs::create_dir_all(&pkg_dir).unwrap();
1561        std::fs::write(pkg_dir.join("package.json"), "{ not valid json }").unwrap();
1562
1563        std::fs::write(
1564            dir.path().join(".fallowrc.json"),
1565            r#"{"extends": "npm:bad-pkg"}"#,
1566        )
1567        .unwrap();
1568
1569        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1570        assert!(result.is_err());
1571        let err_msg = format!("{}", result.unwrap_err());
1572        assert!(
1573            err_msg.contains("Failed to parse"),
1574            "Expected parse error, got: {err_msg}"
1575        );
1576    }
1577
1578    #[test]
1579    fn extends_npm_exports_traversal_rejected() {
1580        let dir = test_dir("npm-exports-escape");
1581        let pkg_dir = dir.path().join("node_modules/evil-exports");
1582        std::fs::create_dir_all(&pkg_dir).unwrap();
1583        std::fs::write(
1584            pkg_dir.join("package.json"),
1585            r#"{"name": "evil-exports", "exports": "../../secret.json"}"#,
1586        )
1587        .unwrap();
1588        // Create the target file outside the package
1589        std::fs::write(
1590            dir.path().join("secret.json"),
1591            r#"{"entry": ["stolen.ts"]}"#,
1592        )
1593        .unwrap();
1594
1595        std::fs::write(
1596            dir.path().join(".fallowrc.json"),
1597            r#"{"extends": "npm:evil-exports"}"#,
1598        )
1599        .unwrap();
1600
1601        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1602        assert!(result.is_err());
1603        let err_msg = format!("{}", result.unwrap_err());
1604        assert!(
1605            err_msg.contains("traversal"),
1606            "Expected traversal error, got: {err_msg}"
1607        );
1608    }
1609
1610    // ── deep_merge_json unit tests ───────────────────────────────────
1611
1612    #[test]
1613    fn deep_merge_scalar_overlay_replaces_base() {
1614        let mut base = serde_json::json!("hello");
1615        deep_merge_json(&mut base, serde_json::json!("world"));
1616        assert_eq!(base, serde_json::json!("world"));
1617    }
1618
1619    #[test]
1620    fn deep_merge_array_overlay_replaces_base() {
1621        let mut base = serde_json::json!(["a", "b"]);
1622        deep_merge_json(&mut base, serde_json::json!(["c"]));
1623        assert_eq!(base, serde_json::json!(["c"]));
1624    }
1625
1626    #[test]
1627    fn deep_merge_nested_object_merge() {
1628        let mut base = serde_json::json!({
1629            "level1": {
1630                "level2": {
1631                    "a": 1,
1632                    "b": 2
1633                }
1634            }
1635        });
1636        let overlay = serde_json::json!({
1637            "level1": {
1638                "level2": {
1639                    "b": 99,
1640                    "c": 3
1641                }
1642            }
1643        });
1644        deep_merge_json(&mut base, overlay);
1645        assert_eq!(base["level1"]["level2"]["a"], 1);
1646        assert_eq!(base["level1"]["level2"]["b"], 99);
1647        assert_eq!(base["level1"]["level2"]["c"], 3);
1648    }
1649
1650    #[test]
1651    fn deep_merge_overlay_adds_new_fields() {
1652        let mut base = serde_json::json!({"existing": true});
1653        let overlay = serde_json::json!({"new_field": "added", "another": 42});
1654        deep_merge_json(&mut base, overlay);
1655        assert_eq!(base["existing"], true);
1656        assert_eq!(base["new_field"], "added");
1657        assert_eq!(base["another"], 42);
1658    }
1659
1660    #[test]
1661    fn deep_merge_null_overlay_replaces_object() {
1662        let mut base = serde_json::json!({"key": "value"});
1663        deep_merge_json(&mut base, serde_json::json!(null));
1664        assert_eq!(base, serde_json::json!(null));
1665    }
1666
1667    #[test]
1668    fn deep_merge_empty_object_overlay_preserves_base() {
1669        let mut base = serde_json::json!({"a": 1, "b": 2});
1670        deep_merge_json(&mut base, serde_json::json!({}));
1671        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1672    }
1673
1674    // ── rule severity parsing via JSON config ────────────────────────
1675
1676    #[test]
1677    fn rules_severity_error_warn_off_from_json() {
1678        let json_str = r#"{
1679            "rules": {
1680                "unused-files": "error",
1681                "unused-exports": "warn",
1682                "unused-types": "off"
1683            }
1684        }"#;
1685        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1686        assert_eq!(config.rules.unused_files, Severity::Error);
1687        assert_eq!(config.rules.unused_exports, Severity::Warn);
1688        assert_eq!(config.rules.unused_types, Severity::Off);
1689    }
1690
1691    #[test]
1692    fn rules_omitted_default_to_error() {
1693        let json_str = r#"{
1694            "rules": {
1695                "unused-files": "warn"
1696            }
1697        }"#;
1698        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1699        assert_eq!(config.rules.unused_files, Severity::Warn);
1700        // All other rules default to error
1701        assert_eq!(config.rules.unused_exports, Severity::Error);
1702        assert_eq!(config.rules.unused_types, Severity::Error);
1703        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1704        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1705        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
1706        assert_eq!(config.rules.duplicate_exports, Severity::Error);
1707        assert_eq!(config.rules.circular_dependencies, Severity::Error);
1708        // type_only_dependencies defaults to warn, not error
1709        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
1710    }
1711
1712    // ── find_and_load tests ───────────────────────────────────────
1713
1714    #[test]
1715    fn find_and_load_returns_none_when_no_config() {
1716        let dir = test_dir("find-none");
1717        // Create a .git dir so it stops searching
1718        std::fs::create_dir(dir.path().join(".git")).unwrap();
1719
1720        let result = FallowConfig::find_and_load(dir.path()).unwrap();
1721        assert!(result.is_none());
1722    }
1723
1724    #[test]
1725    fn find_and_load_finds_fallowrc_json() {
1726        let dir = test_dir("find-json");
1727        std::fs::create_dir(dir.path().join(".git")).unwrap();
1728        std::fs::write(
1729            dir.path().join(".fallowrc.json"),
1730            r#"{"entry": ["src/main.ts"]}"#,
1731        )
1732        .unwrap();
1733
1734        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1735        assert_eq!(config.entry, vec!["src/main.ts"]);
1736        assert!(path.ends_with(".fallowrc.json"));
1737    }
1738
1739    #[test]
1740    fn find_and_load_prefers_fallowrc_json_over_toml() {
1741        let dir = test_dir("find-priority");
1742        std::fs::create_dir(dir.path().join(".git")).unwrap();
1743        std::fs::write(
1744            dir.path().join(".fallowrc.json"),
1745            r#"{"entry": ["from-json.ts"]}"#,
1746        )
1747        .unwrap();
1748        std::fs::write(
1749            dir.path().join("fallow.toml"),
1750            "entry = [\"from-toml.ts\"]\n",
1751        )
1752        .unwrap();
1753
1754        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1755        assert_eq!(config.entry, vec!["from-json.ts"]);
1756        assert!(path.ends_with(".fallowrc.json"));
1757    }
1758
1759    #[test]
1760    fn find_and_load_finds_fallow_toml() {
1761        let dir = test_dir("find-toml");
1762        std::fs::create_dir(dir.path().join(".git")).unwrap();
1763        std::fs::write(
1764            dir.path().join("fallow.toml"),
1765            "entry = [\"src/index.ts\"]\n",
1766        )
1767        .unwrap();
1768
1769        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
1770        assert_eq!(config.entry, vec!["src/index.ts"]);
1771    }
1772
1773    #[test]
1774    fn find_and_load_stops_at_git_dir() {
1775        let dir = test_dir("find-git-stop");
1776        let sub = dir.path().join("sub");
1777        std::fs::create_dir(&sub).unwrap();
1778        // .git marker in root stops search
1779        std::fs::create_dir(dir.path().join(".git")).unwrap();
1780        // Config file above .git should not be found from sub
1781        // (sub has no .git or package.json, so it keeps searching up to parent)
1782        // But parent has .git, so it stops there without finding config
1783        let result = FallowConfig::find_and_load(&sub).unwrap();
1784        assert!(result.is_none());
1785    }
1786
1787    #[test]
1788    fn find_and_load_stops_at_package_json() {
1789        let dir = test_dir("find-pkg-stop");
1790        std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
1791
1792        let result = FallowConfig::find_and_load(dir.path()).unwrap();
1793        assert!(result.is_none());
1794    }
1795
1796    #[test]
1797    fn find_and_load_returns_error_for_invalid_config() {
1798        let dir = test_dir("find-invalid");
1799        std::fs::create_dir(dir.path().join(".git")).unwrap();
1800        std::fs::write(
1801            dir.path().join(".fallowrc.json"),
1802            r"{ this is not valid json }",
1803        )
1804        .unwrap();
1805
1806        let result = FallowConfig::find_and_load(dir.path());
1807        assert!(result.is_err());
1808    }
1809
1810    // ── load TOML config file ────────────────────────────────────
1811
1812    #[test]
1813    fn load_toml_config_file() {
1814        let dir = test_dir("toml-config");
1815        let config_path = dir.path().join("fallow.toml");
1816        std::fs::write(
1817            &config_path,
1818            r#"
1819entry = ["src/index.ts"]
1820ignorePatterns = ["dist/**"]
1821
1822[rules]
1823unused-files = "warn"
1824
1825[duplicates]
1826minTokens = 100
1827"#,
1828        )
1829        .unwrap();
1830
1831        let config = FallowConfig::load(&config_path).unwrap();
1832        assert_eq!(config.entry, vec!["src/index.ts"]);
1833        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1834        assert_eq!(config.rules.unused_files, Severity::Warn);
1835        assert_eq!(config.duplicates.min_tokens, 100);
1836    }
1837
1838    // ── extends absolute path rejection ──────────────────────────
1839
1840    #[test]
1841    fn extends_absolute_path_rejected() {
1842        let dir = test_dir("extends-absolute");
1843
1844        // Use a platform-appropriate absolute path
1845        #[cfg(unix)]
1846        let abs_path = "/absolute/path/config.json";
1847        #[cfg(windows)]
1848        let abs_path = "C:\\absolute\\path\\config.json";
1849
1850        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1851        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1852
1853        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1854        assert!(result.is_err());
1855        let err_msg = format!("{}", result.unwrap_err());
1856        assert!(
1857            err_msg.contains("must be relative"),
1858            "Expected 'must be relative' error, got: {err_msg}"
1859        );
1860    }
1861
1862    // ── resolve production mode ─────────────────────────────────
1863
1864    #[test]
1865    fn resolve_production_mode_disables_dev_deps() {
1866        let config = FallowConfig {
1867            schema: None,
1868            extends: vec![],
1869            entry: vec![],
1870            ignore_patterns: vec![],
1871            framework: vec![],
1872            workspaces: None,
1873            ignore_dependencies: vec![],
1874            ignore_exports: vec![],
1875            duplicates: DuplicatesConfig::default(),
1876            health: HealthConfig::default(),
1877            rules: RulesConfig::default(),
1878            boundaries: BoundaryConfig::default(),
1879            production: true,
1880            plugins: vec![],
1881            overrides: vec![],
1882            regression: None,
1883        };
1884        let resolved = config.resolve(
1885            PathBuf::from("/tmp/test"),
1886            OutputFormat::Human,
1887            4,
1888            false,
1889            true,
1890        );
1891        assert!(resolved.production);
1892        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1893        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1894        // Other rules should remain at default (Error)
1895        assert_eq!(resolved.rules.unused_files, Severity::Error);
1896        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1897    }
1898
1899    // ── config format fallback to TOML for unknown extensions ───
1900
1901    #[test]
1902    fn config_format_defaults_to_toml_for_unknown() {
1903        assert!(matches!(
1904            ConfigFormat::from_path(Path::new("config.yaml")),
1905            ConfigFormat::Toml
1906        ));
1907        assert!(matches!(
1908            ConfigFormat::from_path(Path::new("config")),
1909            ConfigFormat::Toml
1910        ));
1911    }
1912
1913    // ── deep_merge type coercion ─────────────────────────────────
1914
1915    #[test]
1916    fn deep_merge_object_over_scalar_replaces() {
1917        let mut base = serde_json::json!("just a string");
1918        let overlay = serde_json::json!({"key": "value"});
1919        deep_merge_json(&mut base, overlay);
1920        assert_eq!(base, serde_json::json!({"key": "value"}));
1921    }
1922
1923    #[test]
1924    fn deep_merge_scalar_over_object_replaces() {
1925        let mut base = serde_json::json!({"key": "value"});
1926        let overlay = serde_json::json!(42);
1927        deep_merge_json(&mut base, overlay);
1928        assert_eq!(base, serde_json::json!(42));
1929    }
1930
1931    // ── extends with non-string/array extends field ──────────────
1932
1933    #[test]
1934    fn extends_non_string_non_array_ignored() {
1935        let dir = test_dir("extends-numeric");
1936        std::fs::write(
1937            dir.path().join(".fallowrc.json"),
1938            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1939        )
1940        .unwrap();
1941
1942        // extends=42 is neither string nor array, so it's treated as no extends
1943        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1944        assert_eq!(config.entry, vec!["src/index.ts"]);
1945    }
1946
1947    // ── extends with multiple bases (later overrides earlier) ────
1948
1949    #[test]
1950    fn extends_multiple_bases_later_wins() {
1951        let dir = test_dir("extends-multi-base");
1952
1953        std::fs::write(
1954            dir.path().join("base-a.json"),
1955            r#"{"rules": {"unused-files": "warn"}}"#,
1956        )
1957        .unwrap();
1958        std::fs::write(
1959            dir.path().join("base-b.json"),
1960            r#"{"rules": {"unused-files": "off"}}"#,
1961        )
1962        .unwrap();
1963        std::fs::write(
1964            dir.path().join(".fallowrc.json"),
1965            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1966        )
1967        .unwrap();
1968
1969        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1970        // base-b is later in the array, so its value should win
1971        assert_eq!(config.rules.unused_files, Severity::Off);
1972    }
1973
1974    // ── config with production flag ──────────────────────────────
1975
1976    #[test]
1977    fn fallow_config_deserialize_production() {
1978        let json_str = r#"{"production": true}"#;
1979        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1980        assert!(config.production);
1981    }
1982
1983    #[test]
1984    fn fallow_config_production_defaults_false() {
1985        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1986        assert!(!config.production);
1987    }
1988
1989    // ── optional dependency names ────────────────────────────────
1990
1991    #[test]
1992    fn package_json_optional_dependency_names() {
1993        let pkg: PackageJson = serde_json::from_str(
1994            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1995        )
1996        .unwrap();
1997        let opt = pkg.optional_dependency_names();
1998        assert_eq!(opt.len(), 2);
1999        assert!(opt.contains(&"fsevents".to_string()));
2000        assert!(opt.contains(&"chokidar".to_string()));
2001    }
2002
2003    #[test]
2004    fn package_json_optional_deps_empty_when_missing() {
2005        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
2006        assert!(pkg.optional_dependency_names().is_empty());
2007    }
2008
2009    // ── find_config_path ────────────────────────────────────────────
2010
2011    #[test]
2012    fn find_config_path_returns_fallowrc_json() {
2013        let dir = test_dir("find-path-json");
2014        std::fs::create_dir(dir.path().join(".git")).unwrap();
2015        std::fs::write(
2016            dir.path().join(".fallowrc.json"),
2017            r#"{"entry": ["src/main.ts"]}"#,
2018        )
2019        .unwrap();
2020
2021        let path = FallowConfig::find_config_path(dir.path());
2022        assert!(path.is_some());
2023        assert!(path.unwrap().ends_with(".fallowrc.json"));
2024    }
2025
2026    #[test]
2027    fn find_config_path_returns_fallow_toml() {
2028        let dir = test_dir("find-path-toml");
2029        std::fs::create_dir(dir.path().join(".git")).unwrap();
2030        std::fs::write(
2031            dir.path().join("fallow.toml"),
2032            "entry = [\"src/main.ts\"]\n",
2033        )
2034        .unwrap();
2035
2036        let path = FallowConfig::find_config_path(dir.path());
2037        assert!(path.is_some());
2038        assert!(path.unwrap().ends_with("fallow.toml"));
2039    }
2040
2041    #[test]
2042    fn find_config_path_returns_dot_fallow_toml() {
2043        let dir = test_dir("find-path-dot-toml");
2044        std::fs::create_dir(dir.path().join(".git")).unwrap();
2045        std::fs::write(
2046            dir.path().join(".fallow.toml"),
2047            "entry = [\"src/main.ts\"]\n",
2048        )
2049        .unwrap();
2050
2051        let path = FallowConfig::find_config_path(dir.path());
2052        assert!(path.is_some());
2053        assert!(path.unwrap().ends_with(".fallow.toml"));
2054    }
2055
2056    #[test]
2057    fn find_config_path_prefers_json_over_toml() {
2058        let dir = test_dir("find-path-priority");
2059        std::fs::create_dir(dir.path().join(".git")).unwrap();
2060        std::fs::write(
2061            dir.path().join(".fallowrc.json"),
2062            r#"{"entry": ["json.ts"]}"#,
2063        )
2064        .unwrap();
2065        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
2066
2067        let path = FallowConfig::find_config_path(dir.path());
2068        assert!(path.unwrap().ends_with(".fallowrc.json"));
2069    }
2070
2071    #[test]
2072    fn find_config_path_none_when_no_config() {
2073        let dir = test_dir("find-path-none");
2074        std::fs::create_dir(dir.path().join(".git")).unwrap();
2075
2076        let path = FallowConfig::find_config_path(dir.path());
2077        assert!(path.is_none());
2078    }
2079
2080    #[test]
2081    fn find_config_path_stops_at_package_json() {
2082        let dir = test_dir("find-path-pkg-stop");
2083        std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
2084
2085        let path = FallowConfig::find_config_path(dir.path());
2086        assert!(path.is_none());
2087    }
2088
2089    // ── TOML extends support ────────────────────────────────────────
2090
2091    #[test]
2092    fn extends_toml_base() {
2093        let dir = test_dir("extends-toml");
2094
2095        std::fs::write(
2096            dir.path().join("base.json"),
2097            r#"{"rules": {"unused-files": "warn"}}"#,
2098        )
2099        .unwrap();
2100        std::fs::write(
2101            dir.path().join("fallow.toml"),
2102            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
2103        )
2104        .unwrap();
2105
2106        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2107        assert_eq!(config.rules.unused_files, Severity::Warn);
2108        assert_eq!(config.entry, vec!["src/index.ts"]);
2109    }
2110
2111    // ── deep_merge_json edge cases ──────────────────────────────────
2112
2113    #[test]
2114    fn deep_merge_boolean_overlay() {
2115        let mut base = serde_json::json!(true);
2116        deep_merge_json(&mut base, serde_json::json!(false));
2117        assert_eq!(base, serde_json::json!(false));
2118    }
2119
2120    #[test]
2121    fn deep_merge_number_overlay() {
2122        let mut base = serde_json::json!(42);
2123        deep_merge_json(&mut base, serde_json::json!(99));
2124        assert_eq!(base, serde_json::json!(99));
2125    }
2126
2127    #[test]
2128    fn deep_merge_disjoint_objects() {
2129        let mut base = serde_json::json!({"a": 1});
2130        let overlay = serde_json::json!({"b": 2});
2131        deep_merge_json(&mut base, overlay);
2132        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
2133    }
2134
2135    // ── MAX_EXTENDS_DEPTH constant ──────────────────────────────────
2136
2137    #[test]
2138    fn max_extends_depth_is_reasonable() {
2139        assert_eq!(MAX_EXTENDS_DEPTH, 10);
2140    }
2141
2142    // ── Config names constant ───────────────────────────────────────
2143
2144    #[test]
2145    fn config_names_has_three_entries() {
2146        assert_eq!(CONFIG_NAMES.len(), 3);
2147        // All names should start with "." or "fallow"
2148        for name in CONFIG_NAMES {
2149            assert!(
2150                name.starts_with('.') || name.starts_with("fallow"),
2151                "unexpected config name: {name}"
2152            );
2153        }
2154    }
2155
2156    // ── package.json peer dependency names ───────────────────────────
2157
2158    #[test]
2159    fn package_json_peer_dependency_names() {
2160        let pkg: PackageJson = serde_json::from_str(
2161            r#"{
2162            "dependencies": {"react": "^18"},
2163            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
2164        }"#,
2165        )
2166        .unwrap();
2167        let all = pkg.all_dependency_names();
2168        assert!(all.contains(&"react".to_string()));
2169        assert!(all.contains(&"react-dom".to_string()));
2170        assert!(all.contains(&"react-native".to_string()));
2171    }
2172
2173    // ── package.json scripts field ──────────────────────────────────
2174
2175    #[test]
2176    fn package_json_scripts_field() {
2177        let pkg: PackageJson = serde_json::from_str(
2178            r#"{
2179            "scripts": {
2180                "build": "tsc",
2181                "test": "vitest",
2182                "lint": "fallow check"
2183            }
2184        }"#,
2185        )
2186        .unwrap();
2187        let scripts = pkg.scripts.unwrap();
2188        assert_eq!(scripts.len(), 3);
2189        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
2190        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
2191    }
2192
2193    // ── Extends with TOML-to-TOML chain ─────────────────────────────
2194
2195    #[test]
2196    fn extends_toml_chain() {
2197        let dir = test_dir("extends-toml-chain");
2198
2199        std::fs::write(
2200            dir.path().join("base.json"),
2201            r#"{"entry": ["src/base.ts"]}"#,
2202        )
2203        .unwrap();
2204        std::fs::write(
2205            dir.path().join("middle.json"),
2206            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
2207        )
2208        .unwrap();
2209        std::fs::write(
2210            dir.path().join("fallow.toml"),
2211            "extends = [\"middle.json\"]\n",
2212        )
2213        .unwrap();
2214
2215        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
2216        assert_eq!(config.entry, vec!["src/base.ts"]);
2217        assert_eq!(config.rules.unused_files, Severity::Off);
2218    }
2219
2220    // ── find_and_load walks up to parent ────────────────────────────
2221
2222    #[test]
2223    fn find_and_load_walks_up_directories() {
2224        let dir = test_dir("find-walk-up");
2225        let sub = dir.path().join("src").join("deep");
2226        std::fs::create_dir_all(&sub).unwrap();
2227        std::fs::write(
2228            dir.path().join(".fallowrc.json"),
2229            r#"{"entry": ["src/main.ts"]}"#,
2230        )
2231        .unwrap();
2232        // Create .git in root to stop search there
2233        std::fs::create_dir(dir.path().join(".git")).unwrap();
2234
2235        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
2236        assert_eq!(config.entry, vec!["src/main.ts"]);
2237        assert!(path.ends_with(".fallowrc.json"));
2238    }
2239
2240    // ── JSON schema generation ──────────────────────────────────────
2241
2242    #[test]
2243    fn json_schema_contains_entry_field() {
2244        let schema = FallowConfig::json_schema();
2245        let obj = schema.as_object().unwrap();
2246        let props = obj.get("properties").and_then(|v| v.as_object());
2247        assert!(props.is_some(), "schema should have properties");
2248        assert!(
2249            props.unwrap().contains_key("entry"),
2250            "schema should contain entry property"
2251        );
2252    }
2253
2254    // ── Duplicates config via JSON in FallowConfig ──────────────────
2255
2256    #[test]
2257    fn fallow_config_json_duplicates_all_fields() {
2258        let json = r#"{
2259            "duplicates": {
2260                "enabled": true,
2261                "mode": "semantic",
2262                "minTokens": 200,
2263                "minLines": 20,
2264                "threshold": 10.5,
2265                "ignore": ["**/*.test.ts"],
2266                "skipLocal": true,
2267                "crossLanguage": true,
2268                "normalization": {
2269                    "ignoreIdentifiers": true,
2270                    "ignoreStringValues": false
2271                }
2272            }
2273        }"#;
2274        let config: FallowConfig = serde_json::from_str(json).unwrap();
2275        assert!(config.duplicates.enabled);
2276        assert_eq!(
2277            config.duplicates.mode,
2278            crate::config::DetectionMode::Semantic
2279        );
2280        assert_eq!(config.duplicates.min_tokens, 200);
2281        assert_eq!(config.duplicates.min_lines, 20);
2282        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
2283        assert!(config.duplicates.skip_local);
2284        assert!(config.duplicates.cross_language);
2285        assert_eq!(
2286            config.duplicates.normalization.ignore_identifiers,
2287            Some(true)
2288        );
2289        assert_eq!(
2290            config.duplicates.normalization.ignore_string_values,
2291            Some(false)
2292        );
2293    }
2294}