Skip to main content

fallow_config/config/
rules.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Severity level for rules.
5///
6/// Controls whether an issue type causes CI failure (`error`), is reported
7/// without failing (`warn`), or is suppressed entirely (`off`).
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// Report and fail CI (non-zero exit code).
12    #[default]
13    Error,
14    /// Report but don't fail CI.
15    Warn,
16    /// Don't detect or report.
17    Off,
18}
19
20impl Severity {
21    /// Default value for fields that should default to `Warn` instead of `Error`.
22    const fn default_warn() -> Self {
23        Self::Warn
24    }
25
26    /// Default value for fields that should default to `Off`.
27    const fn default_off() -> Self {
28        Self::Off
29    }
30}
31
32impl std::fmt::Display for Severity {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Error => write!(f, "error"),
36            Self::Warn => write!(f, "warn"),
37            Self::Off => write!(f, "off"),
38        }
39    }
40}
41
42impl std::str::FromStr for Severity {
43    type Err = String;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s.to_lowercase().as_str() {
47            "error" => Ok(Self::Error),
48            "warn" | "warning" => Ok(Self::Warn),
49            "off" | "none" => Ok(Self::Off),
50            other => Err(format!(
51                "unknown severity: '{other}' (expected error, warn, or off)"
52            )),
53        }
54    }
55}
56
57/// Per-issue-type severity configuration.
58///
59/// Controls which issue types cause CI failure, are reported as warnings,
60/// or are suppressed entirely. Most fields default to `Severity::Error`.
61///
62/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
63#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
64#[serde(rename_all = "kebab-case")]
65pub struct RulesConfig {
66    #[serde(default, alias = "unused-file")]
67    pub unused_files: Severity,
68    #[serde(default, alias = "unused-export")]
69    pub unused_exports: Severity,
70    #[serde(default, alias = "unused-type")]
71    pub unused_types: Severity,
72    #[serde(default = "Severity::default_off", alias = "private-type-leak")]
73    pub private_type_leaks: Severity,
74    #[serde(default, alias = "unused-dependency")]
75    pub unused_dependencies: Severity,
76    #[serde(default = "Severity::default_warn", alias = "unused-dev-dependency")]
77    pub unused_dev_dependencies: Severity,
78    #[serde(
79        default = "Severity::default_warn",
80        alias = "unused-optional-dependency"
81    )]
82    pub unused_optional_dependencies: Severity,
83    #[serde(default, alias = "unused-enum-member")]
84    pub unused_enum_members: Severity,
85    #[serde(default, alias = "unused-class-member")]
86    pub unused_class_members: Severity,
87    #[serde(default, alias = "unresolved-import")]
88    pub unresolved_imports: Severity,
89    #[serde(default, alias = "unlisted-dependency")]
90    pub unlisted_dependencies: Severity,
91    #[serde(default, alias = "duplicate-export")]
92    pub duplicate_exports: Severity,
93    #[serde(default = "Severity::default_warn", alias = "type-only-dependency")]
94    pub type_only_dependencies: Severity,
95    #[serde(default = "Severity::default_warn", alias = "test-only-dependency")]
96    pub test_only_dependencies: Severity,
97    #[serde(default, alias = "circular-dependency")]
98    pub circular_dependencies: Severity,
99    #[serde(default, alias = "boundary-violations")]
100    pub boundary_violation: Severity,
101    #[serde(default, alias = "coverage-gap")]
102    pub coverage_gaps: Severity,
103    #[serde(default = "Severity::default_off", alias = "feature-flag")]
104    pub feature_flags: Severity,
105    #[serde(default = "Severity::default_warn", alias = "stale-suppression")]
106    pub stale_suppressions: Severity,
107    #[serde(default = "Severity::default_warn", alias = "unused-catalog-entry")]
108    pub unused_catalog_entries: Severity,
109}
110
111impl Default for RulesConfig {
112    fn default() -> Self {
113        Self {
114            unused_files: Severity::Error,
115            unused_exports: Severity::Error,
116            unused_types: Severity::Error,
117            private_type_leaks: Severity::Off,
118            unused_dependencies: Severity::Error,
119            unused_dev_dependencies: Severity::Warn,
120            unused_optional_dependencies: Severity::Warn,
121            unused_enum_members: Severity::Error,
122            unused_class_members: Severity::Error,
123            unresolved_imports: Severity::Error,
124            unlisted_dependencies: Severity::Error,
125            duplicate_exports: Severity::Error,
126            type_only_dependencies: Severity::Warn,
127            test_only_dependencies: Severity::Warn,
128            circular_dependencies: Severity::Error,
129            boundary_violation: Severity::Error,
130            coverage_gaps: Severity::Off,
131            feature_flags: Severity::Off,
132            stale_suppressions: Severity::Warn,
133            unused_catalog_entries: Severity::Warn,
134        }
135    }
136}
137
138impl RulesConfig {
139    /// Apply a partial rules config on top. Only `Some` fields override.
140    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
141        if let Some(s) = partial.unused_files {
142            self.unused_files = s;
143        }
144        if let Some(s) = partial.unused_exports {
145            self.unused_exports = s;
146        }
147        if let Some(s) = partial.unused_types {
148            self.unused_types = s;
149        }
150        if let Some(s) = partial.private_type_leaks {
151            self.private_type_leaks = s;
152        }
153        if let Some(s) = partial.unused_dependencies {
154            self.unused_dependencies = s;
155        }
156        if let Some(s) = partial.unused_dev_dependencies {
157            self.unused_dev_dependencies = s;
158        }
159        if let Some(s) = partial.unused_optional_dependencies {
160            self.unused_optional_dependencies = s;
161        }
162        if let Some(s) = partial.unused_enum_members {
163            self.unused_enum_members = s;
164        }
165        if let Some(s) = partial.unused_class_members {
166            self.unused_class_members = s;
167        }
168        if let Some(s) = partial.unresolved_imports {
169            self.unresolved_imports = s;
170        }
171        if let Some(s) = partial.unlisted_dependencies {
172            self.unlisted_dependencies = s;
173        }
174        if let Some(s) = partial.duplicate_exports {
175            self.duplicate_exports = s;
176        }
177        if let Some(s) = partial.type_only_dependencies {
178            self.type_only_dependencies = s;
179        }
180        if let Some(s) = partial.test_only_dependencies {
181            self.test_only_dependencies = s;
182        }
183        if let Some(s) = partial.circular_dependencies {
184            self.circular_dependencies = s;
185        }
186        if let Some(s) = partial.boundary_violation {
187            self.boundary_violation = s;
188        }
189        if let Some(s) = partial.coverage_gaps {
190            self.coverage_gaps = s;
191        }
192        if let Some(s) = partial.feature_flags {
193            self.feature_flags = s;
194        }
195        if let Some(s) = partial.stale_suppressions {
196            self.stale_suppressions = s;
197        }
198        if let Some(s) = partial.unused_catalog_entries {
199            self.unused_catalog_entries = s;
200        }
201    }
202}
203
204/// Partial per-issue-type severity for overrides. All fields optional.
205#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
206#[serde(rename_all = "kebab-case")]
207pub struct PartialRulesConfig {
208    #[serde(
209        default,
210        alias = "unused-file",
211        skip_serializing_if = "Option::is_none"
212    )]
213    pub unused_files: Option<Severity>,
214    #[serde(
215        default,
216        alias = "unused-export",
217        skip_serializing_if = "Option::is_none"
218    )]
219    pub unused_exports: Option<Severity>,
220    #[serde(
221        default,
222        alias = "unused-type",
223        skip_serializing_if = "Option::is_none"
224    )]
225    pub unused_types: Option<Severity>,
226    #[serde(
227        default,
228        alias = "private-type-leak",
229        skip_serializing_if = "Option::is_none"
230    )]
231    pub private_type_leaks: Option<Severity>,
232    #[serde(
233        default,
234        alias = "unused-dependency",
235        skip_serializing_if = "Option::is_none"
236    )]
237    pub unused_dependencies: Option<Severity>,
238    #[serde(
239        default,
240        alias = "unused-dev-dependency",
241        skip_serializing_if = "Option::is_none"
242    )]
243    pub unused_dev_dependencies: Option<Severity>,
244    #[serde(
245        default,
246        alias = "unused-optional-dependency",
247        skip_serializing_if = "Option::is_none"
248    )]
249    pub unused_optional_dependencies: Option<Severity>,
250    #[serde(
251        default,
252        alias = "unused-enum-member",
253        skip_serializing_if = "Option::is_none"
254    )]
255    pub unused_enum_members: Option<Severity>,
256    #[serde(
257        default,
258        alias = "unused-class-member",
259        skip_serializing_if = "Option::is_none"
260    )]
261    pub unused_class_members: Option<Severity>,
262    #[serde(
263        default,
264        alias = "unresolved-import",
265        skip_serializing_if = "Option::is_none"
266    )]
267    pub unresolved_imports: Option<Severity>,
268    #[serde(
269        default,
270        alias = "unlisted-dependency",
271        skip_serializing_if = "Option::is_none"
272    )]
273    pub unlisted_dependencies: Option<Severity>,
274    #[serde(
275        default,
276        alias = "duplicate-export",
277        skip_serializing_if = "Option::is_none"
278    )]
279    pub duplicate_exports: Option<Severity>,
280    #[serde(
281        default,
282        alias = "type-only-dependency",
283        skip_serializing_if = "Option::is_none"
284    )]
285    pub type_only_dependencies: Option<Severity>,
286    #[serde(
287        default,
288        alias = "test-only-dependency",
289        skip_serializing_if = "Option::is_none"
290    )]
291    pub test_only_dependencies: Option<Severity>,
292    #[serde(
293        default,
294        alias = "circular-dependency",
295        skip_serializing_if = "Option::is_none"
296    )]
297    pub circular_dependencies: Option<Severity>,
298    #[serde(
299        default,
300        alias = "boundary-violations",
301        skip_serializing_if = "Option::is_none"
302    )]
303    pub boundary_violation: Option<Severity>,
304    #[serde(
305        default,
306        alias = "coverage-gap",
307        skip_serializing_if = "Option::is_none"
308    )]
309    pub coverage_gaps: Option<Severity>,
310    #[serde(
311        default,
312        alias = "feature-flag",
313        skip_serializing_if = "Option::is_none"
314    )]
315    pub feature_flags: Option<Severity>,
316    #[serde(
317        default,
318        alias = "stale-suppression",
319        skip_serializing_if = "Option::is_none"
320    )]
321    pub stale_suppressions: Option<Severity>,
322    #[serde(
323        default,
324        alias = "unused-catalog-entry",
325        skip_serializing_if = "Option::is_none"
326    )]
327    pub unused_catalog_entries: Option<Severity>,
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn rules_default_severities() {
336        let rules = RulesConfig::default();
337        assert_eq!(rules.unused_files, Severity::Error);
338        assert_eq!(rules.unused_exports, Severity::Error);
339        assert_eq!(rules.unused_types, Severity::Error);
340        assert_eq!(rules.private_type_leaks, Severity::Off);
341        assert_eq!(rules.unused_dependencies, Severity::Error);
342        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
343        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
344        assert_eq!(rules.unused_enum_members, Severity::Error);
345        assert_eq!(rules.unused_class_members, Severity::Error);
346        assert_eq!(rules.unresolved_imports, Severity::Error);
347        assert_eq!(rules.unlisted_dependencies, Severity::Error);
348        assert_eq!(rules.duplicate_exports, Severity::Error);
349        assert_eq!(rules.type_only_dependencies, Severity::Warn);
350        assert_eq!(rules.test_only_dependencies, Severity::Warn);
351        assert_eq!(rules.circular_dependencies, Severity::Error);
352        assert_eq!(rules.boundary_violation, Severity::Error);
353        assert_eq!(rules.coverage_gaps, Severity::Off);
354        assert_eq!(rules.feature_flags, Severity::Off);
355        assert_eq!(rules.stale_suppressions, Severity::Warn);
356        assert_eq!(rules.unused_catalog_entries, Severity::Warn);
357    }
358
359    #[test]
360    fn rules_deserialize_kebab_case() {
361        let json_str = r#"{
362            "unused-files": "error",
363            "unused-exports": "warn",
364            "unused-types": "off"
365        }"#;
366        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
367        assert_eq!(rules.unused_files, Severity::Error);
368        assert_eq!(rules.unused_exports, Severity::Warn);
369        assert_eq!(rules.unused_types, Severity::Off);
370        // Unset fields default to error
371        assert_eq!(rules.unresolved_imports, Severity::Error);
372    }
373
374    #[test]
375    fn rules_deserialize_circular_dependency_alias() {
376        let json_str = r#"{
377            "circular-dependency": "off"
378        }"#;
379        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
380        assert_eq!(rules.circular_dependencies, Severity::Off);
381    }
382
383    #[test]
384    fn rules_deserialize_boundary_violations_alias() {
385        let json_str = r#"{
386            "boundary-violations": "off"
387        }"#;
388        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
389        assert_eq!(rules.boundary_violation, Severity::Off);
390
391        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
392        assert_eq!(partial.boundary_violation, Some(Severity::Off));
393    }
394
395    #[test]
396    fn rules_deserialize_singular_aliases_for_every_plural_rule() {
397        // Mirrors the LSP's per-diagnostic singular codes (e.g. "unused-export")
398        // so users who copy the form they see in IDE warnings into their config
399        // get their stated severity honored instead of silently dropped.
400        let json_str = r#"{
401            "unused-file": "off",
402            "unused-export": "off",
403            "unused-type": "off",
404            "private-type-leak": "warn",
405            "unused-dependency": "off",
406            "unused-dev-dependency": "off",
407            "unused-optional-dependency": "off",
408            "unused-enum-member": "off",
409            "unused-class-member": "off",
410            "unresolved-import": "off",
411            "unlisted-dependency": "off",
412            "duplicate-export": "off",
413            "type-only-dependency": "off",
414            "test-only-dependency": "off",
415            "coverage-gap": "warn",
416            "feature-flag": "warn",
417            "stale-suppression": "off",
418            "unused-catalog-entry": "error"
419        }"#;
420
421        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
422        assert_eq!(rules.unused_files, Severity::Off);
423        assert_eq!(rules.unused_exports, Severity::Off);
424        assert_eq!(rules.unused_types, Severity::Off);
425        assert_eq!(rules.private_type_leaks, Severity::Warn);
426        assert_eq!(rules.unused_dependencies, Severity::Off);
427        assert_eq!(rules.unused_dev_dependencies, Severity::Off);
428        assert_eq!(rules.unused_optional_dependencies, Severity::Off);
429        assert_eq!(rules.unused_enum_members, Severity::Off);
430        assert_eq!(rules.unused_class_members, Severity::Off);
431        assert_eq!(rules.unresolved_imports, Severity::Off);
432        assert_eq!(rules.unlisted_dependencies, Severity::Off);
433        assert_eq!(rules.duplicate_exports, Severity::Off);
434        assert_eq!(rules.type_only_dependencies, Severity::Off);
435        assert_eq!(rules.test_only_dependencies, Severity::Off);
436        assert_eq!(rules.coverage_gaps, Severity::Warn);
437        assert_eq!(rules.feature_flags, Severity::Warn);
438        assert_eq!(rules.stale_suppressions, Severity::Off);
439        assert_eq!(rules.unused_catalog_entries, Severity::Error);
440
441        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
442        assert_eq!(partial.unused_files, Some(Severity::Off));
443        assert_eq!(partial.unused_exports, Some(Severity::Off));
444        assert_eq!(partial.unused_types, Some(Severity::Off));
445        assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
446        assert_eq!(partial.unused_dependencies, Some(Severity::Off));
447        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
448        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
449        assert_eq!(partial.unused_enum_members, Some(Severity::Off));
450        assert_eq!(partial.unused_class_members, Some(Severity::Off));
451        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
452        assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
453        assert_eq!(partial.duplicate_exports, Some(Severity::Off));
454        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
455        assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
456        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
457        assert_eq!(partial.feature_flags, Some(Severity::Warn));
458        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
459        assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
460    }
461
462    #[test]
463    fn severity_from_str() {
464        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
465        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
466        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
467        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
468        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
469        assert!("invalid".parse::<Severity>().is_err());
470    }
471
472    #[test]
473    fn apply_partial_only_some_fields() {
474        let mut rules = RulesConfig::default();
475        let partial = PartialRulesConfig {
476            unused_files: Some(Severity::Warn),
477            unused_exports: Some(Severity::Off),
478            ..Default::default()
479        };
480        rules.apply_partial(&partial);
481        assert_eq!(rules.unused_files, Severity::Warn);
482        assert_eq!(rules.unused_exports, Severity::Off);
483        // Unset fields unchanged
484        assert_eq!(rules.unused_types, Severity::Error);
485        assert_eq!(rules.unresolved_imports, Severity::Error);
486    }
487
488    #[test]
489    fn severity_display() {
490        assert_eq!(Severity::Error.to_string(), "error");
491        assert_eq!(Severity::Warn.to_string(), "warn");
492        assert_eq!(Severity::Off.to_string(), "off");
493    }
494
495    #[test]
496    fn apply_partial_all_none_changes_nothing() {
497        let mut rules = RulesConfig::default();
498        let original = rules.clone();
499        let partial = PartialRulesConfig::default(); // all None
500        rules.apply_partial(&partial);
501        assert_eq!(rules.unused_files, original.unused_files);
502        assert_eq!(rules.unused_exports, original.unused_exports);
503        assert_eq!(
504            rules.type_only_dependencies,
505            original.type_only_dependencies
506        );
507    }
508
509    #[test]
510    fn apply_partial_all_fields_set() {
511        let mut rules = RulesConfig::default();
512        let partial = PartialRulesConfig {
513            unused_files: Some(Severity::Off),
514            unused_exports: Some(Severity::Off),
515            unused_types: Some(Severity::Off),
516            private_type_leaks: Some(Severity::Off),
517            unused_dependencies: Some(Severity::Off),
518            unused_dev_dependencies: Some(Severity::Off),
519            unused_optional_dependencies: Some(Severity::Off),
520            unused_enum_members: Some(Severity::Off),
521            unused_class_members: Some(Severity::Off),
522            unresolved_imports: Some(Severity::Off),
523            unlisted_dependencies: Some(Severity::Off),
524            duplicate_exports: Some(Severity::Off),
525            type_only_dependencies: Some(Severity::Off),
526            test_only_dependencies: Some(Severity::Off),
527            circular_dependencies: Some(Severity::Off),
528            boundary_violation: Some(Severity::Off),
529            coverage_gaps: Some(Severity::Off),
530            feature_flags: Some(Severity::Off),
531            stale_suppressions: Some(Severity::Off),
532            unused_catalog_entries: Some(Severity::Off),
533        };
534        rules.apply_partial(&partial);
535        assert_eq!(rules.unused_files, Severity::Off);
536        assert_eq!(rules.private_type_leaks, Severity::Off);
537        assert_eq!(rules.circular_dependencies, Severity::Off);
538        assert_eq!(rules.type_only_dependencies, Severity::Off);
539        assert_eq!(rules.test_only_dependencies, Severity::Off);
540        assert_eq!(rules.boundary_violation, Severity::Off);
541        assert_eq!(rules.coverage_gaps, Severity::Off);
542        assert_eq!(rules.feature_flags, Severity::Off);
543        assert_eq!(rules.stale_suppressions, Severity::Off);
544    }
545
546    #[test]
547    fn rules_config_defaults_include_optional_deps() {
548        let rules = RulesConfig::default();
549        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
550    }
551
552    #[test]
553    fn severity_from_str_case_insensitive() {
554        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
555        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
556        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
557        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
558        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
559    }
560
561    #[test]
562    fn severity_from_str_invalid_returns_error() {
563        let result = "critical".parse::<Severity>();
564        assert!(result.is_err());
565        let err = result.unwrap_err();
566        assert!(
567            err.contains("unknown severity"),
568            "Expected descriptive error, got: {err}"
569        );
570    }
571
572    // ── PartialRulesConfig deserialization ───────────────────────────
573
574    #[test]
575    fn partial_rules_empty_json() {
576        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
577        assert!(partial.unused_files.is_none());
578        assert!(partial.unused_exports.is_none());
579        assert!(partial.unused_types.is_none());
580        assert!(partial.unused_dependencies.is_none());
581        assert!(partial.circular_dependencies.is_none());
582        assert!(partial.boundary_violation.is_none());
583        assert!(partial.coverage_gaps.is_none());
584        assert!(partial.feature_flags.is_none());
585        assert!(partial.stale_suppressions.is_none());
586    }
587
588    #[test]
589    fn partial_rules_subset_json() {
590        let json = r#"{
591            "unused-files": "warn",
592            "circular-dependencies": "off"
593        }"#;
594        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
595        assert_eq!(partial.unused_files, Some(Severity::Warn));
596        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
597        assert!(partial.unused_exports.is_none());
598    }
599
600    #[test]
601    fn partial_rules_deserialize_circular_dependency_alias() {
602        let json = r#"{
603            "circular-dependency": "warn"
604        }"#;
605        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
606        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
607    }
608
609    #[test]
610    fn partial_rules_all_fields_json() {
611        let json = r#"{
612            "unused-files": "error",
613            "unused-exports": "warn",
614            "unused-types": "off",
615            "unused-dependencies": "error",
616            "unused-dev-dependencies": "warn",
617            "unused-optional-dependencies": "off",
618            "unused-enum-members": "error",
619            "unused-class-members": "warn",
620            "unresolved-imports": "off",
621            "unlisted-dependencies": "error",
622            "duplicate-exports": "warn",
623            "type-only-dependencies": "off",
624            "test-only-dependencies": "error",
625            "circular-dependencies": "warn",
626            "boundary-violation": "off",
627            "coverage-gaps": "warn",
628            "feature-flags": "error",
629            "stale-suppressions": "off"
630        }"#;
631        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
632        assert_eq!(partial.unused_files, Some(Severity::Error));
633        assert_eq!(partial.unused_exports, Some(Severity::Warn));
634        assert_eq!(partial.unused_types, Some(Severity::Off));
635        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
636        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
637        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
638        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
639        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
640        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
641        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
642        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
643        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
644        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
645        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
646        assert_eq!(partial.boundary_violation, Some(Severity::Off));
647        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
648        assert_eq!(partial.feature_flags, Some(Severity::Error));
649        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
650    }
651
652    // ── PartialRulesConfig serialization skip_serializing_if ────────
653
654    #[test]
655    fn partial_rules_none_fields_not_serialized() {
656        let partial = PartialRulesConfig::default();
657        let json = serde_json::to_string(&partial).unwrap();
658        assert_eq!(
659            json, "{}",
660            "all-None partial should serialize to empty object"
661        );
662    }
663
664    #[test]
665    fn partial_rules_some_fields_serialized() {
666        let partial = PartialRulesConfig {
667            unused_files: Some(Severity::Warn),
668            ..Default::default()
669        };
670        let json = serde_json::to_string(&partial).unwrap();
671        assert!(json.contains("unused-files"));
672        assert!(!json.contains("unused-exports"));
673    }
674
675    // ── Severity JSON deserialization ────────────────────────────────
676
677    #[test]
678    fn severity_json_deserialization() {
679        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
680        assert_eq!(error, Severity::Error);
681
682        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
683        assert_eq!(warn, Severity::Warn);
684
685        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
686        assert_eq!(off, Severity::Off);
687    }
688
689    #[test]
690    fn severity_invalid_json_value_rejected() {
691        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
692        assert!(result.is_err());
693    }
694
695    // ── Severity default ────────────────────────────────────────────
696
697    #[test]
698    fn severity_default_is_error() {
699        assert_eq!(Severity::default(), Severity::Error);
700    }
701
702    // ── RulesConfig JSON serialize roundtrip ─────────────────────────
703
704    #[test]
705    fn rules_config_json_roundtrip() {
706        let rules = RulesConfig {
707            unused_files: Severity::Warn,
708            unused_exports: Severity::Off,
709            type_only_dependencies: Severity::Error,
710            ..RulesConfig::default()
711        };
712        let json = serde_json::to_string(&rules).unwrap();
713        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
714        assert_eq!(restored.unused_files, Severity::Warn);
715        assert_eq!(restored.unused_exports, Severity::Off);
716        assert_eq!(restored.type_only_dependencies, Severity::Error);
717        assert_eq!(restored.unused_dependencies, Severity::Error); // default
718    }
719
720    // ── apply_partial preserves type_only/test_only defaults ────────
721
722    #[test]
723    fn apply_partial_preserves_type_only_default() {
724        let mut rules = RulesConfig::default();
725        let partial = PartialRulesConfig {
726            unused_files: Some(Severity::Off),
727            ..Default::default()
728        };
729        rules.apply_partial(&partial);
730        // type_only_dependencies defaults to Warn, should be preserved
731        assert_eq!(rules.type_only_dependencies, Severity::Warn);
732        assert_eq!(rules.test_only_dependencies, Severity::Warn);
733    }
734}