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/// Detect config format from file extension.
17pub(super) enum ConfigFormat {
18    Toml,
19    Json,
20}
21
22impl ConfigFormat {
23    pub(super) fn from_path(path: &Path) -> Self {
24        match path.extension().and_then(|e| e.to_str()) {
25            Some("json") => Self::Json,
26            _ => Self::Toml,
27        }
28    }
29}
30
31/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
32/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
33pub(super) fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
34    match (base, overlay) {
35        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
36            for (key, value) in overlay_map {
37                if let Some(base_value) = base_map.get_mut(&key) {
38                    deep_merge_json(base_value, value);
39                } else {
40                    base_map.insert(key, value);
41                }
42            }
43        }
44        (base, overlay) => {
45            *base = overlay;
46        }
47    }
48}
49
50pub(super) fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
51    let content = std::fs::read_to_string(path)
52        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
53
54    match ConfigFormat::from_path(path) {
55        ConfigFormat::Toml => {
56            let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
57                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
58            })?;
59            serde_json::to_value(toml_value).map_err(|e| {
60                miette::miette!(
61                    "Failed to convert TOML to JSON for {}: {}",
62                    path.display(),
63                    e
64                )
65            })
66        }
67        ConfigFormat::Json => {
68            let mut stripped = String::new();
69            json_comments::StripComments::new(content.as_bytes())
70                .read_to_string(&mut stripped)
71                .map_err(|e| {
72                    miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
73                })?;
74            serde_json::from_str(&stripped).map_err(|e| {
75                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
76            })
77        }
78    }
79}
80
81pub(super) fn resolve_extends(
82    path: &Path,
83    visited: &mut FxHashSet<PathBuf>,
84    depth: usize,
85) -> Result<serde_json::Value, miette::Report> {
86    if depth >= MAX_EXTENDS_DEPTH {
87        return Err(miette::miette!(
88            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
89            path.display()
90        ));
91    }
92
93    let canonical = path.canonicalize().map_err(|e| {
94        miette::miette!(
95            "Config file not found or unresolvable: {}: {}",
96            path.display(),
97            e
98        )
99    })?;
100
101    if !visited.insert(canonical) {
102        return Err(miette::miette!(
103            "Circular extends detected: {} was already visited in the extends chain",
104            path.display()
105        ));
106    }
107
108    let mut value = parse_config_to_value(path)?;
109
110    let extends = value
111        .as_object_mut()
112        .and_then(|obj| obj.remove("extends"))
113        .and_then(|v| match v {
114            serde_json::Value::Array(arr) => Some(
115                arr.into_iter()
116                    .filter_map(|v| v.as_str().map(String::from))
117                    .collect::<Vec<_>>(),
118            ),
119            serde_json::Value::String(s) => Some(vec![s]),
120            _ => None,
121        })
122        .unwrap_or_default();
123
124    if extends.is_empty() {
125        return Ok(value);
126    }
127
128    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
129    let mut merged = serde_json::Value::Object(serde_json::Map::new());
130
131    for extend_path_str in &extends {
132        if Path::new(extend_path_str).is_absolute() {
133            return Err(miette::miette!(
134                "extends paths must be relative, got absolute path: {} (in {})",
135                extend_path_str,
136                path.display()
137            ));
138        }
139        let extend_path = config_dir.join(extend_path_str);
140        if !extend_path.exists() {
141            return Err(miette::miette!(
142                "Extended config file not found: {} (referenced from {})",
143                extend_path.display(),
144                path.display()
145            ));
146        }
147        let base = resolve_extends(&extend_path, visited, depth + 1)?;
148        deep_merge_json(&mut merged, base);
149    }
150
151    deep_merge_json(&mut merged, value);
152    Ok(merged)
153}
154
155impl FallowConfig {
156    /// Load config from a fallow config file (TOML or JSON/JSONC).
157    ///
158    /// The format is detected from the file extension:
159    /// - `.toml` → TOML
160    /// - `.json` → JSON (with JSONC comment stripping)
161    ///
162    /// Supports `extends` for config inheritance. Extended configs are loaded
163    /// and deep-merged before this config's values are applied.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error when the config file cannot be read, merged, or deserialized.
168    pub fn load(path: &Path) -> Result<Self, miette::Report> {
169        let mut visited = FxHashSet::default();
170        let merged = resolve_extends(path, &mut visited, 0)?;
171
172        serde_json::from_value(merged).map_err(|e| {
173            miette::miette!(
174                "Failed to deserialize config from {}: {}",
175                path.display(),
176                e
177            )
178        })
179    }
180
181    /// Find the config file path without loading it.
182    /// Searches the same locations as `find_and_load`.
183    #[must_use]
184    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
185        let mut dir = start;
186        loop {
187            for name in CONFIG_NAMES {
188                let candidate = dir.join(name);
189                if candidate.exists() {
190                    return Some(candidate);
191                }
192            }
193            if dir.join(".git").exists() || dir.join("package.json").exists() {
194                break;
195            }
196            dir = dir.parent()?;
197        }
198        None
199    }
200
201    /// Find and load config, searching from `start` up to the project root.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if a config file is found but cannot be read or parsed.
206    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
207        let mut dir = start;
208        loop {
209            for name in CONFIG_NAMES {
210                let candidate = dir.join(name);
211                if candidate.exists() {
212                    match Self::load(&candidate) {
213                        Ok(config) => return Ok(Some((config, candidate))),
214                        Err(e) => {
215                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
216                        }
217                    }
218                }
219            }
220            // Stop at project root indicators
221            if dir.join(".git").exists() || dir.join("package.json").exists() {
222                break;
223            }
224            dir = match dir.parent() {
225                Some(parent) => parent,
226                None => break,
227            };
228        }
229        Ok(None)
230    }
231
232    /// Generate JSON Schema for the configuration format.
233    #[must_use]
234    pub fn json_schema() -> serde_json::Value {
235        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use std::io::Read as _;
242
243    use super::*;
244    use crate::PackageJson;
245    use crate::config::boundaries::BoundaryConfig;
246    use crate::config::duplicates_config::DuplicatesConfig;
247    use crate::config::format::OutputFormat;
248    use crate::config::health::HealthConfig;
249    use crate::config::rules::{RulesConfig, Severity};
250
251    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
252    fn test_dir(_name: &str) -> tempfile::TempDir {
253        tempfile::tempdir().expect("create temp dir")
254    }
255
256    #[test]
257    fn fallow_config_deserialize_minimal() {
258        let toml_str = r#"
259entry = ["src/main.ts"]
260"#;
261        let config: FallowConfig = toml::from_str(toml_str).unwrap();
262        assert_eq!(config.entry, vec!["src/main.ts"]);
263        assert!(config.ignore_patterns.is_empty());
264    }
265
266    #[test]
267    fn fallow_config_deserialize_ignore_exports() {
268        let toml_str = r#"
269[[ignoreExports]]
270file = "src/types/*.ts"
271exports = ["*"]
272
273[[ignoreExports]]
274file = "src/constants.ts"
275exports = ["FOO", "BAR"]
276"#;
277        let config: FallowConfig = toml::from_str(toml_str).unwrap();
278        assert_eq!(config.ignore_exports.len(), 2);
279        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
280        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
281        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
282    }
283
284    #[test]
285    fn fallow_config_deserialize_ignore_dependencies() {
286        let toml_str = r#"
287ignoreDependencies = ["autoprefixer", "postcss"]
288"#;
289        let config: FallowConfig = toml::from_str(toml_str).unwrap();
290        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
291    }
292
293    #[test]
294    fn fallow_config_resolve_default_ignores() {
295        let config = FallowConfig {
296            schema: None,
297            extends: vec![],
298            entry: vec![],
299            ignore_patterns: vec![],
300            framework: vec![],
301            workspaces: None,
302            ignore_dependencies: vec![],
303            ignore_exports: vec![],
304            duplicates: DuplicatesConfig::default(),
305            health: HealthConfig::default(),
306            rules: RulesConfig::default(),
307            boundaries: BoundaryConfig::default(),
308            production: false,
309            plugins: vec![],
310            overrides: vec![],
311            regression: None,
312        };
313        let resolved = config.resolve(
314            PathBuf::from("/tmp/test"),
315            OutputFormat::Human,
316            4,
317            true,
318            true,
319        );
320
321        // Default ignores should be compiled
322        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
323        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
324        assert!(resolved.ignore_patterns.is_match("build/output.js"));
325        assert!(resolved.ignore_patterns.is_match(".git/config"));
326        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
327        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
328        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
329    }
330
331    #[test]
332    fn fallow_config_resolve_custom_ignores() {
333        let config = FallowConfig {
334            schema: None,
335            extends: vec![],
336            entry: vec!["src/**/*.ts".to_string()],
337            ignore_patterns: vec!["**/*.generated.ts".to_string()],
338            framework: vec![],
339            workspaces: None,
340            ignore_dependencies: vec![],
341            ignore_exports: vec![],
342            duplicates: DuplicatesConfig::default(),
343            health: HealthConfig::default(),
344            rules: RulesConfig::default(),
345            boundaries: BoundaryConfig::default(),
346            production: false,
347            plugins: vec![],
348            overrides: vec![],
349            regression: None,
350        };
351        let resolved = config.resolve(
352            PathBuf::from("/tmp/test"),
353            OutputFormat::Json,
354            4,
355            false,
356            true,
357        );
358
359        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
360        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
361        assert!(matches!(resolved.output, OutputFormat::Json));
362        assert!(!resolved.no_cache);
363    }
364
365    #[test]
366    fn fallow_config_resolve_cache_dir() {
367        let config = FallowConfig {
368            schema: None,
369            extends: vec![],
370            entry: vec![],
371            ignore_patterns: vec![],
372            framework: vec![],
373            workspaces: None,
374            ignore_dependencies: vec![],
375            ignore_exports: vec![],
376            duplicates: DuplicatesConfig::default(),
377            health: HealthConfig::default(),
378            rules: RulesConfig::default(),
379            boundaries: BoundaryConfig::default(),
380            production: false,
381            plugins: vec![],
382            overrides: vec![],
383            regression: None,
384        };
385        let resolved = config.resolve(
386            PathBuf::from("/tmp/project"),
387            OutputFormat::Human,
388            4,
389            true,
390            true,
391        );
392        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
393        assert!(resolved.no_cache);
394    }
395
396    #[test]
397    fn package_json_entry_points_main() {
398        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
399        let entries = pkg.entry_points();
400        assert!(entries.contains(&"dist/index.js".to_string()));
401    }
402
403    #[test]
404    fn package_json_entry_points_module() {
405        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
406        let entries = pkg.entry_points();
407        assert!(entries.contains(&"dist/index.mjs".to_string()));
408    }
409
410    #[test]
411    fn package_json_entry_points_types() {
412        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
413        let entries = pkg.entry_points();
414        assert!(entries.contains(&"dist/index.d.ts".to_string()));
415    }
416
417    #[test]
418    fn package_json_entry_points_bin_string() {
419        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
420        let entries = pkg.entry_points();
421        assert!(entries.contains(&"bin/cli.js".to_string()));
422    }
423
424    #[test]
425    fn package_json_entry_points_bin_object() {
426        let pkg: PackageJson =
427            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
428                .unwrap();
429        let entries = pkg.entry_points();
430        assert!(entries.contains(&"bin/cli.js".to_string()));
431        assert!(entries.contains(&"bin/serve.js".to_string()));
432    }
433
434    #[test]
435    fn package_json_entry_points_exports_string() {
436        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
437        let entries = pkg.entry_points();
438        assert!(entries.contains(&"./dist/index.js".to_string()));
439    }
440
441    #[test]
442    fn package_json_entry_points_exports_object() {
443        let pkg: PackageJson = serde_json::from_str(
444            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
445        )
446        .unwrap();
447        let entries = pkg.entry_points();
448        assert!(entries.contains(&"./dist/index.mjs".to_string()));
449        assert!(entries.contains(&"./dist/index.cjs".to_string()));
450    }
451
452    #[test]
453    fn package_json_dependency_names() {
454        let pkg: PackageJson = serde_json::from_str(
455            r#"{
456            "dependencies": {"react": "^18", "lodash": "^4"},
457            "devDependencies": {"typescript": "^5"},
458            "peerDependencies": {"react-dom": "^18"}
459        }"#,
460        )
461        .unwrap();
462
463        let all = pkg.all_dependency_names();
464        assert!(all.contains(&"react".to_string()));
465        assert!(all.contains(&"lodash".to_string()));
466        assert!(all.contains(&"typescript".to_string()));
467        assert!(all.contains(&"react-dom".to_string()));
468
469        let prod = pkg.production_dependency_names();
470        assert!(prod.contains(&"react".to_string()));
471        assert!(!prod.contains(&"typescript".to_string()));
472
473        let dev = pkg.dev_dependency_names();
474        assert!(dev.contains(&"typescript".to_string()));
475        assert!(!dev.contains(&"react".to_string()));
476    }
477
478    #[test]
479    fn package_json_no_dependencies() {
480        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
481        assert!(pkg.all_dependency_names().is_empty());
482        assert!(pkg.production_dependency_names().is_empty());
483        assert!(pkg.dev_dependency_names().is_empty());
484        assert!(pkg.entry_points().is_empty());
485    }
486
487    #[test]
488    fn rules_deserialize_toml_kebab_case() {
489        let toml_str = r#"
490[rules]
491unused-files = "error"
492unused-exports = "warn"
493unused-types = "off"
494"#;
495        let config: FallowConfig = toml::from_str(toml_str).unwrap();
496        assert_eq!(config.rules.unused_files, Severity::Error);
497        assert_eq!(config.rules.unused_exports, Severity::Warn);
498        assert_eq!(config.rules.unused_types, Severity::Off);
499        // Unset fields default to error
500        assert_eq!(config.rules.unresolved_imports, Severity::Error);
501    }
502
503    #[test]
504    fn config_without_rules_defaults_to_error() {
505        let toml_str = r#"
506entry = ["src/main.ts"]
507"#;
508        let config: FallowConfig = toml::from_str(toml_str).unwrap();
509        assert_eq!(config.rules.unused_files, Severity::Error);
510        assert_eq!(config.rules.unused_exports, Severity::Error);
511    }
512
513    #[test]
514    fn fallow_config_denies_unknown_fields() {
515        let toml_str = r"
516unknown_field = true
517";
518        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn fallow_config_deserialize_json() {
524        let json_str = r#"{"entry": ["src/main.ts"]}"#;
525        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
526        assert_eq!(config.entry, vec!["src/main.ts"]);
527    }
528
529    #[test]
530    fn fallow_config_deserialize_jsonc() {
531        let jsonc_str = r#"{
532            // This is a comment
533            "entry": ["src/main.ts"],
534            "rules": {
535                "unused-files": "warn"
536            }
537        }"#;
538        let mut stripped = String::new();
539        json_comments::StripComments::new(jsonc_str.as_bytes())
540            .read_to_string(&mut stripped)
541            .unwrap();
542        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
543        assert_eq!(config.entry, vec!["src/main.ts"]);
544        assert_eq!(config.rules.unused_files, Severity::Warn);
545    }
546
547    #[test]
548    fn fallow_config_json_with_schema_field() {
549        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
550        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
551        assert_eq!(config.entry, vec!["src/main.ts"]);
552    }
553
554    #[test]
555    fn fallow_config_json_schema_generation() {
556        let schema = FallowConfig::json_schema();
557        assert!(schema.is_object());
558        let obj = schema.as_object().unwrap();
559        assert!(obj.contains_key("properties"));
560    }
561
562    #[test]
563    fn config_format_detection() {
564        assert!(matches!(
565            ConfigFormat::from_path(Path::new("fallow.toml")),
566            ConfigFormat::Toml
567        ));
568        assert!(matches!(
569            ConfigFormat::from_path(Path::new(".fallowrc.json")),
570            ConfigFormat::Json
571        ));
572        assert!(matches!(
573            ConfigFormat::from_path(Path::new(".fallow.toml")),
574            ConfigFormat::Toml
575        ));
576    }
577
578    #[test]
579    fn config_names_priority_order() {
580        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
581        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
582        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
583    }
584
585    #[test]
586    fn load_json_config_file() {
587        let dir = test_dir("json-config");
588        let config_path = dir.path().join(".fallowrc.json");
589        std::fs::write(
590            &config_path,
591            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
592        )
593        .unwrap();
594
595        let config = FallowConfig::load(&config_path).unwrap();
596        assert_eq!(config.entry, vec!["src/index.ts"]);
597        assert_eq!(config.rules.unused_exports, Severity::Warn);
598    }
599
600    #[test]
601    fn load_jsonc_config_file() {
602        let dir = test_dir("jsonc-config");
603        let config_path = dir.path().join(".fallowrc.json");
604        std::fs::write(
605            &config_path,
606            r#"{
607                // Entry points for analysis
608                "entry": ["src/index.ts"],
609                /* Block comment */
610                "rules": {
611                    "unused-exports": "warn"
612                }
613            }"#,
614        )
615        .unwrap();
616
617        let config = FallowConfig::load(&config_path).unwrap();
618        assert_eq!(config.entry, vec!["src/index.ts"]);
619        assert_eq!(config.rules.unused_exports, Severity::Warn);
620    }
621
622    #[test]
623    fn json_config_ignore_dependencies_camel_case() {
624        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
625        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
626        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
627    }
628
629    #[test]
630    fn json_config_all_fields() {
631        let json_str = r#"{
632            "ignoreDependencies": ["lodash"],
633            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
634            "rules": {
635                "unused-files": "off",
636                "unused-exports": "warn",
637                "unused-dependencies": "error",
638                "unused-dev-dependencies": "off",
639                "unused-types": "warn",
640                "unused-enum-members": "error",
641                "unused-class-members": "off",
642                "unresolved-imports": "warn",
643                "unlisted-dependencies": "error",
644                "duplicate-exports": "off"
645            },
646            "duplicates": {
647                "minTokens": 100,
648                "minLines": 10,
649                "skipLocal": true
650            }
651        }"#;
652        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
653        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
654        assert_eq!(config.rules.unused_files, Severity::Off);
655        assert_eq!(config.rules.unused_exports, Severity::Warn);
656        assert_eq!(config.rules.unused_dependencies, Severity::Error);
657        assert_eq!(config.duplicates.min_tokens, 100);
658        assert_eq!(config.duplicates.min_lines, 10);
659        assert!(config.duplicates.skip_local);
660    }
661
662    // ── extends tests ──────────────────────────────────────────────
663
664    #[test]
665    fn extends_single_base() {
666        let dir = test_dir("extends-single");
667
668        std::fs::write(
669            dir.path().join("base.json"),
670            r#"{"rules": {"unused-files": "warn"}}"#,
671        )
672        .unwrap();
673        std::fs::write(
674            dir.path().join(".fallowrc.json"),
675            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
676        )
677        .unwrap();
678
679        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
680        assert_eq!(config.rules.unused_files, Severity::Warn);
681        assert_eq!(config.entry, vec!["src/index.ts"]);
682        // Unset fields from base still default
683        assert_eq!(config.rules.unused_exports, Severity::Error);
684    }
685
686    #[test]
687    fn extends_overlay_overrides_base() {
688        let dir = test_dir("extends-overlay");
689
690        std::fs::write(
691            dir.path().join("base.json"),
692            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
693        )
694        .unwrap();
695        std::fs::write(
696            dir.path().join(".fallowrc.json"),
697            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
698        )
699        .unwrap();
700
701        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
702        // Overlay overrides base
703        assert_eq!(config.rules.unused_files, Severity::Error);
704        // Base value preserved when not overridden
705        assert_eq!(config.rules.unused_exports, Severity::Off);
706    }
707
708    #[test]
709    fn extends_chained() {
710        let dir = test_dir("extends-chained");
711
712        std::fs::write(
713            dir.path().join("grandparent.json"),
714            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
715        )
716        .unwrap();
717        std::fs::write(
718            dir.path().join("parent.json"),
719            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
720        )
721        .unwrap();
722        std::fs::write(
723            dir.path().join(".fallowrc.json"),
724            r#"{"extends": ["parent.json"]}"#,
725        )
726        .unwrap();
727
728        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
729        // grandparent: off -> parent: warn -> child: inherits warn
730        assert_eq!(config.rules.unused_files, Severity::Warn);
731        // grandparent: warn, not overridden
732        assert_eq!(config.rules.unused_exports, Severity::Warn);
733    }
734
735    #[test]
736    fn extends_circular_detected() {
737        let dir = test_dir("extends-circular");
738
739        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
740        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
741
742        let result = FallowConfig::load(&dir.path().join("a.json"));
743        assert!(result.is_err());
744        let err_msg = format!("{}", result.unwrap_err());
745        assert!(
746            err_msg.contains("Circular extends"),
747            "Expected circular error, got: {err_msg}"
748        );
749    }
750
751    #[test]
752    fn extends_missing_file_errors() {
753        let dir = test_dir("extends-missing");
754
755        std::fs::write(
756            dir.path().join(".fallowrc.json"),
757            r#"{"extends": ["nonexistent.json"]}"#,
758        )
759        .unwrap();
760
761        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
762        assert!(result.is_err());
763        let err_msg = format!("{}", result.unwrap_err());
764        assert!(
765            err_msg.contains("not found"),
766            "Expected not found error, got: {err_msg}"
767        );
768    }
769
770    #[test]
771    fn extends_string_sugar() {
772        let dir = test_dir("extends-string");
773
774        std::fs::write(
775            dir.path().join("base.json"),
776            r#"{"ignorePatterns": ["gen/**"]}"#,
777        )
778        .unwrap();
779        // String form instead of array
780        std::fs::write(
781            dir.path().join(".fallowrc.json"),
782            r#"{"extends": "base.json"}"#,
783        )
784        .unwrap();
785
786        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
787        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
788    }
789
790    #[test]
791    fn extends_deep_merge_preserves_arrays() {
792        let dir = test_dir("extends-array");
793
794        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
795        std::fs::write(
796            dir.path().join(".fallowrc.json"),
797            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
798        )
799        .unwrap();
800
801        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
802        // Arrays are replaced, not merged (overlay replaces base)
803        assert_eq!(config.entry, vec!["src/b.ts"]);
804    }
805
806    // ── deep_merge_json unit tests ───────────────────────────────────
807
808    #[test]
809    fn deep_merge_scalar_overlay_replaces_base() {
810        let mut base = serde_json::json!("hello");
811        deep_merge_json(&mut base, serde_json::json!("world"));
812        assert_eq!(base, serde_json::json!("world"));
813    }
814
815    #[test]
816    fn deep_merge_array_overlay_replaces_base() {
817        let mut base = serde_json::json!(["a", "b"]);
818        deep_merge_json(&mut base, serde_json::json!(["c"]));
819        assert_eq!(base, serde_json::json!(["c"]));
820    }
821
822    #[test]
823    fn deep_merge_nested_object_merge() {
824        let mut base = serde_json::json!({
825            "level1": {
826                "level2": {
827                    "a": 1,
828                    "b": 2
829                }
830            }
831        });
832        let overlay = serde_json::json!({
833            "level1": {
834                "level2": {
835                    "b": 99,
836                    "c": 3
837                }
838            }
839        });
840        deep_merge_json(&mut base, overlay);
841        assert_eq!(base["level1"]["level2"]["a"], 1);
842        assert_eq!(base["level1"]["level2"]["b"], 99);
843        assert_eq!(base["level1"]["level2"]["c"], 3);
844    }
845
846    #[test]
847    fn deep_merge_overlay_adds_new_fields() {
848        let mut base = serde_json::json!({"existing": true});
849        let overlay = serde_json::json!({"new_field": "added", "another": 42});
850        deep_merge_json(&mut base, overlay);
851        assert_eq!(base["existing"], true);
852        assert_eq!(base["new_field"], "added");
853        assert_eq!(base["another"], 42);
854    }
855
856    #[test]
857    fn deep_merge_null_overlay_replaces_object() {
858        let mut base = serde_json::json!({"key": "value"});
859        deep_merge_json(&mut base, serde_json::json!(null));
860        assert_eq!(base, serde_json::json!(null));
861    }
862
863    #[test]
864    fn deep_merge_empty_object_overlay_preserves_base() {
865        let mut base = serde_json::json!({"a": 1, "b": 2});
866        deep_merge_json(&mut base, serde_json::json!({}));
867        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
868    }
869
870    // ── rule severity parsing via JSON config ────────────────────────
871
872    #[test]
873    fn rules_severity_error_warn_off_from_json() {
874        let json_str = r#"{
875            "rules": {
876                "unused-files": "error",
877                "unused-exports": "warn",
878                "unused-types": "off"
879            }
880        }"#;
881        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
882        assert_eq!(config.rules.unused_files, Severity::Error);
883        assert_eq!(config.rules.unused_exports, Severity::Warn);
884        assert_eq!(config.rules.unused_types, Severity::Off);
885    }
886
887    #[test]
888    fn rules_omitted_default_to_error() {
889        let json_str = r#"{
890            "rules": {
891                "unused-files": "warn"
892            }
893        }"#;
894        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
895        assert_eq!(config.rules.unused_files, Severity::Warn);
896        // All other rules default to error
897        assert_eq!(config.rules.unused_exports, Severity::Error);
898        assert_eq!(config.rules.unused_types, Severity::Error);
899        assert_eq!(config.rules.unused_dependencies, Severity::Error);
900        assert_eq!(config.rules.unresolved_imports, Severity::Error);
901        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
902        assert_eq!(config.rules.duplicate_exports, Severity::Error);
903        assert_eq!(config.rules.circular_dependencies, Severity::Error);
904        // type_only_dependencies defaults to warn, not error
905        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
906    }
907
908    // ── find_and_load tests ───────────────────────────────────────
909
910    #[test]
911    fn find_and_load_returns_none_when_no_config() {
912        let dir = test_dir("find-none");
913        // Create a .git dir so it stops searching
914        std::fs::create_dir(dir.path().join(".git")).unwrap();
915
916        let result = FallowConfig::find_and_load(dir.path()).unwrap();
917        assert!(result.is_none());
918    }
919
920    #[test]
921    fn find_and_load_finds_fallowrc_json() {
922        let dir = test_dir("find-json");
923        std::fs::create_dir(dir.path().join(".git")).unwrap();
924        std::fs::write(
925            dir.path().join(".fallowrc.json"),
926            r#"{"entry": ["src/main.ts"]}"#,
927        )
928        .unwrap();
929
930        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
931        assert_eq!(config.entry, vec!["src/main.ts"]);
932        assert!(path.ends_with(".fallowrc.json"));
933    }
934
935    #[test]
936    fn find_and_load_prefers_fallowrc_json_over_toml() {
937        let dir = test_dir("find-priority");
938        std::fs::create_dir(dir.path().join(".git")).unwrap();
939        std::fs::write(
940            dir.path().join(".fallowrc.json"),
941            r#"{"entry": ["from-json.ts"]}"#,
942        )
943        .unwrap();
944        std::fs::write(
945            dir.path().join("fallow.toml"),
946            "entry = [\"from-toml.ts\"]\n",
947        )
948        .unwrap();
949
950        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
951        assert_eq!(config.entry, vec!["from-json.ts"]);
952        assert!(path.ends_with(".fallowrc.json"));
953    }
954
955    #[test]
956    fn find_and_load_finds_fallow_toml() {
957        let dir = test_dir("find-toml");
958        std::fs::create_dir(dir.path().join(".git")).unwrap();
959        std::fs::write(
960            dir.path().join("fallow.toml"),
961            "entry = [\"src/index.ts\"]\n",
962        )
963        .unwrap();
964
965        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
966        assert_eq!(config.entry, vec!["src/index.ts"]);
967    }
968
969    #[test]
970    fn find_and_load_stops_at_git_dir() {
971        let dir = test_dir("find-git-stop");
972        let sub = dir.path().join("sub");
973        std::fs::create_dir(&sub).unwrap();
974        // .git marker in root stops search
975        std::fs::create_dir(dir.path().join(".git")).unwrap();
976        // Config file above .git should not be found from sub
977        // (sub has no .git or package.json, so it keeps searching up to parent)
978        // But parent has .git, so it stops there without finding config
979        let result = FallowConfig::find_and_load(&sub).unwrap();
980        assert!(result.is_none());
981    }
982
983    #[test]
984    fn find_and_load_stops_at_package_json() {
985        let dir = test_dir("find-pkg-stop");
986        std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
987
988        let result = FallowConfig::find_and_load(dir.path()).unwrap();
989        assert!(result.is_none());
990    }
991
992    #[test]
993    fn find_and_load_returns_error_for_invalid_config() {
994        let dir = test_dir("find-invalid");
995        std::fs::create_dir(dir.path().join(".git")).unwrap();
996        std::fs::write(
997            dir.path().join(".fallowrc.json"),
998            r"{ this is not valid json }",
999        )
1000        .unwrap();
1001
1002        let result = FallowConfig::find_and_load(dir.path());
1003        assert!(result.is_err());
1004    }
1005
1006    // ── load TOML config file ────────────────────────────────────
1007
1008    #[test]
1009    fn load_toml_config_file() {
1010        let dir = test_dir("toml-config");
1011        let config_path = dir.path().join("fallow.toml");
1012        std::fs::write(
1013            &config_path,
1014            r#"
1015entry = ["src/index.ts"]
1016ignorePatterns = ["dist/**"]
1017
1018[rules]
1019unused-files = "warn"
1020
1021[duplicates]
1022minTokens = 100
1023"#,
1024        )
1025        .unwrap();
1026
1027        let config = FallowConfig::load(&config_path).unwrap();
1028        assert_eq!(config.entry, vec!["src/index.ts"]);
1029        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1030        assert_eq!(config.rules.unused_files, Severity::Warn);
1031        assert_eq!(config.duplicates.min_tokens, 100);
1032    }
1033
1034    // ── extends absolute path rejection ──────────────────────────
1035
1036    #[test]
1037    fn extends_absolute_path_rejected() {
1038        let dir = test_dir("extends-absolute");
1039
1040        // Use a platform-appropriate absolute path
1041        #[cfg(unix)]
1042        let abs_path = "/absolute/path/config.json";
1043        #[cfg(windows)]
1044        let abs_path = "C:\\absolute\\path\\config.json";
1045
1046        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1047        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1048
1049        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1050        assert!(result.is_err());
1051        let err_msg = format!("{}", result.unwrap_err());
1052        assert!(
1053            err_msg.contains("must be relative"),
1054            "Expected 'must be relative' error, got: {err_msg}"
1055        );
1056    }
1057
1058    // ── resolve production mode ─────────────────────────────────
1059
1060    #[test]
1061    fn resolve_production_mode_disables_dev_deps() {
1062        let config = FallowConfig {
1063            schema: None,
1064            extends: vec![],
1065            entry: vec![],
1066            ignore_patterns: vec![],
1067            framework: vec![],
1068            workspaces: None,
1069            ignore_dependencies: vec![],
1070            ignore_exports: vec![],
1071            duplicates: DuplicatesConfig::default(),
1072            health: HealthConfig::default(),
1073            rules: RulesConfig::default(),
1074            boundaries: BoundaryConfig::default(),
1075            production: true,
1076            plugins: vec![],
1077            overrides: vec![],
1078            regression: None,
1079        };
1080        let resolved = config.resolve(
1081            PathBuf::from("/tmp/test"),
1082            OutputFormat::Human,
1083            4,
1084            false,
1085            true,
1086        );
1087        assert!(resolved.production);
1088        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1089        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1090        // Other rules should remain at default (Error)
1091        assert_eq!(resolved.rules.unused_files, Severity::Error);
1092        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1093    }
1094
1095    // ── config format fallback to TOML for unknown extensions ───
1096
1097    #[test]
1098    fn config_format_defaults_to_toml_for_unknown() {
1099        assert!(matches!(
1100            ConfigFormat::from_path(Path::new("config.yaml")),
1101            ConfigFormat::Toml
1102        ));
1103        assert!(matches!(
1104            ConfigFormat::from_path(Path::new("config")),
1105            ConfigFormat::Toml
1106        ));
1107    }
1108
1109    // ── deep_merge type coercion ─────────────────────────────────
1110
1111    #[test]
1112    fn deep_merge_object_over_scalar_replaces() {
1113        let mut base = serde_json::json!("just a string");
1114        let overlay = serde_json::json!({"key": "value"});
1115        deep_merge_json(&mut base, overlay);
1116        assert_eq!(base, serde_json::json!({"key": "value"}));
1117    }
1118
1119    #[test]
1120    fn deep_merge_scalar_over_object_replaces() {
1121        let mut base = serde_json::json!({"key": "value"});
1122        let overlay = serde_json::json!(42);
1123        deep_merge_json(&mut base, overlay);
1124        assert_eq!(base, serde_json::json!(42));
1125    }
1126
1127    // ── extends with non-string/array extends field ──────────────
1128
1129    #[test]
1130    fn extends_non_string_non_array_ignored() {
1131        let dir = test_dir("extends-numeric");
1132        std::fs::write(
1133            dir.path().join(".fallowrc.json"),
1134            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1135        )
1136        .unwrap();
1137
1138        // extends=42 is neither string nor array, so it's treated as no extends
1139        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1140        assert_eq!(config.entry, vec!["src/index.ts"]);
1141    }
1142
1143    // ── extends with multiple bases (later overrides earlier) ────
1144
1145    #[test]
1146    fn extends_multiple_bases_later_wins() {
1147        let dir = test_dir("extends-multi-base");
1148
1149        std::fs::write(
1150            dir.path().join("base-a.json"),
1151            r#"{"rules": {"unused-files": "warn"}}"#,
1152        )
1153        .unwrap();
1154        std::fs::write(
1155            dir.path().join("base-b.json"),
1156            r#"{"rules": {"unused-files": "off"}}"#,
1157        )
1158        .unwrap();
1159        std::fs::write(
1160            dir.path().join(".fallowrc.json"),
1161            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1162        )
1163        .unwrap();
1164
1165        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1166        // base-b is later in the array, so its value should win
1167        assert_eq!(config.rules.unused_files, Severity::Off);
1168    }
1169
1170    // ── config with production flag ──────────────────────────────
1171
1172    #[test]
1173    fn fallow_config_deserialize_production() {
1174        let json_str = r#"{"production": true}"#;
1175        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1176        assert!(config.production);
1177    }
1178
1179    #[test]
1180    fn fallow_config_production_defaults_false() {
1181        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1182        assert!(!config.production);
1183    }
1184
1185    // ── optional dependency names ────────────────────────────────
1186
1187    #[test]
1188    fn package_json_optional_dependency_names() {
1189        let pkg: PackageJson = serde_json::from_str(
1190            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1191        )
1192        .unwrap();
1193        let opt = pkg.optional_dependency_names();
1194        assert_eq!(opt.len(), 2);
1195        assert!(opt.contains(&"fsevents".to_string()));
1196        assert!(opt.contains(&"chokidar".to_string()));
1197    }
1198
1199    #[test]
1200    fn package_json_optional_deps_empty_when_missing() {
1201        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1202        assert!(pkg.optional_dependency_names().is_empty());
1203    }
1204
1205    // ── find_config_path ────────────────────────────────────────────
1206
1207    #[test]
1208    fn find_config_path_returns_fallowrc_json() {
1209        let dir = test_dir("find-path-json");
1210        std::fs::create_dir(dir.path().join(".git")).unwrap();
1211        std::fs::write(
1212            dir.path().join(".fallowrc.json"),
1213            r#"{"entry": ["src/main.ts"]}"#,
1214        )
1215        .unwrap();
1216
1217        let path = FallowConfig::find_config_path(dir.path());
1218        assert!(path.is_some());
1219        assert!(path.unwrap().ends_with(".fallowrc.json"));
1220    }
1221
1222    #[test]
1223    fn find_config_path_returns_fallow_toml() {
1224        let dir = test_dir("find-path-toml");
1225        std::fs::create_dir(dir.path().join(".git")).unwrap();
1226        std::fs::write(
1227            dir.path().join("fallow.toml"),
1228            "entry = [\"src/main.ts\"]\n",
1229        )
1230        .unwrap();
1231
1232        let path = FallowConfig::find_config_path(dir.path());
1233        assert!(path.is_some());
1234        assert!(path.unwrap().ends_with("fallow.toml"));
1235    }
1236
1237    #[test]
1238    fn find_config_path_returns_dot_fallow_toml() {
1239        let dir = test_dir("find-path-dot-toml");
1240        std::fs::create_dir(dir.path().join(".git")).unwrap();
1241        std::fs::write(
1242            dir.path().join(".fallow.toml"),
1243            "entry = [\"src/main.ts\"]\n",
1244        )
1245        .unwrap();
1246
1247        let path = FallowConfig::find_config_path(dir.path());
1248        assert!(path.is_some());
1249        assert!(path.unwrap().ends_with(".fallow.toml"));
1250    }
1251
1252    #[test]
1253    fn find_config_path_prefers_json_over_toml() {
1254        let dir = test_dir("find-path-priority");
1255        std::fs::create_dir(dir.path().join(".git")).unwrap();
1256        std::fs::write(
1257            dir.path().join(".fallowrc.json"),
1258            r#"{"entry": ["json.ts"]}"#,
1259        )
1260        .unwrap();
1261        std::fs::write(dir.path().join("fallow.toml"), "entry = [\"toml.ts\"]\n").unwrap();
1262
1263        let path = FallowConfig::find_config_path(dir.path());
1264        assert!(path.unwrap().ends_with(".fallowrc.json"));
1265    }
1266
1267    #[test]
1268    fn find_config_path_none_when_no_config() {
1269        let dir = test_dir("find-path-none");
1270        std::fs::create_dir(dir.path().join(".git")).unwrap();
1271
1272        let path = FallowConfig::find_config_path(dir.path());
1273        assert!(path.is_none());
1274    }
1275
1276    #[test]
1277    fn find_config_path_stops_at_package_json() {
1278        let dir = test_dir("find-path-pkg-stop");
1279        std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
1280
1281        let path = FallowConfig::find_config_path(dir.path());
1282        assert!(path.is_none());
1283    }
1284
1285    // ── TOML extends support ────────────────────────────────────────
1286
1287    #[test]
1288    fn extends_toml_base() {
1289        let dir = test_dir("extends-toml");
1290
1291        std::fs::write(
1292            dir.path().join("base.json"),
1293            r#"{"rules": {"unused-files": "warn"}}"#,
1294        )
1295        .unwrap();
1296        std::fs::write(
1297            dir.path().join("fallow.toml"),
1298            "extends = [\"base.json\"]\nentry = [\"src/index.ts\"]\n",
1299        )
1300        .unwrap();
1301
1302        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
1303        assert_eq!(config.rules.unused_files, Severity::Warn);
1304        assert_eq!(config.entry, vec!["src/index.ts"]);
1305    }
1306
1307    // ── deep_merge_json edge cases ──────────────────────────────────
1308
1309    #[test]
1310    fn deep_merge_boolean_overlay() {
1311        let mut base = serde_json::json!(true);
1312        deep_merge_json(&mut base, serde_json::json!(false));
1313        assert_eq!(base, serde_json::json!(false));
1314    }
1315
1316    #[test]
1317    fn deep_merge_number_overlay() {
1318        let mut base = serde_json::json!(42);
1319        deep_merge_json(&mut base, serde_json::json!(99));
1320        assert_eq!(base, serde_json::json!(99));
1321    }
1322
1323    #[test]
1324    fn deep_merge_disjoint_objects() {
1325        let mut base = serde_json::json!({"a": 1});
1326        let overlay = serde_json::json!({"b": 2});
1327        deep_merge_json(&mut base, overlay);
1328        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
1329    }
1330
1331    // ── MAX_EXTENDS_DEPTH constant ──────────────────────────────────
1332
1333    #[test]
1334    fn max_extends_depth_is_reasonable() {
1335        assert_eq!(MAX_EXTENDS_DEPTH, 10);
1336    }
1337
1338    // ── Config names constant ───────────────────────────────────────
1339
1340    #[test]
1341    fn config_names_has_three_entries() {
1342        assert_eq!(CONFIG_NAMES.len(), 3);
1343        // All names should start with "." or "fallow"
1344        for name in CONFIG_NAMES {
1345            assert!(
1346                name.starts_with('.') || name.starts_with("fallow"),
1347                "unexpected config name: {name}"
1348            );
1349        }
1350    }
1351
1352    // ── package.json peer dependency names ───────────────────────────
1353
1354    #[test]
1355    fn package_json_peer_dependency_names() {
1356        let pkg: PackageJson = serde_json::from_str(
1357            r#"{
1358            "dependencies": {"react": "^18"},
1359            "peerDependencies": {"react-dom": "^18", "react-native": "^0.72"}
1360        }"#,
1361        )
1362        .unwrap();
1363        let all = pkg.all_dependency_names();
1364        assert!(all.contains(&"react".to_string()));
1365        assert!(all.contains(&"react-dom".to_string()));
1366        assert!(all.contains(&"react-native".to_string()));
1367    }
1368
1369    // ── package.json scripts field ──────────────────────────────────
1370
1371    #[test]
1372    fn package_json_scripts_field() {
1373        let pkg: PackageJson = serde_json::from_str(
1374            r#"{
1375            "scripts": {
1376                "build": "tsc",
1377                "test": "vitest",
1378                "lint": "fallow check"
1379            }
1380        }"#,
1381        )
1382        .unwrap();
1383        let scripts = pkg.scripts.unwrap();
1384        assert_eq!(scripts.len(), 3);
1385        assert_eq!(scripts.get("build"), Some(&"tsc".to_string()));
1386        assert_eq!(scripts.get("lint"), Some(&"fallow check".to_string()));
1387    }
1388
1389    // ── Extends with TOML-to-TOML chain ─────────────────────────────
1390
1391    #[test]
1392    fn extends_toml_chain() {
1393        let dir = test_dir("extends-toml-chain");
1394
1395        std::fs::write(
1396            dir.path().join("base.json"),
1397            r#"{"entry": ["src/base.ts"]}"#,
1398        )
1399        .unwrap();
1400        std::fs::write(
1401            dir.path().join("middle.json"),
1402            r#"{"extends": ["base.json"], "rules": {"unused-files": "off"}}"#,
1403        )
1404        .unwrap();
1405        std::fs::write(
1406            dir.path().join("fallow.toml"),
1407            "extends = [\"middle.json\"]\n",
1408        )
1409        .unwrap();
1410
1411        let config = FallowConfig::load(&dir.path().join("fallow.toml")).unwrap();
1412        assert_eq!(config.entry, vec!["src/base.ts"]);
1413        assert_eq!(config.rules.unused_files, Severity::Off);
1414    }
1415
1416    // ── find_and_load walks up to parent ────────────────────────────
1417
1418    #[test]
1419    fn find_and_load_walks_up_directories() {
1420        let dir = test_dir("find-walk-up");
1421        let sub = dir.path().join("src").join("deep");
1422        std::fs::create_dir_all(&sub).unwrap();
1423        std::fs::write(
1424            dir.path().join(".fallowrc.json"),
1425            r#"{"entry": ["src/main.ts"]}"#,
1426        )
1427        .unwrap();
1428        // Create .git in root to stop search there
1429        std::fs::create_dir(dir.path().join(".git")).unwrap();
1430
1431        let (config, path) = FallowConfig::find_and_load(&sub).unwrap().unwrap();
1432        assert_eq!(config.entry, vec!["src/main.ts"]);
1433        assert!(path.ends_with(".fallowrc.json"));
1434    }
1435
1436    // ── JSON schema generation ──────────────────────────────────────
1437
1438    #[test]
1439    fn json_schema_contains_entry_field() {
1440        let schema = FallowConfig::json_schema();
1441        let obj = schema.as_object().unwrap();
1442        let props = obj.get("properties").and_then(|v| v.as_object());
1443        assert!(props.is_some(), "schema should have properties");
1444        assert!(
1445            props.unwrap().contains_key("entry"),
1446            "schema should contain entry property"
1447        );
1448    }
1449
1450    // ── Duplicates config via JSON in FallowConfig ──────────────────
1451
1452    #[test]
1453    fn fallow_config_json_duplicates_all_fields() {
1454        let json = r#"{
1455            "duplicates": {
1456                "enabled": true,
1457                "mode": "semantic",
1458                "minTokens": 200,
1459                "minLines": 20,
1460                "threshold": 10.5,
1461                "ignore": ["**/*.test.ts"],
1462                "skipLocal": true,
1463                "crossLanguage": true,
1464                "normalization": {
1465                    "ignoreIdentifiers": true,
1466                    "ignoreStringValues": false
1467                }
1468            }
1469        }"#;
1470        let config: FallowConfig = serde_json::from_str(json).unwrap();
1471        assert!(config.duplicates.enabled);
1472        assert_eq!(
1473            config.duplicates.mode,
1474            crate::config::DetectionMode::Semantic
1475        );
1476        assert_eq!(config.duplicates.min_tokens, 200);
1477        assert_eq!(config.duplicates.min_lines, 20);
1478        assert!((config.duplicates.threshold - 10.5).abs() < f64::EPSILON);
1479        assert!(config.duplicates.skip_local);
1480        assert!(config.duplicates.cross_language);
1481        assert_eq!(
1482            config.duplicates.normalization.ignore_identifiers,
1483            Some(true)
1484        );
1485        assert_eq!(
1486            config.duplicates.normalization.ignore_string_values,
1487            Some(false)
1488        );
1489    }
1490}