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    pub fn load(path: &Path) -> Result<Self, miette::Report> {
165        let mut visited = FxHashSet::default();
166        let merged = resolve_extends(path, &mut visited, 0)?;
167
168        serde_json::from_value(merged).map_err(|e| {
169            miette::miette!(
170                "Failed to deserialize config from {}: {}",
171                path.display(),
172                e
173            )
174        })
175    }
176
177    /// Find and load config from the current directory or ancestors.
178    ///
179    /// Checks for config files in priority order:
180    /// `.fallowrc.json` > `fallow.toml` > `.fallow.toml`
181    ///
182    /// Stops searching at the first directory containing `.git` or `package.json`,
183    /// to avoid picking up unrelated config files above the project root.
184    ///
185    /// Returns `Ok(Some(...))` if a config was found and parsed, `Ok(None)` if
186    /// no config file exists, and `Err(...)` if a config file exists but fails to parse.
187    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
188        let mut dir = start;
189        loop {
190            for name in CONFIG_NAMES {
191                let candidate = dir.join(name);
192                if candidate.exists() {
193                    match Self::load(&candidate) {
194                        Ok(config) => return Ok(Some((config, candidate))),
195                        Err(e) => {
196                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
197                        }
198                    }
199                }
200            }
201            // Stop at project root indicators
202            if dir.join(".git").exists() || dir.join("package.json").exists() {
203                break;
204            }
205            dir = match dir.parent() {
206                Some(parent) => parent,
207                None => break,
208            };
209        }
210        Ok(None)
211    }
212
213    /// Generate JSON Schema for the configuration format.
214    pub fn json_schema() -> serde_json::Value {
215        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use std::io::Read as _;
222
223    use super::*;
224    use crate::PackageJson;
225    use crate::config::duplicates_config::DuplicatesConfig;
226    use crate::config::format::OutputFormat;
227    use crate::config::health::HealthConfig;
228    use crate::config::rules::{RulesConfig, Severity};
229
230    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
231    fn test_dir(_name: &str) -> tempfile::TempDir {
232        tempfile::tempdir().expect("create temp dir")
233    }
234
235    #[test]
236    fn fallow_config_deserialize_minimal() {
237        let toml_str = r#"
238entry = ["src/main.ts"]
239"#;
240        let config: FallowConfig = toml::from_str(toml_str).unwrap();
241        assert_eq!(config.entry, vec!["src/main.ts"]);
242        assert!(config.ignore_patterns.is_empty());
243    }
244
245    #[test]
246    fn fallow_config_deserialize_ignore_exports() {
247        let toml_str = r#"
248[[ignoreExports]]
249file = "src/types/*.ts"
250exports = ["*"]
251
252[[ignoreExports]]
253file = "src/constants.ts"
254exports = ["FOO", "BAR"]
255"#;
256        let config: FallowConfig = toml::from_str(toml_str).unwrap();
257        assert_eq!(config.ignore_exports.len(), 2);
258        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
259        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
260        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
261    }
262
263    #[test]
264    fn fallow_config_deserialize_ignore_dependencies() {
265        let toml_str = r#"
266ignoreDependencies = ["autoprefixer", "postcss"]
267"#;
268        let config: FallowConfig = toml::from_str(toml_str).unwrap();
269        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
270    }
271
272    #[test]
273    fn fallow_config_resolve_default_ignores() {
274        let config = FallowConfig {
275            schema: None,
276            extends: vec![],
277            entry: vec![],
278            ignore_patterns: vec![],
279            framework: vec![],
280            workspaces: None,
281            ignore_dependencies: vec![],
282            ignore_exports: vec![],
283            duplicates: DuplicatesConfig::default(),
284            health: HealthConfig::default(),
285            rules: RulesConfig::default(),
286            production: false,
287            plugins: vec![],
288            overrides: vec![],
289        };
290        let resolved = config.resolve(
291            PathBuf::from("/tmp/test"),
292            OutputFormat::Human,
293            4,
294            true,
295            true,
296        );
297
298        // Default ignores should be compiled
299        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
300        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
301        assert!(resolved.ignore_patterns.is_match("build/output.js"));
302        assert!(resolved.ignore_patterns.is_match(".git/config"));
303        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
304        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
305        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
306    }
307
308    #[test]
309    fn fallow_config_resolve_custom_ignores() {
310        let config = FallowConfig {
311            schema: None,
312            extends: vec![],
313            entry: vec!["src/**/*.ts".to_string()],
314            ignore_patterns: vec!["**/*.generated.ts".to_string()],
315            framework: vec![],
316            workspaces: None,
317            ignore_dependencies: vec![],
318            ignore_exports: vec![],
319            duplicates: DuplicatesConfig::default(),
320            health: HealthConfig::default(),
321            rules: RulesConfig::default(),
322            production: false,
323            plugins: vec![],
324            overrides: vec![],
325        };
326        let resolved = config.resolve(
327            PathBuf::from("/tmp/test"),
328            OutputFormat::Json,
329            4,
330            false,
331            true,
332        );
333
334        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
335        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
336        assert!(matches!(resolved.output, OutputFormat::Json));
337        assert!(!resolved.no_cache);
338    }
339
340    #[test]
341    fn fallow_config_resolve_cache_dir() {
342        let config = FallowConfig {
343            schema: None,
344            extends: vec![],
345            entry: vec![],
346            ignore_patterns: vec![],
347            framework: vec![],
348            workspaces: None,
349            ignore_dependencies: vec![],
350            ignore_exports: vec![],
351            duplicates: DuplicatesConfig::default(),
352            health: HealthConfig::default(),
353            rules: RulesConfig::default(),
354            production: false,
355            plugins: vec![],
356            overrides: vec![],
357        };
358        let resolved = config.resolve(
359            PathBuf::from("/tmp/project"),
360            OutputFormat::Human,
361            4,
362            true,
363            true,
364        );
365        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
366        assert!(resolved.no_cache);
367    }
368
369    #[test]
370    fn package_json_entry_points_main() {
371        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
372        let entries = pkg.entry_points();
373        assert!(entries.contains(&"dist/index.js".to_string()));
374    }
375
376    #[test]
377    fn package_json_entry_points_module() {
378        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
379        let entries = pkg.entry_points();
380        assert!(entries.contains(&"dist/index.mjs".to_string()));
381    }
382
383    #[test]
384    fn package_json_entry_points_types() {
385        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
386        let entries = pkg.entry_points();
387        assert!(entries.contains(&"dist/index.d.ts".to_string()));
388    }
389
390    #[test]
391    fn package_json_entry_points_bin_string() {
392        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
393        let entries = pkg.entry_points();
394        assert!(entries.contains(&"bin/cli.js".to_string()));
395    }
396
397    #[test]
398    fn package_json_entry_points_bin_object() {
399        let pkg: PackageJson =
400            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
401                .unwrap();
402        let entries = pkg.entry_points();
403        assert!(entries.contains(&"bin/cli.js".to_string()));
404        assert!(entries.contains(&"bin/serve.js".to_string()));
405    }
406
407    #[test]
408    fn package_json_entry_points_exports_string() {
409        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
410        let entries = pkg.entry_points();
411        assert!(entries.contains(&"./dist/index.js".to_string()));
412    }
413
414    #[test]
415    fn package_json_entry_points_exports_object() {
416        let pkg: PackageJson = serde_json::from_str(
417            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
418        )
419        .unwrap();
420        let entries = pkg.entry_points();
421        assert!(entries.contains(&"./dist/index.mjs".to_string()));
422        assert!(entries.contains(&"./dist/index.cjs".to_string()));
423    }
424
425    #[test]
426    fn package_json_dependency_names() {
427        let pkg: PackageJson = serde_json::from_str(
428            r#"{
429            "dependencies": {"react": "^18", "lodash": "^4"},
430            "devDependencies": {"typescript": "^5"},
431            "peerDependencies": {"react-dom": "^18"}
432        }"#,
433        )
434        .unwrap();
435
436        let all = pkg.all_dependency_names();
437        assert!(all.contains(&"react".to_string()));
438        assert!(all.contains(&"lodash".to_string()));
439        assert!(all.contains(&"typescript".to_string()));
440        assert!(all.contains(&"react-dom".to_string()));
441
442        let prod = pkg.production_dependency_names();
443        assert!(prod.contains(&"react".to_string()));
444        assert!(!prod.contains(&"typescript".to_string()));
445
446        let dev = pkg.dev_dependency_names();
447        assert!(dev.contains(&"typescript".to_string()));
448        assert!(!dev.contains(&"react".to_string()));
449    }
450
451    #[test]
452    fn package_json_no_dependencies() {
453        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
454        assert!(pkg.all_dependency_names().is_empty());
455        assert!(pkg.production_dependency_names().is_empty());
456        assert!(pkg.dev_dependency_names().is_empty());
457        assert!(pkg.entry_points().is_empty());
458    }
459
460    #[test]
461    fn rules_deserialize_toml_kebab_case() {
462        let toml_str = r#"
463[rules]
464unused-files = "error"
465unused-exports = "warn"
466unused-types = "off"
467"#;
468        let config: FallowConfig = toml::from_str(toml_str).unwrap();
469        assert_eq!(config.rules.unused_files, Severity::Error);
470        assert_eq!(config.rules.unused_exports, Severity::Warn);
471        assert_eq!(config.rules.unused_types, Severity::Off);
472        // Unset fields default to error
473        assert_eq!(config.rules.unresolved_imports, Severity::Error);
474    }
475
476    #[test]
477    fn config_without_rules_defaults_to_error() {
478        let toml_str = r#"
479entry = ["src/main.ts"]
480"#;
481        let config: FallowConfig = toml::from_str(toml_str).unwrap();
482        assert_eq!(config.rules.unused_files, Severity::Error);
483        assert_eq!(config.rules.unused_exports, Severity::Error);
484    }
485
486    #[test]
487    fn fallow_config_denies_unknown_fields() {
488        let toml_str = r#"
489unknown_field = true
490"#;
491        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn fallow_config_deserialize_json() {
497        let json_str = r#"{"entry": ["src/main.ts"]}"#;
498        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
499        assert_eq!(config.entry, vec!["src/main.ts"]);
500    }
501
502    #[test]
503    fn fallow_config_deserialize_jsonc() {
504        let jsonc_str = r#"{
505            // This is a comment
506            "entry": ["src/main.ts"],
507            "rules": {
508                "unused-files": "warn"
509            }
510        }"#;
511        let mut stripped = String::new();
512        json_comments::StripComments::new(jsonc_str.as_bytes())
513            .read_to_string(&mut stripped)
514            .unwrap();
515        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
516        assert_eq!(config.entry, vec!["src/main.ts"]);
517        assert_eq!(config.rules.unused_files, Severity::Warn);
518    }
519
520    #[test]
521    fn fallow_config_json_with_schema_field() {
522        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
523        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
524        assert_eq!(config.entry, vec!["src/main.ts"]);
525    }
526
527    #[test]
528    fn fallow_config_json_schema_generation() {
529        let schema = FallowConfig::json_schema();
530        assert!(schema.is_object());
531        let obj = schema.as_object().unwrap();
532        assert!(obj.contains_key("properties"));
533    }
534
535    #[test]
536    fn config_format_detection() {
537        assert!(matches!(
538            ConfigFormat::from_path(Path::new("fallow.toml")),
539            ConfigFormat::Toml
540        ));
541        assert!(matches!(
542            ConfigFormat::from_path(Path::new(".fallowrc.json")),
543            ConfigFormat::Json
544        ));
545        assert!(matches!(
546            ConfigFormat::from_path(Path::new(".fallow.toml")),
547            ConfigFormat::Toml
548        ));
549    }
550
551    #[test]
552    fn config_names_priority_order() {
553        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
554        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
555        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
556    }
557
558    #[test]
559    fn load_json_config_file() {
560        let dir = test_dir("json-config");
561        let config_path = dir.path().join(".fallowrc.json");
562        std::fs::write(
563            &config_path,
564            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
565        )
566        .unwrap();
567
568        let config = FallowConfig::load(&config_path).unwrap();
569        assert_eq!(config.entry, vec!["src/index.ts"]);
570        assert_eq!(config.rules.unused_exports, Severity::Warn);
571    }
572
573    #[test]
574    fn load_jsonc_config_file() {
575        let dir = test_dir("jsonc-config");
576        let config_path = dir.path().join(".fallowrc.json");
577        std::fs::write(
578            &config_path,
579            r#"{
580                // Entry points for analysis
581                "entry": ["src/index.ts"],
582                /* Block comment */
583                "rules": {
584                    "unused-exports": "warn"
585                }
586            }"#,
587        )
588        .unwrap();
589
590        let config = FallowConfig::load(&config_path).unwrap();
591        assert_eq!(config.entry, vec!["src/index.ts"]);
592        assert_eq!(config.rules.unused_exports, Severity::Warn);
593    }
594
595    #[test]
596    fn json_config_ignore_dependencies_camel_case() {
597        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
598        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
599        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
600    }
601
602    #[test]
603    fn json_config_all_fields() {
604        let json_str = r#"{
605            "ignoreDependencies": ["lodash"],
606            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
607            "rules": {
608                "unused-files": "off",
609                "unused-exports": "warn",
610                "unused-dependencies": "error",
611                "unused-dev-dependencies": "off",
612                "unused-types": "warn",
613                "unused-enum-members": "error",
614                "unused-class-members": "off",
615                "unresolved-imports": "warn",
616                "unlisted-dependencies": "error",
617                "duplicate-exports": "off"
618            },
619            "duplicates": {
620                "minTokens": 100,
621                "minLines": 10,
622                "skipLocal": true
623            }
624        }"#;
625        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
626        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
627        assert_eq!(config.rules.unused_files, Severity::Off);
628        assert_eq!(config.rules.unused_exports, Severity::Warn);
629        assert_eq!(config.rules.unused_dependencies, Severity::Error);
630        assert_eq!(config.duplicates.min_tokens, 100);
631        assert_eq!(config.duplicates.min_lines, 10);
632        assert!(config.duplicates.skip_local);
633    }
634
635    // ── extends tests ──────────────────────────────────────────────
636
637    #[test]
638    fn extends_single_base() {
639        let dir = test_dir("extends-single");
640
641        std::fs::write(
642            dir.path().join("base.json"),
643            r#"{"rules": {"unused-files": "warn"}}"#,
644        )
645        .unwrap();
646        std::fs::write(
647            dir.path().join(".fallowrc.json"),
648            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
649        )
650        .unwrap();
651
652        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
653        assert_eq!(config.rules.unused_files, Severity::Warn);
654        assert_eq!(config.entry, vec!["src/index.ts"]);
655        // Unset fields from base still default
656        assert_eq!(config.rules.unused_exports, Severity::Error);
657    }
658
659    #[test]
660    fn extends_overlay_overrides_base() {
661        let dir = test_dir("extends-overlay");
662
663        std::fs::write(
664            dir.path().join("base.json"),
665            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
666        )
667        .unwrap();
668        std::fs::write(
669            dir.path().join(".fallowrc.json"),
670            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
671        )
672        .unwrap();
673
674        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
675        // Overlay overrides base
676        assert_eq!(config.rules.unused_files, Severity::Error);
677        // Base value preserved when not overridden
678        assert_eq!(config.rules.unused_exports, Severity::Off);
679    }
680
681    #[test]
682    fn extends_chained() {
683        let dir = test_dir("extends-chained");
684
685        std::fs::write(
686            dir.path().join("grandparent.json"),
687            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
688        )
689        .unwrap();
690        std::fs::write(
691            dir.path().join("parent.json"),
692            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
693        )
694        .unwrap();
695        std::fs::write(
696            dir.path().join(".fallowrc.json"),
697            r#"{"extends": ["parent.json"]}"#,
698        )
699        .unwrap();
700
701        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
702        // grandparent: off -> parent: warn -> child: inherits warn
703        assert_eq!(config.rules.unused_files, Severity::Warn);
704        // grandparent: warn, not overridden
705        assert_eq!(config.rules.unused_exports, Severity::Warn);
706    }
707
708    #[test]
709    fn extends_circular_detected() {
710        let dir = test_dir("extends-circular");
711
712        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
713        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
714
715        let result = FallowConfig::load(&dir.path().join("a.json"));
716        assert!(result.is_err());
717        let err_msg = format!("{}", result.unwrap_err());
718        assert!(
719            err_msg.contains("Circular extends"),
720            "Expected circular error, got: {err_msg}"
721        );
722    }
723
724    #[test]
725    fn extends_missing_file_errors() {
726        let dir = test_dir("extends-missing");
727
728        std::fs::write(
729            dir.path().join(".fallowrc.json"),
730            r#"{"extends": ["nonexistent.json"]}"#,
731        )
732        .unwrap();
733
734        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
735        assert!(result.is_err());
736        let err_msg = format!("{}", result.unwrap_err());
737        assert!(
738            err_msg.contains("not found"),
739            "Expected not found error, got: {err_msg}"
740        );
741    }
742
743    #[test]
744    fn extends_string_sugar() {
745        let dir = test_dir("extends-string");
746
747        std::fs::write(
748            dir.path().join("base.json"),
749            r#"{"ignorePatterns": ["gen/**"]}"#,
750        )
751        .unwrap();
752        // String form instead of array
753        std::fs::write(
754            dir.path().join(".fallowrc.json"),
755            r#"{"extends": "base.json"}"#,
756        )
757        .unwrap();
758
759        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
760        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
761    }
762
763    #[test]
764    fn extends_deep_merge_preserves_arrays() {
765        let dir = test_dir("extends-array");
766
767        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
768        std::fs::write(
769            dir.path().join(".fallowrc.json"),
770            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
771        )
772        .unwrap();
773
774        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
775        // Arrays are replaced, not merged (overlay replaces base)
776        assert_eq!(config.entry, vec!["src/b.ts"]);
777    }
778
779    // ── deep_merge_json unit tests ───────────────────────────────────
780
781    #[test]
782    fn deep_merge_scalar_overlay_replaces_base() {
783        let mut base = serde_json::json!("hello");
784        deep_merge_json(&mut base, serde_json::json!("world"));
785        assert_eq!(base, serde_json::json!("world"));
786    }
787
788    #[test]
789    fn deep_merge_array_overlay_replaces_base() {
790        let mut base = serde_json::json!(["a", "b"]);
791        deep_merge_json(&mut base, serde_json::json!(["c"]));
792        assert_eq!(base, serde_json::json!(["c"]));
793    }
794
795    #[test]
796    fn deep_merge_nested_object_merge() {
797        let mut base = serde_json::json!({
798            "level1": {
799                "level2": {
800                    "a": 1,
801                    "b": 2
802                }
803            }
804        });
805        let overlay = serde_json::json!({
806            "level1": {
807                "level2": {
808                    "b": 99,
809                    "c": 3
810                }
811            }
812        });
813        deep_merge_json(&mut base, overlay);
814        assert_eq!(base["level1"]["level2"]["a"], 1);
815        assert_eq!(base["level1"]["level2"]["b"], 99);
816        assert_eq!(base["level1"]["level2"]["c"], 3);
817    }
818
819    #[test]
820    fn deep_merge_overlay_adds_new_fields() {
821        let mut base = serde_json::json!({"existing": true});
822        let overlay = serde_json::json!({"new_field": "added", "another": 42});
823        deep_merge_json(&mut base, overlay);
824        assert_eq!(base["existing"], true);
825        assert_eq!(base["new_field"], "added");
826        assert_eq!(base["another"], 42);
827    }
828
829    #[test]
830    fn deep_merge_null_overlay_replaces_object() {
831        let mut base = serde_json::json!({"key": "value"});
832        deep_merge_json(&mut base, serde_json::json!(null));
833        assert_eq!(base, serde_json::json!(null));
834    }
835
836    #[test]
837    fn deep_merge_empty_object_overlay_preserves_base() {
838        let mut base = serde_json::json!({"a": 1, "b": 2});
839        deep_merge_json(&mut base, serde_json::json!({}));
840        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
841    }
842
843    // ── rule severity parsing via JSON config ────────────────────────
844
845    #[test]
846    fn rules_severity_error_warn_off_from_json() {
847        let json_str = r#"{
848            "rules": {
849                "unused-files": "error",
850                "unused-exports": "warn",
851                "unused-types": "off"
852            }
853        }"#;
854        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
855        assert_eq!(config.rules.unused_files, Severity::Error);
856        assert_eq!(config.rules.unused_exports, Severity::Warn);
857        assert_eq!(config.rules.unused_types, Severity::Off);
858    }
859
860    #[test]
861    fn rules_omitted_default_to_error() {
862        let json_str = r#"{
863            "rules": {
864                "unused-files": "warn"
865            }
866        }"#;
867        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
868        assert_eq!(config.rules.unused_files, Severity::Warn);
869        // All other rules default to error
870        assert_eq!(config.rules.unused_exports, Severity::Error);
871        assert_eq!(config.rules.unused_types, Severity::Error);
872        assert_eq!(config.rules.unused_dependencies, Severity::Error);
873        assert_eq!(config.rules.unresolved_imports, Severity::Error);
874        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
875        assert_eq!(config.rules.duplicate_exports, Severity::Error);
876        assert_eq!(config.rules.circular_dependencies, Severity::Error);
877        // type_only_dependencies defaults to warn, not error
878        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
879    }
880}