Skip to main content

fallow_config/config/
parsing.rs

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