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::rules::{RulesConfig, Severity};
228
229    /// Create a unique temp directory for this test to avoid parallel test races.
230    fn test_dir(name: &str) -> PathBuf {
231        use std::sync::atomic::{AtomicU64, Ordering};
232        static COUNTER: AtomicU64 = AtomicU64::new(0);
233        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
234        let dir = std::env::temp_dir().join(format!("fallow-{name}-{id}"));
235        let _ = std::fs::remove_dir_all(&dir);
236        std::fs::create_dir_all(&dir).unwrap();
237        dir
238    }
239
240    #[test]
241    fn fallow_config_deserialize_minimal() {
242        let toml_str = r#"
243entry = ["src/main.ts"]
244"#;
245        let config: FallowConfig = toml::from_str(toml_str).unwrap();
246        assert_eq!(config.entry, vec!["src/main.ts"]);
247        assert!(config.ignore_patterns.is_empty());
248    }
249
250    #[test]
251    fn fallow_config_deserialize_ignore_exports() {
252        let toml_str = r#"
253[[ignoreExports]]
254file = "src/types/*.ts"
255exports = ["*"]
256
257[[ignoreExports]]
258file = "src/constants.ts"
259exports = ["FOO", "BAR"]
260"#;
261        let config: FallowConfig = toml::from_str(toml_str).unwrap();
262        assert_eq!(config.ignore_exports.len(), 2);
263        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
264        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
265        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
266    }
267
268    #[test]
269    fn fallow_config_deserialize_ignore_dependencies() {
270        let toml_str = r#"
271ignoreDependencies = ["autoprefixer", "postcss"]
272"#;
273        let config: FallowConfig = toml::from_str(toml_str).unwrap();
274        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
275    }
276
277    #[test]
278    fn fallow_config_resolve_default_ignores() {
279        let config = FallowConfig {
280            schema: None,
281            extends: vec![],
282            entry: vec![],
283            ignore_patterns: vec![],
284            framework: vec![],
285            workspaces: None,
286            ignore_dependencies: vec![],
287            ignore_exports: vec![],
288            duplicates: DuplicatesConfig::default(),
289            rules: RulesConfig::default(),
290            production: false,
291            plugins: vec![],
292            overrides: vec![],
293        };
294        let resolved = config.resolve(
295            PathBuf::from("/tmp/test"),
296            OutputFormat::Human,
297            4,
298            true,
299            true,
300        );
301
302        // Default ignores should be compiled
303        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
304        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
305        assert!(resolved.ignore_patterns.is_match("build/output.js"));
306        assert!(resolved.ignore_patterns.is_match(".git/config"));
307        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
308        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
309        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
310    }
311
312    #[test]
313    fn fallow_config_resolve_custom_ignores() {
314        let config = FallowConfig {
315            schema: None,
316            extends: vec![],
317            entry: vec!["src/**/*.ts".to_string()],
318            ignore_patterns: vec!["**/*.generated.ts".to_string()],
319            framework: vec![],
320            workspaces: None,
321            ignore_dependencies: vec![],
322            ignore_exports: vec![],
323            duplicates: DuplicatesConfig::default(),
324            rules: RulesConfig::default(),
325            production: false,
326            plugins: vec![],
327            overrides: vec![],
328        };
329        let resolved = config.resolve(
330            PathBuf::from("/tmp/test"),
331            OutputFormat::Json,
332            4,
333            false,
334            true,
335        );
336
337        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
338        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
339        assert!(matches!(resolved.output, OutputFormat::Json));
340        assert!(!resolved.no_cache);
341    }
342
343    #[test]
344    fn fallow_config_resolve_cache_dir() {
345        let config = FallowConfig {
346            schema: None,
347            extends: vec![],
348            entry: vec![],
349            ignore_patterns: vec![],
350            framework: vec![],
351            workspaces: None,
352            ignore_dependencies: vec![],
353            ignore_exports: vec![],
354            duplicates: DuplicatesConfig::default(),
355            rules: RulesConfig::default(),
356            production: false,
357            plugins: vec![],
358            overrides: vec![],
359        };
360        let resolved = config.resolve(
361            PathBuf::from("/tmp/project"),
362            OutputFormat::Human,
363            4,
364            true,
365            true,
366        );
367        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
368        assert!(resolved.no_cache);
369    }
370
371    #[test]
372    fn package_json_entry_points_main() {
373        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
374        let entries = pkg.entry_points();
375        assert!(entries.contains(&"dist/index.js".to_string()));
376    }
377
378    #[test]
379    fn package_json_entry_points_module() {
380        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
381        let entries = pkg.entry_points();
382        assert!(entries.contains(&"dist/index.mjs".to_string()));
383    }
384
385    #[test]
386    fn package_json_entry_points_types() {
387        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
388        let entries = pkg.entry_points();
389        assert!(entries.contains(&"dist/index.d.ts".to_string()));
390    }
391
392    #[test]
393    fn package_json_entry_points_bin_string() {
394        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
395        let entries = pkg.entry_points();
396        assert!(entries.contains(&"bin/cli.js".to_string()));
397    }
398
399    #[test]
400    fn package_json_entry_points_bin_object() {
401        let pkg: PackageJson =
402            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
403                .unwrap();
404        let entries = pkg.entry_points();
405        assert!(entries.contains(&"bin/cli.js".to_string()));
406        assert!(entries.contains(&"bin/serve.js".to_string()));
407    }
408
409    #[test]
410    fn package_json_entry_points_exports_string() {
411        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
412        let entries = pkg.entry_points();
413        assert!(entries.contains(&"./dist/index.js".to_string()));
414    }
415
416    #[test]
417    fn package_json_entry_points_exports_object() {
418        let pkg: PackageJson = serde_json::from_str(
419            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
420        )
421        .unwrap();
422        let entries = pkg.entry_points();
423        assert!(entries.contains(&"./dist/index.mjs".to_string()));
424        assert!(entries.contains(&"./dist/index.cjs".to_string()));
425    }
426
427    #[test]
428    fn package_json_dependency_names() {
429        let pkg: PackageJson = serde_json::from_str(
430            r#"{
431            "dependencies": {"react": "^18", "lodash": "^4"},
432            "devDependencies": {"typescript": "^5"},
433            "peerDependencies": {"react-dom": "^18"}
434        }"#,
435        )
436        .unwrap();
437
438        let all = pkg.all_dependency_names();
439        assert!(all.contains(&"react".to_string()));
440        assert!(all.contains(&"lodash".to_string()));
441        assert!(all.contains(&"typescript".to_string()));
442        assert!(all.contains(&"react-dom".to_string()));
443
444        let prod = pkg.production_dependency_names();
445        assert!(prod.contains(&"react".to_string()));
446        assert!(!prod.contains(&"typescript".to_string()));
447
448        let dev = pkg.dev_dependency_names();
449        assert!(dev.contains(&"typescript".to_string()));
450        assert!(!dev.contains(&"react".to_string()));
451    }
452
453    #[test]
454    fn package_json_no_dependencies() {
455        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
456        assert!(pkg.all_dependency_names().is_empty());
457        assert!(pkg.production_dependency_names().is_empty());
458        assert!(pkg.dev_dependency_names().is_empty());
459        assert!(pkg.entry_points().is_empty());
460    }
461
462    #[test]
463    fn rules_deserialize_toml_kebab_case() {
464        let toml_str = r#"
465[rules]
466unused-files = "error"
467unused-exports = "warn"
468unused-types = "off"
469"#;
470        let config: FallowConfig = toml::from_str(toml_str).unwrap();
471        assert_eq!(config.rules.unused_files, Severity::Error);
472        assert_eq!(config.rules.unused_exports, Severity::Warn);
473        assert_eq!(config.rules.unused_types, Severity::Off);
474        // Unset fields default to error
475        assert_eq!(config.rules.unresolved_imports, Severity::Error);
476    }
477
478    #[test]
479    fn config_without_rules_defaults_to_error() {
480        let toml_str = r#"
481entry = ["src/main.ts"]
482"#;
483        let config: FallowConfig = toml::from_str(toml_str).unwrap();
484        assert_eq!(config.rules.unused_files, Severity::Error);
485        assert_eq!(config.rules.unused_exports, Severity::Error);
486    }
487
488    #[test]
489    fn fallow_config_denies_unknown_fields() {
490        let toml_str = r#"
491unknown_field = true
492"#;
493        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn fallow_config_deserialize_json() {
499        let json_str = r#"{"entry": ["src/main.ts"]}"#;
500        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
501        assert_eq!(config.entry, vec!["src/main.ts"]);
502    }
503
504    #[test]
505    fn fallow_config_deserialize_jsonc() {
506        let jsonc_str = r#"{
507            // This is a comment
508            "entry": ["src/main.ts"],
509            "rules": {
510                "unused-files": "warn"
511            }
512        }"#;
513        let mut stripped = String::new();
514        json_comments::StripComments::new(jsonc_str.as_bytes())
515            .read_to_string(&mut stripped)
516            .unwrap();
517        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
518        assert_eq!(config.entry, vec!["src/main.ts"]);
519        assert_eq!(config.rules.unused_files, Severity::Warn);
520    }
521
522    #[test]
523    fn fallow_config_json_with_schema_field() {
524        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "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_json_schema_generation() {
531        let schema = FallowConfig::json_schema();
532        assert!(schema.is_object());
533        let obj = schema.as_object().unwrap();
534        assert!(obj.contains_key("properties"));
535    }
536
537    #[test]
538    fn config_format_detection() {
539        assert!(matches!(
540            ConfigFormat::from_path(Path::new("fallow.toml")),
541            ConfigFormat::Toml
542        ));
543        assert!(matches!(
544            ConfigFormat::from_path(Path::new(".fallowrc.json")),
545            ConfigFormat::Json
546        ));
547        assert!(matches!(
548            ConfigFormat::from_path(Path::new(".fallow.toml")),
549            ConfigFormat::Toml
550        ));
551    }
552
553    #[test]
554    fn config_names_priority_order() {
555        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
556        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
557        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
558    }
559
560    #[test]
561    fn load_json_config_file() {
562        let dir = test_dir("json-config");
563        let config_path = dir.join(".fallowrc.json");
564        std::fs::write(
565            &config_path,
566            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
567        )
568        .unwrap();
569
570        let config = FallowConfig::load(&config_path).unwrap();
571        assert_eq!(config.entry, vec!["src/index.ts"]);
572        assert_eq!(config.rules.unused_exports, Severity::Warn);
573
574        let _ = std::fs::remove_dir_all(&dir);
575    }
576
577    #[test]
578    fn load_jsonc_config_file() {
579        let dir = test_dir("jsonc-config");
580        let config_path = dir.join(".fallowrc.json");
581        std::fs::write(
582            &config_path,
583            r#"{
584                // Entry points for analysis
585                "entry": ["src/index.ts"],
586                /* Block comment */
587                "rules": {
588                    "unused-exports": "warn"
589                }
590            }"#,
591        )
592        .unwrap();
593
594        let config = FallowConfig::load(&config_path).unwrap();
595        assert_eq!(config.entry, vec!["src/index.ts"]);
596        assert_eq!(config.rules.unused_exports, Severity::Warn);
597
598        let _ = std::fs::remove_dir_all(&dir);
599    }
600
601    #[test]
602    fn json_config_ignore_dependencies_camel_case() {
603        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
604        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
605        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
606    }
607
608    #[test]
609    fn json_config_all_fields() {
610        let json_str = r#"{
611            "ignoreDependencies": ["lodash"],
612            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
613            "rules": {
614                "unused-files": "off",
615                "unused-exports": "warn",
616                "unused-dependencies": "error",
617                "unused-dev-dependencies": "off",
618                "unused-types": "warn",
619                "unused-enum-members": "error",
620                "unused-class-members": "off",
621                "unresolved-imports": "warn",
622                "unlisted-dependencies": "error",
623                "duplicate-exports": "off"
624            },
625            "duplicates": {
626                "minTokens": 100,
627                "minLines": 10,
628                "skipLocal": true
629            }
630        }"#;
631        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
632        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
633        assert_eq!(config.rules.unused_files, Severity::Off);
634        assert_eq!(config.rules.unused_exports, Severity::Warn);
635        assert_eq!(config.rules.unused_dependencies, Severity::Error);
636        assert_eq!(config.duplicates.min_tokens, 100);
637        assert_eq!(config.duplicates.min_lines, 10);
638        assert!(config.duplicates.skip_local);
639    }
640
641    // ── extends tests ──────────────────────────────────────────────
642
643    #[test]
644    fn extends_single_base() {
645        let dir = test_dir("extends-single");
646
647        std::fs::write(
648            dir.join("base.json"),
649            r#"{"rules": {"unused-files": "warn"}}"#,
650        )
651        .unwrap();
652        std::fs::write(
653            dir.join(".fallowrc.json"),
654            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
655        )
656        .unwrap();
657
658        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
659        assert_eq!(config.rules.unused_files, Severity::Warn);
660        assert_eq!(config.entry, vec!["src/index.ts"]);
661        // Unset fields from base still default
662        assert_eq!(config.rules.unused_exports, Severity::Error);
663
664        let _ = std::fs::remove_dir_all(&dir);
665    }
666
667    #[test]
668    fn extends_overlay_overrides_base() {
669        let dir = test_dir("extends-overlay");
670
671        std::fs::write(
672            dir.join("base.json"),
673            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
674        )
675        .unwrap();
676        std::fs::write(
677            dir.join(".fallowrc.json"),
678            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
679        )
680        .unwrap();
681
682        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
683        // Overlay overrides base
684        assert_eq!(config.rules.unused_files, Severity::Error);
685        // Base value preserved when not overridden
686        assert_eq!(config.rules.unused_exports, Severity::Off);
687
688        let _ = std::fs::remove_dir_all(&dir);
689    }
690
691    #[test]
692    fn extends_chained() {
693        let dir = test_dir("extends-chained");
694
695        std::fs::write(
696            dir.join("grandparent.json"),
697            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
698        )
699        .unwrap();
700        std::fs::write(
701            dir.join("parent.json"),
702            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
703        )
704        .unwrap();
705        std::fs::write(
706            dir.join(".fallowrc.json"),
707            r#"{"extends": ["parent.json"]}"#,
708        )
709        .unwrap();
710
711        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
712        // grandparent: off -> parent: warn -> child: inherits warn
713        assert_eq!(config.rules.unused_files, Severity::Warn);
714        // grandparent: warn, not overridden
715        assert_eq!(config.rules.unused_exports, Severity::Warn);
716
717        let _ = std::fs::remove_dir_all(&dir);
718    }
719
720    #[test]
721    fn extends_circular_detected() {
722        let dir = test_dir("extends-circular");
723
724        std::fs::write(dir.join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
725        std::fs::write(dir.join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
726
727        let result = FallowConfig::load(&dir.join("a.json"));
728        assert!(result.is_err());
729        let err_msg = format!("{}", result.unwrap_err());
730        assert!(
731            err_msg.contains("Circular extends"),
732            "Expected circular error, got: {err_msg}"
733        );
734
735        let _ = std::fs::remove_dir_all(&dir);
736    }
737
738    #[test]
739    fn extends_missing_file_errors() {
740        let dir = test_dir("extends-missing");
741
742        std::fs::write(
743            dir.join(".fallowrc.json"),
744            r#"{"extends": ["nonexistent.json"]}"#,
745        )
746        .unwrap();
747
748        let result = FallowConfig::load(&dir.join(".fallowrc.json"));
749        assert!(result.is_err());
750        let err_msg = format!("{}", result.unwrap_err());
751        assert!(
752            err_msg.contains("not found"),
753            "Expected not found error, got: {err_msg}"
754        );
755
756        let _ = std::fs::remove_dir_all(&dir);
757    }
758
759    #[test]
760    fn extends_string_sugar() {
761        let dir = test_dir("extends-string");
762
763        std::fs::write(dir.join("base.json"), r#"{"ignorePatterns": ["gen/**"]}"#).unwrap();
764        // String form instead of array
765        std::fs::write(dir.join(".fallowrc.json"), r#"{"extends": "base.json"}"#).unwrap();
766
767        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
768        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
769
770        let _ = std::fs::remove_dir_all(&dir);
771    }
772
773    #[test]
774    fn extends_deep_merge_preserves_arrays() {
775        let dir = test_dir("extends-array");
776
777        std::fs::write(dir.join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
778        std::fs::write(
779            dir.join(".fallowrc.json"),
780            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
781        )
782        .unwrap();
783
784        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
785        // Arrays are replaced, not merged (overlay replaces base)
786        assert_eq!(config.entry, vec!["src/b.ts"]);
787
788        let _ = std::fs::remove_dir_all(&dir);
789    }
790}