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