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