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