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