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