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    /// Mark this config as sealed: `extends` paths must be file-relative and
180    /// resolve within this config's own directory. `npm:` and `https:` extends
181    /// are rejected. Useful for library publishers and monorepo sub-packages
182    /// that want to guarantee their config is self-contained and not subject
183    /// to ancestor configs being injected via `extends`.
184    ///
185    /// Discovery is unaffected (first-match-wins already stops the directory
186    /// walk at the nearest config). This only constrains `extends`.
187    #[serde(default)]
188    pub sealed: bool,
189}
190
191/// Regression baseline counts, embedded in the config file.
192///
193/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
194/// fallow reads the baseline from this config section.
195/// When `--save-regression-baseline` is used without a path argument,
196/// fallow writes the baseline into the config file.
197#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
198#[serde(rename_all = "camelCase")]
199pub struct RegressionConfig {
200    /// Dead code issue counts baseline.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub baseline: Option<RegressionBaseline>,
203}
204
205/// Per-type issue counts for regression comparison.
206#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct RegressionBaseline {
209    #[serde(default)]
210    pub total_issues: usize,
211    #[serde(default)]
212    pub unused_files: usize,
213    #[serde(default)]
214    pub unused_exports: usize,
215    #[serde(default)]
216    pub unused_types: usize,
217    #[serde(default)]
218    pub unused_dependencies: usize,
219    #[serde(default)]
220    pub unused_dev_dependencies: usize,
221    #[serde(default)]
222    pub unused_optional_dependencies: usize,
223    #[serde(default)]
224    pub unused_enum_members: usize,
225    #[serde(default)]
226    pub unused_class_members: usize,
227    #[serde(default)]
228    pub unresolved_imports: usize,
229    #[serde(default)]
230    pub unlisted_dependencies: usize,
231    #[serde(default)]
232    pub duplicate_exports: usize,
233    #[serde(default)]
234    pub circular_dependencies: usize,
235    #[serde(default)]
236    pub type_only_dependencies: usize,
237    #[serde(default)]
238    pub test_only_dependencies: usize,
239    #[serde(default)]
240    pub boundary_violations: usize,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    // ── Default trait ───────────────────────────────────────────────
248
249    #[test]
250    fn default_config_has_empty_collections() {
251        let config = FallowConfig::default();
252        assert!(config.schema.is_none());
253        assert!(config.extends.is_empty());
254        assert!(config.entry.is_empty());
255        assert!(config.ignore_patterns.is_empty());
256        assert!(config.framework.is_empty());
257        assert!(config.workspaces.is_none());
258        assert!(config.ignore_dependencies.is_empty());
259        assert!(config.ignore_exports.is_empty());
260        assert!(config.used_class_members.is_empty());
261        assert!(config.plugins.is_empty());
262        assert!(config.dynamically_loaded.is_empty());
263        assert!(config.overrides.is_empty());
264        assert!(config.public_packages.is_empty());
265        assert!(!config.production);
266    }
267
268    #[test]
269    fn default_config_rules_are_error() {
270        let config = FallowConfig::default();
271        assert_eq!(config.rules.unused_files, Severity::Error);
272        assert_eq!(config.rules.unused_exports, Severity::Error);
273        assert_eq!(config.rules.unused_dependencies, Severity::Error);
274    }
275
276    #[test]
277    fn default_config_duplicates_enabled() {
278        let config = FallowConfig::default();
279        assert!(config.duplicates.enabled);
280        assert_eq!(config.duplicates.min_tokens, 50);
281        assert_eq!(config.duplicates.min_lines, 5);
282    }
283
284    #[test]
285    fn default_config_health_thresholds() {
286        let config = FallowConfig::default();
287        assert_eq!(config.health.max_cyclomatic, 20);
288        assert_eq!(config.health.max_cognitive, 15);
289    }
290
291    // ── JSON deserialization ────────────────────────────────────────
292
293    #[test]
294    fn deserialize_empty_json_object() {
295        let config: FallowConfig = serde_json::from_str("{}").unwrap();
296        assert!(config.entry.is_empty());
297        assert!(!config.production);
298    }
299
300    #[test]
301    fn deserialize_json_with_all_top_level_fields() {
302        let json = r#"{
303            "$schema": "https://fallow.dev/schema.json",
304            "entry": ["src/main.ts"],
305            "ignorePatterns": ["generated/**"],
306            "ignoreDependencies": ["postcss"],
307            "production": true,
308            "plugins": ["custom-plugin.toml"],
309            "rules": {"unused-files": "warn"},
310            "duplicates": {"enabled": false},
311            "health": {"maxCyclomatic": 30}
312        }"#;
313        let config: FallowConfig = serde_json::from_str(json).unwrap();
314        assert_eq!(
315            config.schema.as_deref(),
316            Some("https://fallow.dev/schema.json")
317        );
318        assert_eq!(config.entry, vec!["src/main.ts"]);
319        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
320        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
321        assert!(config.production);
322        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
323        assert_eq!(config.rules.unused_files, Severity::Warn);
324        assert!(!config.duplicates.enabled);
325        assert_eq!(config.health.max_cyclomatic, 30);
326    }
327
328    #[test]
329    fn deserialize_json_deny_unknown_fields() {
330        let json = r#"{"unknownField": true}"#;
331        let result: Result<FallowConfig, _> = serde_json::from_str(json);
332        assert!(result.is_err(), "unknown fields should be rejected");
333    }
334
335    #[test]
336    fn deserialize_json_production_mode_default_false() {
337        let config: FallowConfig = serde_json::from_str("{}").unwrap();
338        assert!(!config.production);
339    }
340
341    #[test]
342    fn deserialize_json_production_mode_true() {
343        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
344        assert!(config.production);
345    }
346
347    #[test]
348    fn deserialize_json_dynamically_loaded() {
349        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
350        let config: FallowConfig = serde_json::from_str(json).unwrap();
351        assert_eq!(
352            config.dynamically_loaded,
353            vec!["plugins/**/*.ts", "locales/**/*.json"]
354        );
355    }
356
357    #[test]
358    fn deserialize_json_dynamically_loaded_defaults_empty() {
359        let config: FallowConfig = serde_json::from_str("{}").unwrap();
360        assert!(config.dynamically_loaded.is_empty());
361    }
362
363    #[test]
364    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
365        let json = r#"{
366            "usedClassMembers": [
367                "agInit",
368                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
369                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
370            ]
371        }"#;
372        let config: FallowConfig = serde_json::from_str(json).unwrap();
373        assert_eq!(
374            config.used_class_members,
375            vec![
376                UsedClassMemberRule::from("agInit"),
377                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
378                    extends: None,
379                    implements: Some("ICellRendererAngularComp".to_string()),
380                    members: vec!["refresh".to_string()],
381                }),
382                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
383                    extends: Some("BaseCommand".to_string()),
384                    implements: Some("CanActivate".to_string()),
385                    members: vec!["execute".to_string()],
386                }),
387            ]
388        );
389    }
390
391    // ── TOML deserialization ────────────────────────────────────────
392
393    #[test]
394    fn deserialize_toml_minimal() {
395        let toml_str = r#"
396entry = ["src/index.ts"]
397production = true
398"#;
399        let config: FallowConfig = toml::from_str(toml_str).unwrap();
400        assert_eq!(config.entry, vec!["src/index.ts"]);
401        assert!(config.production);
402    }
403
404    #[test]
405    fn deserialize_toml_with_inline_framework() {
406        let toml_str = r#"
407[[framework]]
408name = "my-framework"
409enablers = ["my-framework-pkg"]
410entryPoints = ["src/routes/**/*.tsx"]
411"#;
412        let config: FallowConfig = toml::from_str(toml_str).unwrap();
413        assert_eq!(config.framework.len(), 1);
414        assert_eq!(config.framework[0].name, "my-framework");
415        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
416        assert_eq!(
417            config.framework[0].entry_points,
418            vec!["src/routes/**/*.tsx"]
419        );
420    }
421
422    #[test]
423    fn deserialize_toml_with_workspace_config() {
424        let toml_str = r#"
425[workspaces]
426patterns = ["packages/*", "apps/*"]
427"#;
428        let config: FallowConfig = toml::from_str(toml_str).unwrap();
429        assert!(config.workspaces.is_some());
430        let ws = config.workspaces.unwrap();
431        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
432    }
433
434    #[test]
435    fn deserialize_toml_with_ignore_exports() {
436        let toml_str = r#"
437[[ignoreExports]]
438file = "src/types/**/*.ts"
439exports = ["*"]
440"#;
441        let config: FallowConfig = toml::from_str(toml_str).unwrap();
442        assert_eq!(config.ignore_exports.len(), 1);
443        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
444        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
445    }
446
447    #[test]
448    fn deserialize_toml_used_class_members_supports_scoped_rules() {
449        let toml_str = r#"
450usedClassMembers = [
451  { implements = "ICellRendererAngularComp", members = ["refresh"] },
452  { extends = "BaseCommand", members = ["execute"] },
453]
454"#;
455        let config: FallowConfig = toml::from_str(toml_str).unwrap();
456        assert_eq!(
457            config.used_class_members,
458            vec![
459                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
460                    extends: None,
461                    implements: Some("ICellRendererAngularComp".to_string()),
462                    members: vec!["refresh".to_string()],
463                }),
464                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
465                    extends: Some("BaseCommand".to_string()),
466                    implements: None,
467                    members: vec!["execute".to_string()],
468                }),
469            ]
470        );
471    }
472
473    #[test]
474    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
475        let result = serde_json::from_str::<FallowConfig>(
476            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
477        );
478        assert!(
479            result.is_err(),
480            "unconstrained scoped rule should be rejected"
481        );
482    }
483
484    #[test]
485    fn deserialize_toml_deny_unknown_fields() {
486        let toml_str = r"bogus_field = true";
487        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
488        assert!(result.is_err(), "unknown fields should be rejected");
489    }
490
491    // ── Serialization roundtrip ─────────────────────────────────────
492
493    #[test]
494    fn json_serialize_roundtrip() {
495        let config = FallowConfig {
496            entry: vec!["src/main.ts".to_string()],
497            production: true,
498            ..FallowConfig::default()
499        };
500        let json = serde_json::to_string(&config).unwrap();
501        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
502        assert_eq!(restored.entry, vec!["src/main.ts"]);
503        assert!(restored.production);
504    }
505
506    #[test]
507    fn schema_field_not_serialized() {
508        let config = FallowConfig {
509            schema: Some("https://example.com/schema.json".to_string()),
510            ..FallowConfig::default()
511        };
512        let json = serde_json::to_string(&config).unwrap();
513        // $schema has skip_serializing, should not appear in output
514        assert!(
515            !json.contains("$schema"),
516            "schema field should be skipped in serialization"
517        );
518    }
519
520    #[test]
521    fn extends_field_not_serialized() {
522        let config = FallowConfig {
523            extends: vec!["base.json".to_string()],
524            ..FallowConfig::default()
525        };
526        let json = serde_json::to_string(&config).unwrap();
527        assert!(
528            !json.contains("extends"),
529            "extends field should be skipped in serialization"
530        );
531    }
532
533    // ── RegressionConfig / RegressionBaseline ──────────────────────
534
535    #[test]
536    fn regression_config_deserialize_json() {
537        let json = r#"{
538            "regression": {
539                "baseline": {
540                    "totalIssues": 42,
541                    "unusedFiles": 10,
542                    "unusedExports": 5,
543                    "circularDependencies": 2
544                }
545            }
546        }"#;
547        let config: FallowConfig = serde_json::from_str(json).unwrap();
548        let regression = config.regression.unwrap();
549        let baseline = regression.baseline.unwrap();
550        assert_eq!(baseline.total_issues, 42);
551        assert_eq!(baseline.unused_files, 10);
552        assert_eq!(baseline.unused_exports, 5);
553        assert_eq!(baseline.circular_dependencies, 2);
554        // Unset fields default to 0
555        assert_eq!(baseline.unused_types, 0);
556        assert_eq!(baseline.boundary_violations, 0);
557    }
558
559    #[test]
560    fn regression_config_defaults_to_none() {
561        let config: FallowConfig = serde_json::from_str("{}").unwrap();
562        assert!(config.regression.is_none());
563    }
564
565    #[test]
566    fn regression_baseline_all_zeros_by_default() {
567        let baseline = RegressionBaseline::default();
568        assert_eq!(baseline.total_issues, 0);
569        assert_eq!(baseline.unused_files, 0);
570        assert_eq!(baseline.unused_exports, 0);
571        assert_eq!(baseline.unused_types, 0);
572        assert_eq!(baseline.unused_dependencies, 0);
573        assert_eq!(baseline.unused_dev_dependencies, 0);
574        assert_eq!(baseline.unused_optional_dependencies, 0);
575        assert_eq!(baseline.unused_enum_members, 0);
576        assert_eq!(baseline.unused_class_members, 0);
577        assert_eq!(baseline.unresolved_imports, 0);
578        assert_eq!(baseline.unlisted_dependencies, 0);
579        assert_eq!(baseline.duplicate_exports, 0);
580        assert_eq!(baseline.circular_dependencies, 0);
581        assert_eq!(baseline.type_only_dependencies, 0);
582        assert_eq!(baseline.test_only_dependencies, 0);
583        assert_eq!(baseline.boundary_violations, 0);
584    }
585
586    #[test]
587    fn regression_config_serialize_roundtrip() {
588        let baseline = RegressionBaseline {
589            total_issues: 100,
590            unused_files: 20,
591            unused_exports: 30,
592            ..RegressionBaseline::default()
593        };
594        let regression = RegressionConfig {
595            baseline: Some(baseline),
596        };
597        let config = FallowConfig {
598            regression: Some(regression),
599            ..FallowConfig::default()
600        };
601        let json = serde_json::to_string(&config).unwrap();
602        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
603        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
604        assert_eq!(restored_baseline.total_issues, 100);
605        assert_eq!(restored_baseline.unused_files, 20);
606        assert_eq!(restored_baseline.unused_exports, 30);
607        assert_eq!(restored_baseline.unused_types, 0);
608    }
609
610    #[test]
611    fn regression_config_empty_baseline_deserialize() {
612        let json = r#"{"regression": {}}"#;
613        let config: FallowConfig = serde_json::from_str(json).unwrap();
614        let regression = config.regression.unwrap();
615        assert!(regression.baseline.is_none());
616    }
617
618    #[test]
619    fn regression_baseline_not_serialized_when_none() {
620        let config = FallowConfig {
621            regression: None,
622            ..FallowConfig::default()
623        };
624        let json = serde_json::to_string(&config).unwrap();
625        assert!(
626            !json.contains("regression"),
627            "regression should be skipped when None"
628        );
629    }
630
631    // ── JSON config with overrides and boundaries ──────────────────
632
633    #[test]
634    fn deserialize_json_with_overrides() {
635        let json = r#"{
636            "overrides": [
637                {
638                    "files": ["*.test.ts", "*.spec.ts"],
639                    "rules": {
640                        "unused-exports": "off",
641                        "unused-files": "warn"
642                    }
643                }
644            ]
645        }"#;
646        let config: FallowConfig = serde_json::from_str(json).unwrap();
647        assert_eq!(config.overrides.len(), 1);
648        assert_eq!(config.overrides[0].files.len(), 2);
649        assert_eq!(
650            config.overrides[0].rules.unused_exports,
651            Some(Severity::Off)
652        );
653        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
654    }
655
656    #[test]
657    fn deserialize_json_with_boundaries() {
658        let json = r#"{
659            "boundaries": {
660                "preset": "layered"
661            }
662        }"#;
663        let config: FallowConfig = serde_json::from_str(json).unwrap();
664        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
665    }
666
667    // ── TOML with regression config ────────────────────────────────
668
669    #[test]
670    fn deserialize_toml_with_regression_baseline() {
671        let toml_str = r"
672[regression.baseline]
673totalIssues = 50
674unusedFiles = 10
675unusedExports = 15
676";
677        let config: FallowConfig = toml::from_str(toml_str).unwrap();
678        let baseline = config.regression.unwrap().baseline.unwrap();
679        assert_eq!(baseline.total_issues, 50);
680        assert_eq!(baseline.unused_files, 10);
681        assert_eq!(baseline.unused_exports, 15);
682    }
683
684    // ── TOML with multiple overrides ───────────────────────────────
685
686    #[test]
687    fn deserialize_toml_with_overrides() {
688        let toml_str = r#"
689[[overrides]]
690files = ["*.test.ts"]
691
692[overrides.rules]
693unused-exports = "off"
694
695[[overrides]]
696files = ["*.stories.tsx"]
697
698[overrides.rules]
699unused-files = "off"
700"#;
701        let config: FallowConfig = toml::from_str(toml_str).unwrap();
702        assert_eq!(config.overrides.len(), 2);
703        assert_eq!(
704            config.overrides[0].rules.unused_exports,
705            Some(Severity::Off)
706        );
707        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
708    }
709
710    // ── Default regression config ──────────────────────────────────
711
712    #[test]
713    fn regression_config_default_is_none_baseline() {
714        let config = RegressionConfig::default();
715        assert!(config.baseline.is_none());
716    }
717
718    // ── Config with multiple ignore export rules ───────────────────
719
720    #[test]
721    fn deserialize_json_multiple_ignore_export_rules() {
722        let json = r#"{
723            "ignoreExports": [
724                {"file": "src/types/**/*.ts", "exports": ["*"]},
725                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
726                {"file": "src/index.ts", "exports": ["default"]}
727            ]
728        }"#;
729        let config: FallowConfig = serde_json::from_str(json).unwrap();
730        assert_eq!(config.ignore_exports.len(), 3);
731        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
732    }
733
734    // ── Public packages ───────────────────────────────────────────
735
736    #[test]
737    fn deserialize_json_public_packages_camel_case() {
738        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
739        let config: FallowConfig = serde_json::from_str(json).unwrap();
740        assert_eq!(
741            config.public_packages,
742            vec!["@myorg/shared-lib", "@myorg/utils"]
743        );
744    }
745
746    #[test]
747    fn deserialize_json_public_packages_rejects_snake_case() {
748        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
749        let result: Result<FallowConfig, _> = serde_json::from_str(json);
750        assert!(
751            result.is_err(),
752            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
753        );
754    }
755
756    #[test]
757    fn deserialize_json_public_packages_empty() {
758        let config: FallowConfig = serde_json::from_str("{}").unwrap();
759        assert!(config.public_packages.is_empty());
760    }
761
762    #[test]
763    fn deserialize_toml_public_packages() {
764        let toml_str = r#"
765publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
766"#;
767        let config: FallowConfig = toml::from_str(toml_str).unwrap();
768        assert_eq!(
769            config.public_packages,
770            vec!["@myorg/shared-lib", "@myorg/ui"]
771        );
772    }
773
774    #[test]
775    fn public_packages_serialize_roundtrip() {
776        let config = FallowConfig {
777            public_packages: vec!["@myorg/shared-lib".to_string()],
778            ..FallowConfig::default()
779        };
780        let json = serde_json::to_string(&config).unwrap();
781        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
782        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
783    }
784}