Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5mod health;
6mod parsing;
7mod resolution;
8mod rules;
9mod used_class_members;
10
11pub use boundaries::{
12    BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
13    ResolvedBoundaryRule, ResolvedZone,
14};
15pub use duplicates_config::{
16    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
17};
18pub use flags::{FlagsConfig, SdkPattern};
19pub use format::OutputFormat;
20pub use health::{EmailMode, HealthConfig, OwnershipConfig};
21pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
22pub use rules::{PartialRulesConfig, RulesConfig, Severity};
23pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
24
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28use crate::external_plugin::ExternalPluginDef;
29use crate::workspace::WorkspaceConfig;
30
31/// User-facing configuration loaded from `.fallowrc.json` or `fallow.toml`.
32///
33/// # Examples
34///
35/// ```
36/// use fallow_config::FallowConfig;
37///
38/// // Default config has sensible defaults
39/// let config = FallowConfig::default();
40/// assert!(config.entry.is_empty());
41/// assert!(!config.production);
42///
43/// // Deserialize from JSON
44/// let config: FallowConfig = serde_json::from_str(r#"{
45///     "entry": ["src/main.ts"],
46///     "production": true
47/// }"#).unwrap();
48/// assert_eq!(config.entry, vec!["src/main.ts"]);
49/// assert!(config.production);
50/// ```
51#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
52#[serde(deny_unknown_fields, rename_all = "camelCase")]
53pub struct FallowConfig {
54    /// JSON Schema reference (ignored during deserialization).
55    #[serde(rename = "$schema", default, skip_serializing)]
56    pub schema: Option<String>,
57
58    /// Base config files to extend from.
59    ///
60    /// Supports three resolution strategies:
61    /// - **Relative paths**: `"./base.json"` — resolved relative to the config file.
62    /// - **npm packages**: `"npm:@co/config"` — resolved by walking up `node_modules/`.
63    ///   Package resolution checks `package.json` `exports`/`main` first, then falls back
64    ///   to standard config file names. Subpaths are supported (e.g., `npm:@co/config/strict.json`).
65    /// - **HTTPS URLs**: `"https://example.com/fallow-base.json"` — fetched remotely.
66    ///   Only HTTPS is supported (no plain HTTP). URL-sourced configs may extend other
67    ///   URLs or `npm:` packages, but not relative paths. Only JSON/JSONC format is
68    ///   supported for remote configs. Timeout is configurable via
69    ///   `FALLOW_EXTENDS_TIMEOUT_SECS` (default: 5s).
70    ///
71    /// Base configs are loaded first, then this config's values override them.
72    /// Later entries in the array override earlier ones.
73    ///
74    /// **Note:** `npm:` resolution uses `node_modules/` directory walk-up and is
75    /// incompatible with Yarn Plug'n'Play (PnP), which has no `node_modules/`.
76    /// URL extends fetch on every run (no caching). For reliable CI, prefer `npm:`
77    /// for private or critical configs.
78    #[serde(default, skip_serializing)]
79    pub extends: Vec<String>,
80
81    /// Additional entry point glob patterns.
82    #[serde(default)]
83    pub entry: Vec<String>,
84
85    /// Glob patterns to ignore from analysis.
86    #[serde(default)]
87    pub ignore_patterns: Vec<String>,
88
89    /// Custom framework definitions (inline plugin definitions).
90    #[serde(default)]
91    pub framework: Vec<ExternalPluginDef>,
92
93    /// Workspace overrides.
94    #[serde(default)]
95    pub workspaces: Option<WorkspaceConfig>,
96
97    /// Dependencies to ignore (always considered used and always considered available).
98    ///
99    /// Listed dependencies are excluded from both unused dependency and unlisted
100    /// dependency detection. Useful for runtime-provided packages like `bun:sqlite`
101    /// or implicitly available dependencies.
102    #[serde(default)]
103    pub ignore_dependencies: Vec<String>,
104
105    /// Export ignore rules.
106    #[serde(default)]
107    pub ignore_exports: Vec<IgnoreExportRule>,
108
109    /// Class member method/property rules that should never be flagged as
110    /// unused. Supports plain member names for global suppression and scoped
111    /// objects with `extends` / `implements` constraints for framework-invoked
112    /// methods that should only be suppressed on matching classes.
113    #[serde(default)]
114    pub used_class_members: Vec<UsedClassMemberRule>,
115
116    /// Duplication detection settings.
117    #[serde(default)]
118    pub duplicates: DuplicatesConfig,
119
120    /// Complexity health metrics settings.
121    #[serde(default)]
122    pub health: HealthConfig,
123
124    /// Per-issue-type severity rules.
125    #[serde(default)]
126    pub rules: RulesConfig,
127
128    /// Architecture boundary enforcement configuration.
129    #[serde(default)]
130    pub boundaries: BoundaryConfig,
131
132    /// Feature flag detection configuration.
133    #[serde(default)]
134    pub flags: FlagsConfig,
135
136    /// Production mode: exclude test/dev files, only start/build scripts.
137    #[serde(default)]
138    pub production: bool,
139
140    /// Paths to external plugin files or directories containing plugin files.
141    ///
142    /// Supports TOML, JSON, and JSONC formats.
143    ///
144    /// In addition to these explicit paths, fallow automatically discovers:
145    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
146    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
147    #[serde(default)]
148    pub plugins: Vec<String>,
149
150    /// Glob patterns for files that are dynamically loaded at runtime
151    /// (plugin directories, locale files, etc.). These files are treated as
152    /// always-used and will never be flagged as unused.
153    #[serde(default)]
154    pub dynamically_loaded: Vec<String>,
155
156    /// Per-file rule overrides matching oxlint's overrides pattern.
157    #[serde(default)]
158    pub overrides: Vec<ConfigOverride>,
159
160    /// Path to a CODEOWNERS file for `--group-by owner`.
161    ///
162    /// When unset, fallow auto-probes `CODEOWNERS`, `.github/CODEOWNERS`,
163    /// `.gitlab/CODEOWNERS`, and `docs/CODEOWNERS`. Set this to use a
164    /// non-standard location.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub codeowners: Option<String>,
167
168    /// Workspace package name patterns that are public libraries.
169    /// Exports from these packages are not flagged as unused.
170    #[serde(default)]
171    pub public_packages: Vec<String>,
172
173    /// Regression detection baseline embedded in config.
174    /// Stores issue counts from a known-good state for CI regression checks.
175    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub regression: Option<RegressionConfig>,
178
179    /// Audit command baseline paths (one per analysis: dead-code, health, dupes).
180    ///
181    /// `fallow audit` runs three analyses and each has its own baseline format.
182    /// Paths in this section are resolved relative to the project root. CLI flags
183    /// (`--dead-code-baseline`, `--health-baseline`, `--dupes-baseline`) override
184    /// these values when provided.
185    #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
186    pub audit: AuditConfig,
187
188    /// Mark this config as sealed: `extends` paths must be file-relative and
189    /// resolve within this config's own directory. `npm:` and `https:` extends
190    /// are rejected. Useful for library publishers and monorepo sub-packages
191    /// that want to guarantee their config is self-contained and not subject
192    /// to ancestor configs being injected via `extends`.
193    ///
194    /// Discovery is unaffected (first-match-wins already stops the directory
195    /// walk at the nearest config). This only constrains `extends`.
196    #[serde(default)]
197    pub sealed: bool,
198}
199
200/// Per-analysis baseline paths for the `audit` command.
201///
202/// Each field points to a baseline file produced by the corresponding
203/// subcommand (`fallow dead-code --save-baseline`, `fallow health --save-baseline`,
204/// `fallow dupes --save-baseline`). `audit` passes each baseline through to its
205/// underlying analysis; baseline-matched issues are excluded from the verdict.
206#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct AuditConfig {
209    /// Path to the dead-code baseline (produced by `fallow dead-code --save-baseline`).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub dead_code_baseline: Option<String>,
212
213    /// Path to the health baseline (produced by `fallow health --save-baseline`).
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub health_baseline: Option<String>,
216
217    /// Path to the duplication baseline (produced by `fallow dupes --save-baseline`).
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub dupes_baseline: Option<String>,
220}
221
222impl AuditConfig {
223    /// True when all baseline paths are unset.
224    #[must_use]
225    pub fn is_empty(&self) -> bool {
226        self.dead_code_baseline.is_none()
227            && self.health_baseline.is_none()
228            && self.dupes_baseline.is_none()
229    }
230}
231
232/// Regression baseline counts, embedded in the config file.
233///
234/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
235/// fallow reads the baseline from this config section.
236/// When `--save-regression-baseline` is used without a path argument,
237/// fallow writes the baseline into the config file.
238#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct RegressionConfig {
241    /// Dead code issue counts baseline.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub baseline: Option<RegressionBaseline>,
244}
245
246/// Per-type issue counts for regression comparison.
247#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
248#[serde(rename_all = "camelCase")]
249pub struct RegressionBaseline {
250    #[serde(default)]
251    pub total_issues: usize,
252    #[serde(default)]
253    pub unused_files: usize,
254    #[serde(default)]
255    pub unused_exports: usize,
256    #[serde(default)]
257    pub unused_types: usize,
258    #[serde(default)]
259    pub unused_dependencies: usize,
260    #[serde(default)]
261    pub unused_dev_dependencies: usize,
262    #[serde(default)]
263    pub unused_optional_dependencies: usize,
264    #[serde(default)]
265    pub unused_enum_members: usize,
266    #[serde(default)]
267    pub unused_class_members: usize,
268    #[serde(default)]
269    pub unresolved_imports: usize,
270    #[serde(default)]
271    pub unlisted_dependencies: usize,
272    #[serde(default)]
273    pub duplicate_exports: usize,
274    #[serde(default)]
275    pub circular_dependencies: usize,
276    #[serde(default)]
277    pub type_only_dependencies: usize,
278    #[serde(default)]
279    pub test_only_dependencies: usize,
280    #[serde(default)]
281    pub boundary_violations: usize,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    // ── Default trait ───────────────────────────────────────────────
289
290    #[test]
291    fn default_config_has_empty_collections() {
292        let config = FallowConfig::default();
293        assert!(config.schema.is_none());
294        assert!(config.extends.is_empty());
295        assert!(config.entry.is_empty());
296        assert!(config.ignore_patterns.is_empty());
297        assert!(config.framework.is_empty());
298        assert!(config.workspaces.is_none());
299        assert!(config.ignore_dependencies.is_empty());
300        assert!(config.ignore_exports.is_empty());
301        assert!(config.used_class_members.is_empty());
302        assert!(config.plugins.is_empty());
303        assert!(config.dynamically_loaded.is_empty());
304        assert!(config.overrides.is_empty());
305        assert!(config.public_packages.is_empty());
306        assert!(!config.production);
307    }
308
309    #[test]
310    fn default_config_rules_are_error() {
311        let config = FallowConfig::default();
312        assert_eq!(config.rules.unused_files, Severity::Error);
313        assert_eq!(config.rules.unused_exports, Severity::Error);
314        assert_eq!(config.rules.unused_dependencies, Severity::Error);
315    }
316
317    #[test]
318    fn default_config_duplicates_enabled() {
319        let config = FallowConfig::default();
320        assert!(config.duplicates.enabled);
321        assert_eq!(config.duplicates.min_tokens, 50);
322        assert_eq!(config.duplicates.min_lines, 5);
323    }
324
325    #[test]
326    fn default_config_health_thresholds() {
327        let config = FallowConfig::default();
328        assert_eq!(config.health.max_cyclomatic, 20);
329        assert_eq!(config.health.max_cognitive, 15);
330    }
331
332    // ── JSON deserialization ────────────────────────────────────────
333
334    #[test]
335    fn deserialize_empty_json_object() {
336        let config: FallowConfig = serde_json::from_str("{}").unwrap();
337        assert!(config.entry.is_empty());
338        assert!(!config.production);
339    }
340
341    #[test]
342    fn deserialize_json_with_all_top_level_fields() {
343        let json = r#"{
344            "$schema": "https://fallow.dev/schema.json",
345            "entry": ["src/main.ts"],
346            "ignorePatterns": ["generated/**"],
347            "ignoreDependencies": ["postcss"],
348            "production": true,
349            "plugins": ["custom-plugin.toml"],
350            "rules": {"unused-files": "warn"},
351            "duplicates": {"enabled": false},
352            "health": {"maxCyclomatic": 30}
353        }"#;
354        let config: FallowConfig = serde_json::from_str(json).unwrap();
355        assert_eq!(
356            config.schema.as_deref(),
357            Some("https://fallow.dev/schema.json")
358        );
359        assert_eq!(config.entry, vec!["src/main.ts"]);
360        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
361        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
362        assert!(config.production);
363        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
364        assert_eq!(config.rules.unused_files, Severity::Warn);
365        assert!(!config.duplicates.enabled);
366        assert_eq!(config.health.max_cyclomatic, 30);
367    }
368
369    #[test]
370    fn deserialize_json_deny_unknown_fields() {
371        let json = r#"{"unknownField": true}"#;
372        let result: Result<FallowConfig, _> = serde_json::from_str(json);
373        assert!(result.is_err(), "unknown fields should be rejected");
374    }
375
376    #[test]
377    fn deserialize_json_production_mode_default_false() {
378        let config: FallowConfig = serde_json::from_str("{}").unwrap();
379        assert!(!config.production);
380    }
381
382    #[test]
383    fn deserialize_json_production_mode_true() {
384        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
385        assert!(config.production);
386    }
387
388    #[test]
389    fn deserialize_json_dynamically_loaded() {
390        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
391        let config: FallowConfig = serde_json::from_str(json).unwrap();
392        assert_eq!(
393            config.dynamically_loaded,
394            vec!["plugins/**/*.ts", "locales/**/*.json"]
395        );
396    }
397
398    #[test]
399    fn deserialize_json_dynamically_loaded_defaults_empty() {
400        let config: FallowConfig = serde_json::from_str("{}").unwrap();
401        assert!(config.dynamically_loaded.is_empty());
402    }
403
404    #[test]
405    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
406        let json = r#"{
407            "usedClassMembers": [
408                "agInit",
409                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
410                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
411            ]
412        }"#;
413        let config: FallowConfig = serde_json::from_str(json).unwrap();
414        assert_eq!(
415            config.used_class_members,
416            vec![
417                UsedClassMemberRule::from("agInit"),
418                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
419                    extends: None,
420                    implements: Some("ICellRendererAngularComp".to_string()),
421                    members: vec!["refresh".to_string()],
422                }),
423                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
424                    extends: Some("BaseCommand".to_string()),
425                    implements: Some("CanActivate".to_string()),
426                    members: vec!["execute".to_string()],
427                }),
428            ]
429        );
430    }
431
432    // ── TOML deserialization ────────────────────────────────────────
433
434    #[test]
435    fn deserialize_toml_minimal() {
436        let toml_str = r#"
437entry = ["src/index.ts"]
438production = true
439"#;
440        let config: FallowConfig = toml::from_str(toml_str).unwrap();
441        assert_eq!(config.entry, vec!["src/index.ts"]);
442        assert!(config.production);
443    }
444
445    #[test]
446    fn deserialize_toml_with_inline_framework() {
447        let toml_str = r#"
448[[framework]]
449name = "my-framework"
450enablers = ["my-framework-pkg"]
451entryPoints = ["src/routes/**/*.tsx"]
452"#;
453        let config: FallowConfig = toml::from_str(toml_str).unwrap();
454        assert_eq!(config.framework.len(), 1);
455        assert_eq!(config.framework[0].name, "my-framework");
456        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
457        assert_eq!(
458            config.framework[0].entry_points,
459            vec!["src/routes/**/*.tsx"]
460        );
461    }
462
463    #[test]
464    fn deserialize_toml_with_workspace_config() {
465        let toml_str = r#"
466[workspaces]
467patterns = ["packages/*", "apps/*"]
468"#;
469        let config: FallowConfig = toml::from_str(toml_str).unwrap();
470        assert!(config.workspaces.is_some());
471        let ws = config.workspaces.unwrap();
472        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
473    }
474
475    #[test]
476    fn deserialize_toml_with_ignore_exports() {
477        let toml_str = r#"
478[[ignoreExports]]
479file = "src/types/**/*.ts"
480exports = ["*"]
481"#;
482        let config: FallowConfig = toml::from_str(toml_str).unwrap();
483        assert_eq!(config.ignore_exports.len(), 1);
484        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
485        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
486    }
487
488    #[test]
489    fn deserialize_toml_used_class_members_supports_scoped_rules() {
490        let toml_str = r#"
491usedClassMembers = [
492  { implements = "ICellRendererAngularComp", members = ["refresh"] },
493  { extends = "BaseCommand", members = ["execute"] },
494]
495"#;
496        let config: FallowConfig = toml::from_str(toml_str).unwrap();
497        assert_eq!(
498            config.used_class_members,
499            vec![
500                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
501                    extends: None,
502                    implements: Some("ICellRendererAngularComp".to_string()),
503                    members: vec!["refresh".to_string()],
504                }),
505                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
506                    extends: Some("BaseCommand".to_string()),
507                    implements: None,
508                    members: vec!["execute".to_string()],
509                }),
510            ]
511        );
512    }
513
514    #[test]
515    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
516        let result = serde_json::from_str::<FallowConfig>(
517            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
518        );
519        assert!(
520            result.is_err(),
521            "unconstrained scoped rule should be rejected"
522        );
523    }
524
525    #[test]
526    fn deserialize_toml_deny_unknown_fields() {
527        let toml_str = r"bogus_field = true";
528        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
529        assert!(result.is_err(), "unknown fields should be rejected");
530    }
531
532    // ── Serialization roundtrip ─────────────────────────────────────
533
534    #[test]
535    fn json_serialize_roundtrip() {
536        let config = FallowConfig {
537            entry: vec!["src/main.ts".to_string()],
538            production: true,
539            ..FallowConfig::default()
540        };
541        let json = serde_json::to_string(&config).unwrap();
542        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
543        assert_eq!(restored.entry, vec!["src/main.ts"]);
544        assert!(restored.production);
545    }
546
547    #[test]
548    fn schema_field_not_serialized() {
549        let config = FallowConfig {
550            schema: Some("https://example.com/schema.json".to_string()),
551            ..FallowConfig::default()
552        };
553        let json = serde_json::to_string(&config).unwrap();
554        // $schema has skip_serializing, should not appear in output
555        assert!(
556            !json.contains("$schema"),
557            "schema field should be skipped in serialization"
558        );
559    }
560
561    #[test]
562    fn extends_field_not_serialized() {
563        let config = FallowConfig {
564            extends: vec!["base.json".to_string()],
565            ..FallowConfig::default()
566        };
567        let json = serde_json::to_string(&config).unwrap();
568        assert!(
569            !json.contains("extends"),
570            "extends field should be skipped in serialization"
571        );
572    }
573
574    // ── RegressionConfig / RegressionBaseline ──────────────────────
575
576    #[test]
577    fn regression_config_deserialize_json() {
578        let json = r#"{
579            "regression": {
580                "baseline": {
581                    "totalIssues": 42,
582                    "unusedFiles": 10,
583                    "unusedExports": 5,
584                    "circularDependencies": 2
585                }
586            }
587        }"#;
588        let config: FallowConfig = serde_json::from_str(json).unwrap();
589        let regression = config.regression.unwrap();
590        let baseline = regression.baseline.unwrap();
591        assert_eq!(baseline.total_issues, 42);
592        assert_eq!(baseline.unused_files, 10);
593        assert_eq!(baseline.unused_exports, 5);
594        assert_eq!(baseline.circular_dependencies, 2);
595        // Unset fields default to 0
596        assert_eq!(baseline.unused_types, 0);
597        assert_eq!(baseline.boundary_violations, 0);
598    }
599
600    #[test]
601    fn regression_config_defaults_to_none() {
602        let config: FallowConfig = serde_json::from_str("{}").unwrap();
603        assert!(config.regression.is_none());
604    }
605
606    #[test]
607    fn regression_baseline_all_zeros_by_default() {
608        let baseline = RegressionBaseline::default();
609        assert_eq!(baseline.total_issues, 0);
610        assert_eq!(baseline.unused_files, 0);
611        assert_eq!(baseline.unused_exports, 0);
612        assert_eq!(baseline.unused_types, 0);
613        assert_eq!(baseline.unused_dependencies, 0);
614        assert_eq!(baseline.unused_dev_dependencies, 0);
615        assert_eq!(baseline.unused_optional_dependencies, 0);
616        assert_eq!(baseline.unused_enum_members, 0);
617        assert_eq!(baseline.unused_class_members, 0);
618        assert_eq!(baseline.unresolved_imports, 0);
619        assert_eq!(baseline.unlisted_dependencies, 0);
620        assert_eq!(baseline.duplicate_exports, 0);
621        assert_eq!(baseline.circular_dependencies, 0);
622        assert_eq!(baseline.type_only_dependencies, 0);
623        assert_eq!(baseline.test_only_dependencies, 0);
624        assert_eq!(baseline.boundary_violations, 0);
625    }
626
627    #[test]
628    fn regression_config_serialize_roundtrip() {
629        let baseline = RegressionBaseline {
630            total_issues: 100,
631            unused_files: 20,
632            unused_exports: 30,
633            ..RegressionBaseline::default()
634        };
635        let regression = RegressionConfig {
636            baseline: Some(baseline),
637        };
638        let config = FallowConfig {
639            regression: Some(regression),
640            ..FallowConfig::default()
641        };
642        let json = serde_json::to_string(&config).unwrap();
643        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
644        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
645        assert_eq!(restored_baseline.total_issues, 100);
646        assert_eq!(restored_baseline.unused_files, 20);
647        assert_eq!(restored_baseline.unused_exports, 30);
648        assert_eq!(restored_baseline.unused_types, 0);
649    }
650
651    #[test]
652    fn regression_config_empty_baseline_deserialize() {
653        let json = r#"{"regression": {}}"#;
654        let config: FallowConfig = serde_json::from_str(json).unwrap();
655        let regression = config.regression.unwrap();
656        assert!(regression.baseline.is_none());
657    }
658
659    #[test]
660    fn regression_baseline_not_serialized_when_none() {
661        let config = FallowConfig {
662            regression: None,
663            ..FallowConfig::default()
664        };
665        let json = serde_json::to_string(&config).unwrap();
666        assert!(
667            !json.contains("regression"),
668            "regression should be skipped when None"
669        );
670    }
671
672    // ── JSON config with overrides and boundaries ──────────────────
673
674    #[test]
675    fn deserialize_json_with_overrides() {
676        let json = r#"{
677            "overrides": [
678                {
679                    "files": ["*.test.ts", "*.spec.ts"],
680                    "rules": {
681                        "unused-exports": "off",
682                        "unused-files": "warn"
683                    }
684                }
685            ]
686        }"#;
687        let config: FallowConfig = serde_json::from_str(json).unwrap();
688        assert_eq!(config.overrides.len(), 1);
689        assert_eq!(config.overrides[0].files.len(), 2);
690        assert_eq!(
691            config.overrides[0].rules.unused_exports,
692            Some(Severity::Off)
693        );
694        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
695    }
696
697    #[test]
698    fn deserialize_json_with_boundaries() {
699        let json = r#"{
700            "boundaries": {
701                "preset": "layered"
702            }
703        }"#;
704        let config: FallowConfig = serde_json::from_str(json).unwrap();
705        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
706    }
707
708    // ── TOML with regression config ────────────────────────────────
709
710    #[test]
711    fn deserialize_toml_with_regression_baseline() {
712        let toml_str = r"
713[regression.baseline]
714totalIssues = 50
715unusedFiles = 10
716unusedExports = 15
717";
718        let config: FallowConfig = toml::from_str(toml_str).unwrap();
719        let baseline = config.regression.unwrap().baseline.unwrap();
720        assert_eq!(baseline.total_issues, 50);
721        assert_eq!(baseline.unused_files, 10);
722        assert_eq!(baseline.unused_exports, 15);
723    }
724
725    // ── TOML with multiple overrides ───────────────────────────────
726
727    #[test]
728    fn deserialize_toml_with_overrides() {
729        let toml_str = r#"
730[[overrides]]
731files = ["*.test.ts"]
732
733[overrides.rules]
734unused-exports = "off"
735
736[[overrides]]
737files = ["*.stories.tsx"]
738
739[overrides.rules]
740unused-files = "off"
741"#;
742        let config: FallowConfig = toml::from_str(toml_str).unwrap();
743        assert_eq!(config.overrides.len(), 2);
744        assert_eq!(
745            config.overrides[0].rules.unused_exports,
746            Some(Severity::Off)
747        );
748        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
749    }
750
751    // ── Default regression config ──────────────────────────────────
752
753    #[test]
754    fn regression_config_default_is_none_baseline() {
755        let config = RegressionConfig::default();
756        assert!(config.baseline.is_none());
757    }
758
759    // ── Config with multiple ignore export rules ───────────────────
760
761    #[test]
762    fn deserialize_json_multiple_ignore_export_rules() {
763        let json = r#"{
764            "ignoreExports": [
765                {"file": "src/types/**/*.ts", "exports": ["*"]},
766                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
767                {"file": "src/index.ts", "exports": ["default"]}
768            ]
769        }"#;
770        let config: FallowConfig = serde_json::from_str(json).unwrap();
771        assert_eq!(config.ignore_exports.len(), 3);
772        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
773    }
774
775    // ── Public packages ───────────────────────────────────────────
776
777    #[test]
778    fn deserialize_json_public_packages_camel_case() {
779        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
780        let config: FallowConfig = serde_json::from_str(json).unwrap();
781        assert_eq!(
782            config.public_packages,
783            vec!["@myorg/shared-lib", "@myorg/utils"]
784        );
785    }
786
787    #[test]
788    fn deserialize_json_public_packages_rejects_snake_case() {
789        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
790        let result: Result<FallowConfig, _> = serde_json::from_str(json);
791        assert!(
792            result.is_err(),
793            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
794        );
795    }
796
797    #[test]
798    fn deserialize_json_public_packages_empty() {
799        let config: FallowConfig = serde_json::from_str("{}").unwrap();
800        assert!(config.public_packages.is_empty());
801    }
802
803    #[test]
804    fn deserialize_toml_public_packages() {
805        let toml_str = r#"
806publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
807"#;
808        let config: FallowConfig = toml::from_str(toml_str).unwrap();
809        assert_eq!(
810            config.public_packages,
811            vec!["@myorg/shared-lib", "@myorg/ui"]
812        );
813    }
814
815    #[test]
816    fn public_packages_serialize_roundtrip() {
817        let config = FallowConfig {
818            public_packages: vec!["@myorg/shared-lib".to_string()],
819            ..FallowConfig::default()
820        };
821        let json = serde_json::to_string(&config).unwrap();
822        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
823        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
824    }
825}