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 and load config from the current directory or ancestors.
182    ///
183    /// Checks for config files in priority order:
184    /// `.fallowrc.json` > `fallow.toml` > `.fallow.toml`
185    ///
186    /// Stops searching at the first directory containing `.git` or `package.json`,
187    /// to avoid picking up unrelated config files above the project root.
188    ///
189    /// Returns `Ok(Some(...))` if a config was found and parsed, `Ok(None)` if
190    /// no config file exists, and `Err(...)` if a config file exists but fails to parse.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error string when a discovered config file exists but fails to load.
195    /// Find the config file path without loading it.
196    /// Searches the same locations as `find_and_load`.
197    pub fn find_config_path(start: &Path) -> Option<PathBuf> {
198        let mut dir = start;
199        loop {
200            for name in CONFIG_NAMES {
201                let candidate = dir.join(name);
202                if candidate.exists() {
203                    return Some(candidate);
204                }
205            }
206            if dir.join(".git").exists() || dir.join("package.json").exists() {
207                break;
208            }
209            dir = dir.parent()?;
210        }
211        None
212    }
213
214    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
215        let mut dir = start;
216        loop {
217            for name in CONFIG_NAMES {
218                let candidate = dir.join(name);
219                if candidate.exists() {
220                    match Self::load(&candidate) {
221                        Ok(config) => return Ok(Some((config, candidate))),
222                        Err(e) => {
223                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
224                        }
225                    }
226                }
227            }
228            // Stop at project root indicators
229            if dir.join(".git").exists() || dir.join("package.json").exists() {
230                break;
231            }
232            dir = match dir.parent() {
233                Some(parent) => parent,
234                None => break,
235            };
236        }
237        Ok(None)
238    }
239
240    /// Generate JSON Schema for the configuration format.
241    #[must_use]
242    pub fn json_schema() -> serde_json::Value {
243        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::io::Read as _;
250
251    use super::*;
252    use crate::PackageJson;
253    use crate::config::duplicates_config::DuplicatesConfig;
254    use crate::config::format::OutputFormat;
255    use crate::config::health::HealthConfig;
256    use crate::config::rules::{RulesConfig, Severity};
257
258    /// Create a panic-safe temp directory (RAII cleanup via `tempfile::TempDir`).
259    fn test_dir(_name: &str) -> tempfile::TempDir {
260        tempfile::tempdir().expect("create temp dir")
261    }
262
263    #[test]
264    fn fallow_config_deserialize_minimal() {
265        let toml_str = r#"
266entry = ["src/main.ts"]
267"#;
268        let config: FallowConfig = toml::from_str(toml_str).unwrap();
269        assert_eq!(config.entry, vec!["src/main.ts"]);
270        assert!(config.ignore_patterns.is_empty());
271    }
272
273    #[test]
274    fn fallow_config_deserialize_ignore_exports() {
275        let toml_str = r#"
276[[ignoreExports]]
277file = "src/types/*.ts"
278exports = ["*"]
279
280[[ignoreExports]]
281file = "src/constants.ts"
282exports = ["FOO", "BAR"]
283"#;
284        let config: FallowConfig = toml::from_str(toml_str).unwrap();
285        assert_eq!(config.ignore_exports.len(), 2);
286        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
287        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
288        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
289    }
290
291    #[test]
292    fn fallow_config_deserialize_ignore_dependencies() {
293        let toml_str = r#"
294ignoreDependencies = ["autoprefixer", "postcss"]
295"#;
296        let config: FallowConfig = toml::from_str(toml_str).unwrap();
297        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
298    }
299
300    #[test]
301    fn fallow_config_resolve_default_ignores() {
302        let config = FallowConfig {
303            schema: None,
304            extends: vec![],
305            entry: vec![],
306            ignore_patterns: vec![],
307            framework: vec![],
308            workspaces: None,
309            ignore_dependencies: vec![],
310            ignore_exports: vec![],
311            duplicates: DuplicatesConfig::default(),
312            health: HealthConfig::default(),
313            rules: RulesConfig::default(),
314            production: false,
315            plugins: vec![],
316            overrides: vec![],
317            regression: None,
318        };
319        let resolved = config.resolve(
320            PathBuf::from("/tmp/test"),
321            OutputFormat::Human,
322            4,
323            true,
324            true,
325        );
326
327        // Default ignores should be compiled
328        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
329        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
330        assert!(resolved.ignore_patterns.is_match("build/output.js"));
331        assert!(resolved.ignore_patterns.is_match(".git/config"));
332        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
333        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
334        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
335    }
336
337    #[test]
338    fn fallow_config_resolve_custom_ignores() {
339        let config = FallowConfig {
340            schema: None,
341            extends: vec![],
342            entry: vec!["src/**/*.ts".to_string()],
343            ignore_patterns: vec!["**/*.generated.ts".to_string()],
344            framework: vec![],
345            workspaces: None,
346            ignore_dependencies: vec![],
347            ignore_exports: vec![],
348            duplicates: DuplicatesConfig::default(),
349            health: HealthConfig::default(),
350            rules: RulesConfig::default(),
351            production: false,
352            plugins: vec![],
353            overrides: vec![],
354            regression: None,
355        };
356        let resolved = config.resolve(
357            PathBuf::from("/tmp/test"),
358            OutputFormat::Json,
359            4,
360            false,
361            true,
362        );
363
364        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
365        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
366        assert!(matches!(resolved.output, OutputFormat::Json));
367        assert!(!resolved.no_cache);
368    }
369
370    #[test]
371    fn fallow_config_resolve_cache_dir() {
372        let config = FallowConfig {
373            schema: None,
374            extends: vec![],
375            entry: vec![],
376            ignore_patterns: vec![],
377            framework: vec![],
378            workspaces: None,
379            ignore_dependencies: vec![],
380            ignore_exports: vec![],
381            duplicates: DuplicatesConfig::default(),
382            health: HealthConfig::default(),
383            rules: RulesConfig::default(),
384            production: false,
385            plugins: vec![],
386            overrides: vec![],
387            regression: None,
388        };
389        let resolved = config.resolve(
390            PathBuf::from("/tmp/project"),
391            OutputFormat::Human,
392            4,
393            true,
394            true,
395        );
396        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
397        assert!(resolved.no_cache);
398    }
399
400    #[test]
401    fn package_json_entry_points_main() {
402        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
403        let entries = pkg.entry_points();
404        assert!(entries.contains(&"dist/index.js".to_string()));
405    }
406
407    #[test]
408    fn package_json_entry_points_module() {
409        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
410        let entries = pkg.entry_points();
411        assert!(entries.contains(&"dist/index.mjs".to_string()));
412    }
413
414    #[test]
415    fn package_json_entry_points_types() {
416        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
417        let entries = pkg.entry_points();
418        assert!(entries.contains(&"dist/index.d.ts".to_string()));
419    }
420
421    #[test]
422    fn package_json_entry_points_bin_string() {
423        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
424        let entries = pkg.entry_points();
425        assert!(entries.contains(&"bin/cli.js".to_string()));
426    }
427
428    #[test]
429    fn package_json_entry_points_bin_object() {
430        let pkg: PackageJson =
431            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
432                .unwrap();
433        let entries = pkg.entry_points();
434        assert!(entries.contains(&"bin/cli.js".to_string()));
435        assert!(entries.contains(&"bin/serve.js".to_string()));
436    }
437
438    #[test]
439    fn package_json_entry_points_exports_string() {
440        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
441        let entries = pkg.entry_points();
442        assert!(entries.contains(&"./dist/index.js".to_string()));
443    }
444
445    #[test]
446    fn package_json_entry_points_exports_object() {
447        let pkg: PackageJson = serde_json::from_str(
448            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
449        )
450        .unwrap();
451        let entries = pkg.entry_points();
452        assert!(entries.contains(&"./dist/index.mjs".to_string()));
453        assert!(entries.contains(&"./dist/index.cjs".to_string()));
454    }
455
456    #[test]
457    fn package_json_dependency_names() {
458        let pkg: PackageJson = serde_json::from_str(
459            r#"{
460            "dependencies": {"react": "^18", "lodash": "^4"},
461            "devDependencies": {"typescript": "^5"},
462            "peerDependencies": {"react-dom": "^18"}
463        }"#,
464        )
465        .unwrap();
466
467        let all = pkg.all_dependency_names();
468        assert!(all.contains(&"react".to_string()));
469        assert!(all.contains(&"lodash".to_string()));
470        assert!(all.contains(&"typescript".to_string()));
471        assert!(all.contains(&"react-dom".to_string()));
472
473        let prod = pkg.production_dependency_names();
474        assert!(prod.contains(&"react".to_string()));
475        assert!(!prod.contains(&"typescript".to_string()));
476
477        let dev = pkg.dev_dependency_names();
478        assert!(dev.contains(&"typescript".to_string()));
479        assert!(!dev.contains(&"react".to_string()));
480    }
481
482    #[test]
483    fn package_json_no_dependencies() {
484        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
485        assert!(pkg.all_dependency_names().is_empty());
486        assert!(pkg.production_dependency_names().is_empty());
487        assert!(pkg.dev_dependency_names().is_empty());
488        assert!(pkg.entry_points().is_empty());
489    }
490
491    #[test]
492    fn rules_deserialize_toml_kebab_case() {
493        let toml_str = r#"
494[rules]
495unused-files = "error"
496unused-exports = "warn"
497unused-types = "off"
498"#;
499        let config: FallowConfig = toml::from_str(toml_str).unwrap();
500        assert_eq!(config.rules.unused_files, Severity::Error);
501        assert_eq!(config.rules.unused_exports, Severity::Warn);
502        assert_eq!(config.rules.unused_types, Severity::Off);
503        // Unset fields default to error
504        assert_eq!(config.rules.unresolved_imports, Severity::Error);
505    }
506
507    #[test]
508    fn config_without_rules_defaults_to_error() {
509        let toml_str = r#"
510entry = ["src/main.ts"]
511"#;
512        let config: FallowConfig = toml::from_str(toml_str).unwrap();
513        assert_eq!(config.rules.unused_files, Severity::Error);
514        assert_eq!(config.rules.unused_exports, Severity::Error);
515    }
516
517    #[test]
518    fn fallow_config_denies_unknown_fields() {
519        let toml_str = r"
520unknown_field = true
521";
522        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn fallow_config_deserialize_json() {
528        let json_str = r#"{"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_deserialize_jsonc() {
535        let jsonc_str = r#"{
536            // This is a comment
537            "entry": ["src/main.ts"],
538            "rules": {
539                "unused-files": "warn"
540            }
541        }"#;
542        let mut stripped = String::new();
543        json_comments::StripComments::new(jsonc_str.as_bytes())
544            .read_to_string(&mut stripped)
545            .unwrap();
546        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
547        assert_eq!(config.entry, vec!["src/main.ts"]);
548        assert_eq!(config.rules.unused_files, Severity::Warn);
549    }
550
551    #[test]
552    fn fallow_config_json_with_schema_field() {
553        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
554        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
555        assert_eq!(config.entry, vec!["src/main.ts"]);
556    }
557
558    #[test]
559    fn fallow_config_json_schema_generation() {
560        let schema = FallowConfig::json_schema();
561        assert!(schema.is_object());
562        let obj = schema.as_object().unwrap();
563        assert!(obj.contains_key("properties"));
564    }
565
566    #[test]
567    fn config_format_detection() {
568        assert!(matches!(
569            ConfigFormat::from_path(Path::new("fallow.toml")),
570            ConfigFormat::Toml
571        ));
572        assert!(matches!(
573            ConfigFormat::from_path(Path::new(".fallowrc.json")),
574            ConfigFormat::Json
575        ));
576        assert!(matches!(
577            ConfigFormat::from_path(Path::new(".fallow.toml")),
578            ConfigFormat::Toml
579        ));
580    }
581
582    #[test]
583    fn config_names_priority_order() {
584        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
585        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
586        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
587    }
588
589    #[test]
590    fn load_json_config_file() {
591        let dir = test_dir("json-config");
592        let config_path = dir.path().join(".fallowrc.json");
593        std::fs::write(
594            &config_path,
595            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
596        )
597        .unwrap();
598
599        let config = FallowConfig::load(&config_path).unwrap();
600        assert_eq!(config.entry, vec!["src/index.ts"]);
601        assert_eq!(config.rules.unused_exports, Severity::Warn);
602    }
603
604    #[test]
605    fn load_jsonc_config_file() {
606        let dir = test_dir("jsonc-config");
607        let config_path = dir.path().join(".fallowrc.json");
608        std::fs::write(
609            &config_path,
610            r#"{
611                // Entry points for analysis
612                "entry": ["src/index.ts"],
613                /* Block comment */
614                "rules": {
615                    "unused-exports": "warn"
616                }
617            }"#,
618        )
619        .unwrap();
620
621        let config = FallowConfig::load(&config_path).unwrap();
622        assert_eq!(config.entry, vec!["src/index.ts"]);
623        assert_eq!(config.rules.unused_exports, Severity::Warn);
624    }
625
626    #[test]
627    fn json_config_ignore_dependencies_camel_case() {
628        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
629        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
630        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
631    }
632
633    #[test]
634    fn json_config_all_fields() {
635        let json_str = r#"{
636            "ignoreDependencies": ["lodash"],
637            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
638            "rules": {
639                "unused-files": "off",
640                "unused-exports": "warn",
641                "unused-dependencies": "error",
642                "unused-dev-dependencies": "off",
643                "unused-types": "warn",
644                "unused-enum-members": "error",
645                "unused-class-members": "off",
646                "unresolved-imports": "warn",
647                "unlisted-dependencies": "error",
648                "duplicate-exports": "off"
649            },
650            "duplicates": {
651                "minTokens": 100,
652                "minLines": 10,
653                "skipLocal": true
654            }
655        }"#;
656        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
657        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
658        assert_eq!(config.rules.unused_files, Severity::Off);
659        assert_eq!(config.rules.unused_exports, Severity::Warn);
660        assert_eq!(config.rules.unused_dependencies, Severity::Error);
661        assert_eq!(config.duplicates.min_tokens, 100);
662        assert_eq!(config.duplicates.min_lines, 10);
663        assert!(config.duplicates.skip_local);
664    }
665
666    // ── extends tests ──────────────────────────────────────────────
667
668    #[test]
669    fn extends_single_base() {
670        let dir = test_dir("extends-single");
671
672        std::fs::write(
673            dir.path().join("base.json"),
674            r#"{"rules": {"unused-files": "warn"}}"#,
675        )
676        .unwrap();
677        std::fs::write(
678            dir.path().join(".fallowrc.json"),
679            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
680        )
681        .unwrap();
682
683        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
684        assert_eq!(config.rules.unused_files, Severity::Warn);
685        assert_eq!(config.entry, vec!["src/index.ts"]);
686        // Unset fields from base still default
687        assert_eq!(config.rules.unused_exports, Severity::Error);
688    }
689
690    #[test]
691    fn extends_overlay_overrides_base() {
692        let dir = test_dir("extends-overlay");
693
694        std::fs::write(
695            dir.path().join("base.json"),
696            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
697        )
698        .unwrap();
699        std::fs::write(
700            dir.path().join(".fallowrc.json"),
701            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
702        )
703        .unwrap();
704
705        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
706        // Overlay overrides base
707        assert_eq!(config.rules.unused_files, Severity::Error);
708        // Base value preserved when not overridden
709        assert_eq!(config.rules.unused_exports, Severity::Off);
710    }
711
712    #[test]
713    fn extends_chained() {
714        let dir = test_dir("extends-chained");
715
716        std::fs::write(
717            dir.path().join("grandparent.json"),
718            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
719        )
720        .unwrap();
721        std::fs::write(
722            dir.path().join("parent.json"),
723            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
724        )
725        .unwrap();
726        std::fs::write(
727            dir.path().join(".fallowrc.json"),
728            r#"{"extends": ["parent.json"]}"#,
729        )
730        .unwrap();
731
732        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
733        // grandparent: off -> parent: warn -> child: inherits warn
734        assert_eq!(config.rules.unused_files, Severity::Warn);
735        // grandparent: warn, not overridden
736        assert_eq!(config.rules.unused_exports, Severity::Warn);
737    }
738
739    #[test]
740    fn extends_circular_detected() {
741        let dir = test_dir("extends-circular");
742
743        std::fs::write(dir.path().join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
744        std::fs::write(dir.path().join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
745
746        let result = FallowConfig::load(&dir.path().join("a.json"));
747        assert!(result.is_err());
748        let err_msg = format!("{}", result.unwrap_err());
749        assert!(
750            err_msg.contains("Circular extends"),
751            "Expected circular error, got: {err_msg}"
752        );
753    }
754
755    #[test]
756    fn extends_missing_file_errors() {
757        let dir = test_dir("extends-missing");
758
759        std::fs::write(
760            dir.path().join(".fallowrc.json"),
761            r#"{"extends": ["nonexistent.json"]}"#,
762        )
763        .unwrap();
764
765        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
766        assert!(result.is_err());
767        let err_msg = format!("{}", result.unwrap_err());
768        assert!(
769            err_msg.contains("not found"),
770            "Expected not found error, got: {err_msg}"
771        );
772    }
773
774    #[test]
775    fn extends_string_sugar() {
776        let dir = test_dir("extends-string");
777
778        std::fs::write(
779            dir.path().join("base.json"),
780            r#"{"ignorePatterns": ["gen/**"]}"#,
781        )
782        .unwrap();
783        // String form instead of array
784        std::fs::write(
785            dir.path().join(".fallowrc.json"),
786            r#"{"extends": "base.json"}"#,
787        )
788        .unwrap();
789
790        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
791        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
792    }
793
794    #[test]
795    fn extends_deep_merge_preserves_arrays() {
796        let dir = test_dir("extends-array");
797
798        std::fs::write(dir.path().join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
799        std::fs::write(
800            dir.path().join(".fallowrc.json"),
801            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
802        )
803        .unwrap();
804
805        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
806        // Arrays are replaced, not merged (overlay replaces base)
807        assert_eq!(config.entry, vec!["src/b.ts"]);
808    }
809
810    // ── deep_merge_json unit tests ───────────────────────────────────
811
812    #[test]
813    fn deep_merge_scalar_overlay_replaces_base() {
814        let mut base = serde_json::json!("hello");
815        deep_merge_json(&mut base, serde_json::json!("world"));
816        assert_eq!(base, serde_json::json!("world"));
817    }
818
819    #[test]
820    fn deep_merge_array_overlay_replaces_base() {
821        let mut base = serde_json::json!(["a", "b"]);
822        deep_merge_json(&mut base, serde_json::json!(["c"]));
823        assert_eq!(base, serde_json::json!(["c"]));
824    }
825
826    #[test]
827    fn deep_merge_nested_object_merge() {
828        let mut base = serde_json::json!({
829            "level1": {
830                "level2": {
831                    "a": 1,
832                    "b": 2
833                }
834            }
835        });
836        let overlay = serde_json::json!({
837            "level1": {
838                "level2": {
839                    "b": 99,
840                    "c": 3
841                }
842            }
843        });
844        deep_merge_json(&mut base, overlay);
845        assert_eq!(base["level1"]["level2"]["a"], 1);
846        assert_eq!(base["level1"]["level2"]["b"], 99);
847        assert_eq!(base["level1"]["level2"]["c"], 3);
848    }
849
850    #[test]
851    fn deep_merge_overlay_adds_new_fields() {
852        let mut base = serde_json::json!({"existing": true});
853        let overlay = serde_json::json!({"new_field": "added", "another": 42});
854        deep_merge_json(&mut base, overlay);
855        assert_eq!(base["existing"], true);
856        assert_eq!(base["new_field"], "added");
857        assert_eq!(base["another"], 42);
858    }
859
860    #[test]
861    fn deep_merge_null_overlay_replaces_object() {
862        let mut base = serde_json::json!({"key": "value"});
863        deep_merge_json(&mut base, serde_json::json!(null));
864        assert_eq!(base, serde_json::json!(null));
865    }
866
867    #[test]
868    fn deep_merge_empty_object_overlay_preserves_base() {
869        let mut base = serde_json::json!({"a": 1, "b": 2});
870        deep_merge_json(&mut base, serde_json::json!({}));
871        assert_eq!(base, serde_json::json!({"a": 1, "b": 2}));
872    }
873
874    // ── rule severity parsing via JSON config ────────────────────────
875
876    #[test]
877    fn rules_severity_error_warn_off_from_json() {
878        let json_str = r#"{
879            "rules": {
880                "unused-files": "error",
881                "unused-exports": "warn",
882                "unused-types": "off"
883            }
884        }"#;
885        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
886        assert_eq!(config.rules.unused_files, Severity::Error);
887        assert_eq!(config.rules.unused_exports, Severity::Warn);
888        assert_eq!(config.rules.unused_types, Severity::Off);
889    }
890
891    #[test]
892    fn rules_omitted_default_to_error() {
893        let json_str = r#"{
894            "rules": {
895                "unused-files": "warn"
896            }
897        }"#;
898        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
899        assert_eq!(config.rules.unused_files, Severity::Warn);
900        // All other rules default to error
901        assert_eq!(config.rules.unused_exports, Severity::Error);
902        assert_eq!(config.rules.unused_types, Severity::Error);
903        assert_eq!(config.rules.unused_dependencies, Severity::Error);
904        assert_eq!(config.rules.unresolved_imports, Severity::Error);
905        assert_eq!(config.rules.unlisted_dependencies, Severity::Error);
906        assert_eq!(config.rules.duplicate_exports, Severity::Error);
907        assert_eq!(config.rules.circular_dependencies, Severity::Error);
908        // type_only_dependencies defaults to warn, not error
909        assert_eq!(config.rules.type_only_dependencies, Severity::Warn);
910    }
911
912    // ── find_and_load tests ───────────────────────────────────────
913
914    #[test]
915    fn find_and_load_returns_none_when_no_config() {
916        let dir = test_dir("find-none");
917        // Create a .git dir so it stops searching
918        std::fs::create_dir(dir.path().join(".git")).unwrap();
919
920        let result = FallowConfig::find_and_load(dir.path()).unwrap();
921        assert!(result.is_none());
922    }
923
924    #[test]
925    fn find_and_load_finds_fallowrc_json() {
926        let dir = test_dir("find-json");
927        std::fs::create_dir(dir.path().join(".git")).unwrap();
928        std::fs::write(
929            dir.path().join(".fallowrc.json"),
930            r#"{"entry": ["src/main.ts"]}"#,
931        )
932        .unwrap();
933
934        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
935        assert_eq!(config.entry, vec!["src/main.ts"]);
936        assert!(path.ends_with(".fallowrc.json"));
937    }
938
939    #[test]
940    fn find_and_load_prefers_fallowrc_json_over_toml() {
941        let dir = test_dir("find-priority");
942        std::fs::create_dir(dir.path().join(".git")).unwrap();
943        std::fs::write(
944            dir.path().join(".fallowrc.json"),
945            r#"{"entry": ["from-json.ts"]}"#,
946        )
947        .unwrap();
948        std::fs::write(
949            dir.path().join("fallow.toml"),
950            "entry = [\"from-toml.ts\"]\n",
951        )
952        .unwrap();
953
954        let (config, path) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
955        assert_eq!(config.entry, vec!["from-json.ts"]);
956        assert!(path.ends_with(".fallowrc.json"));
957    }
958
959    #[test]
960    fn find_and_load_finds_fallow_toml() {
961        let dir = test_dir("find-toml");
962        std::fs::create_dir(dir.path().join(".git")).unwrap();
963        std::fs::write(
964            dir.path().join("fallow.toml"),
965            "entry = [\"src/index.ts\"]\n",
966        )
967        .unwrap();
968
969        let (config, _) = FallowConfig::find_and_load(dir.path()).unwrap().unwrap();
970        assert_eq!(config.entry, vec!["src/index.ts"]);
971    }
972
973    #[test]
974    fn find_and_load_stops_at_git_dir() {
975        let dir = test_dir("find-git-stop");
976        let sub = dir.path().join("sub");
977        std::fs::create_dir(&sub).unwrap();
978        // .git marker in root stops search
979        std::fs::create_dir(dir.path().join(".git")).unwrap();
980        // Config file above .git should not be found from sub
981        // (sub has no .git or package.json, so it keeps searching up to parent)
982        // But parent has .git, so it stops there without finding config
983        let result = FallowConfig::find_and_load(&sub).unwrap();
984        assert!(result.is_none());
985    }
986
987    #[test]
988    fn find_and_load_stops_at_package_json() {
989        let dir = test_dir("find-pkg-stop");
990        std::fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
991
992        let result = FallowConfig::find_and_load(dir.path()).unwrap();
993        assert!(result.is_none());
994    }
995
996    #[test]
997    fn find_and_load_returns_error_for_invalid_config() {
998        let dir = test_dir("find-invalid");
999        std::fs::create_dir(dir.path().join(".git")).unwrap();
1000        std::fs::write(
1001            dir.path().join(".fallowrc.json"),
1002            r"{ this is not valid json }",
1003        )
1004        .unwrap();
1005
1006        let result = FallowConfig::find_and_load(dir.path());
1007        assert!(result.is_err());
1008    }
1009
1010    // ── load TOML config file ────────────────────────────────────
1011
1012    #[test]
1013    fn load_toml_config_file() {
1014        let dir = test_dir("toml-config");
1015        let config_path = dir.path().join("fallow.toml");
1016        std::fs::write(
1017            &config_path,
1018            r#"
1019entry = ["src/index.ts"]
1020ignorePatterns = ["dist/**"]
1021
1022[rules]
1023unused-files = "warn"
1024
1025[duplicates]
1026minTokens = 100
1027"#,
1028        )
1029        .unwrap();
1030
1031        let config = FallowConfig::load(&config_path).unwrap();
1032        assert_eq!(config.entry, vec!["src/index.ts"]);
1033        assert_eq!(config.ignore_patterns, vec!["dist/**"]);
1034        assert_eq!(config.rules.unused_files, Severity::Warn);
1035        assert_eq!(config.duplicates.min_tokens, 100);
1036    }
1037
1038    // ── extends absolute path rejection ──────────────────────────
1039
1040    #[test]
1041    fn extends_absolute_path_rejected() {
1042        let dir = test_dir("extends-absolute");
1043
1044        // Use a platform-appropriate absolute path
1045        #[cfg(unix)]
1046        let abs_path = "/absolute/path/config.json";
1047        #[cfg(windows)]
1048        let abs_path = "C:\\absolute\\path\\config.json";
1049
1050        let json = format!(r#"{{"extends": ["{}"]}}"#, abs_path.replace('\\', "\\\\"));
1051        std::fs::write(dir.path().join(".fallowrc.json"), json).unwrap();
1052
1053        let result = FallowConfig::load(&dir.path().join(".fallowrc.json"));
1054        assert!(result.is_err());
1055        let err_msg = format!("{}", result.unwrap_err());
1056        assert!(
1057            err_msg.contains("must be relative"),
1058            "Expected 'must be relative' error, got: {err_msg}"
1059        );
1060    }
1061
1062    // ── resolve production mode ─────────────────────────────────
1063
1064    #[test]
1065    fn resolve_production_mode_disables_dev_deps() {
1066        let config = FallowConfig {
1067            schema: None,
1068            extends: vec![],
1069            entry: vec![],
1070            ignore_patterns: vec![],
1071            framework: vec![],
1072            workspaces: None,
1073            ignore_dependencies: vec![],
1074            ignore_exports: vec![],
1075            duplicates: DuplicatesConfig::default(),
1076            health: HealthConfig::default(),
1077            rules: RulesConfig::default(),
1078            production: true,
1079            plugins: vec![],
1080            overrides: vec![],
1081            regression: None,
1082        };
1083        let resolved = config.resolve(
1084            PathBuf::from("/tmp/test"),
1085            OutputFormat::Human,
1086            4,
1087            false,
1088            true,
1089        );
1090        assert!(resolved.production);
1091        assert_eq!(resolved.rules.unused_dev_dependencies, Severity::Off);
1092        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Off);
1093        // Other rules should remain at default (Error)
1094        assert_eq!(resolved.rules.unused_files, Severity::Error);
1095        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1096    }
1097
1098    // ── config format fallback to TOML for unknown extensions ───
1099
1100    #[test]
1101    fn config_format_defaults_to_toml_for_unknown() {
1102        assert!(matches!(
1103            ConfigFormat::from_path(Path::new("config.yaml")),
1104            ConfigFormat::Toml
1105        ));
1106        assert!(matches!(
1107            ConfigFormat::from_path(Path::new("config")),
1108            ConfigFormat::Toml
1109        ));
1110    }
1111
1112    // ── deep_merge type coercion ─────────────────────────────────
1113
1114    #[test]
1115    fn deep_merge_object_over_scalar_replaces() {
1116        let mut base = serde_json::json!("just a string");
1117        let overlay = serde_json::json!({"key": "value"});
1118        deep_merge_json(&mut base, overlay);
1119        assert_eq!(base, serde_json::json!({"key": "value"}));
1120    }
1121
1122    #[test]
1123    fn deep_merge_scalar_over_object_replaces() {
1124        let mut base = serde_json::json!({"key": "value"});
1125        let overlay = serde_json::json!(42);
1126        deep_merge_json(&mut base, overlay);
1127        assert_eq!(base, serde_json::json!(42));
1128    }
1129
1130    // ── extends with non-string/array extends field ──────────────
1131
1132    #[test]
1133    fn extends_non_string_non_array_ignored() {
1134        let dir = test_dir("extends-numeric");
1135        std::fs::write(
1136            dir.path().join(".fallowrc.json"),
1137            r#"{"extends": 42, "entry": ["src/index.ts"]}"#,
1138        )
1139        .unwrap();
1140
1141        // extends=42 is neither string nor array, so it's treated as no extends
1142        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1143        assert_eq!(config.entry, vec!["src/index.ts"]);
1144    }
1145
1146    // ── extends with multiple bases (later overrides earlier) ────
1147
1148    #[test]
1149    fn extends_multiple_bases_later_wins() {
1150        let dir = test_dir("extends-multi-base");
1151
1152        std::fs::write(
1153            dir.path().join("base-a.json"),
1154            r#"{"rules": {"unused-files": "warn"}}"#,
1155        )
1156        .unwrap();
1157        std::fs::write(
1158            dir.path().join("base-b.json"),
1159            r#"{"rules": {"unused-files": "off"}}"#,
1160        )
1161        .unwrap();
1162        std::fs::write(
1163            dir.path().join(".fallowrc.json"),
1164            r#"{"extends": ["base-a.json", "base-b.json"]}"#,
1165        )
1166        .unwrap();
1167
1168        let config = FallowConfig::load(&dir.path().join(".fallowrc.json")).unwrap();
1169        // base-b is later in the array, so its value should win
1170        assert_eq!(config.rules.unused_files, Severity::Off);
1171    }
1172
1173    // ── config with production flag ──────────────────────────────
1174
1175    #[test]
1176    fn fallow_config_deserialize_production() {
1177        let json_str = r#"{"production": true}"#;
1178        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1179        assert!(config.production);
1180    }
1181
1182    #[test]
1183    fn fallow_config_production_defaults_false() {
1184        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1185        assert!(!config.production);
1186    }
1187
1188    // ── optional dependency names ────────────────────────────────
1189
1190    #[test]
1191    fn package_json_optional_dependency_names() {
1192        let pkg: PackageJson = serde_json::from_str(
1193            r#"{"optionalDependencies": {"fsevents": "^2", "chokidar": "^3"}}"#,
1194        )
1195        .unwrap();
1196        let opt = pkg.optional_dependency_names();
1197        assert_eq!(opt.len(), 2);
1198        assert!(opt.contains(&"fsevents".to_string()));
1199        assert!(opt.contains(&"chokidar".to_string()));
1200    }
1201
1202    #[test]
1203    fn package_json_optional_deps_empty_when_missing() {
1204        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1205        assert!(pkg.optional_dependency_names().is_empty());
1206    }
1207}