Skip to main content

imferno_core/diagnostics/
rules.rs

1//! ESLint-style per-rule severity configuration.
2//!
3//! A [`RulesConfig`] maps rule identifiers to [`RuleSeverity`] overrides.
4//! Rules can be identified by their full normalised code
5//! (`"ST2067-3:2020:7.2.2/SegmentDuration"`) or by their short
6//! suffix (`"SegmentDuration"`).
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use crate::diagnostics::codes::ValidationCode;
12use crate::{Severity, ValidationReport};
13
14/// Per-rule severity override.
15///
16/// Mirrors ESLint's `"off"` / `"warn"` / `"error"` vocabulary, extended with
17/// the two IMF severity levels.
18#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum RuleSeverity {
22    /// Suppress this rule entirely — matching issues are removed from output.
23    Off,
24    /// Remap to `Info`.
25    Info,
26    /// Remap to `Warning`.
27    Warn,
28    /// Remap to `Error`.
29    Error,
30    /// Remap to `Critical`.
31    Critical,
32}
33
34/// ESLint-style per-rule severity overrides.
35///
36/// Keys are either:
37/// - A rule suffix — `"SegmentDuration"` — matched against the part of the
38///   issue code after the last `/`.
39/// - A full normalised code — `"ST2067-3:2020:7.2.2/SegmentDuration"`.
40///
41/// Values are the desired [`RuleSeverity`], or [`RuleSeverity::Off`] to
42/// suppress the rule entirely.
43///
44/// An empty map (the default) is a no-op.
45#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct RulesConfig(HashMap<String, RuleSeverity>);
48
49impl RulesConfig {
50    /// Set the severity for a typed validation code.
51    ///
52    /// ```
53    /// use imferno_core::diagnostics::rules::{RulesConfig, RuleSeverity};
54    /// use imferno_core::assetmap::codes::St2067_2_2020;
55    ///
56    /// let mut rules = RulesConfig::default();
57    /// rules.set(St2067_2_2020::FileNotFound, RuleSeverity::Critical);
58    /// ```
59    pub fn set(&mut self, code: impl ValidationCode, severity: RuleSeverity) {
60        self.0.insert(code.code().to_string(), severity);
61    }
62
63    /// Set severity by raw string key (rule suffix or full code).
64    pub fn set_raw(&mut self, key: String, severity: RuleSeverity) {
65        self.0.insert(key, severity);
66    }
67
68    /// Returns `true` if no overrides are configured.
69    pub fn is_empty(&self) -> bool {
70        self.0.is_empty()
71    }
72
73    /// Number of configured overrides.
74    pub fn len(&self) -> usize {
75        self.0.len()
76    }
77
78    /// Iterate over configured overrides.
79    pub fn iter(&self) -> impl Iterator<Item = (&String, &RuleSeverity)> {
80        self.0.iter()
81    }
82}
83
84fn rule_matches(code: &str, key: &str) -> bool {
85    // Full normalised code match first, then suffix match.
86    code == key || code.rsplit('/').next() == Some(key)
87}
88
89impl ValidationReport {
90    /// Apply ESLint-style per-rule severity overrides.
91    ///
92    /// Issues whose rule matches a [`RuleSeverity::Off`] entry are removed.
93    /// All other matching issues have their severity remapped and re-bucketed.
94    /// `is_playable` and `is_compliant` are recomputed from the updated buckets.
95    ///
96    /// An empty [`RulesConfig`] is a no-op (fast path, no allocation).
97    pub fn apply_rules(mut self, rules: &RulesConfig) -> Self {
98        if rules.is_empty() {
99            return self;
100        }
101
102        let all: Vec<_> = self
103            .critical
104            .drain(..)
105            .chain(self.errors.drain(..))
106            .chain(self.warnings.drain(..))
107            .chain(self.info.drain(..))
108            .collect();
109
110        for mut issue in all {
111            let override_sev = rules
112                .iter()
113                .find(|(k, _)| rule_matches(&issue.code, k))
114                .map(|(_, v)| v);
115
116            match override_sev {
117                Some(RuleSeverity::Off) => {} // drop
118                Some(RuleSeverity::Info) => {
119                    issue.severity = Severity::Info;
120                    self.info.push(issue);
121                }
122                Some(RuleSeverity::Warn) => {
123                    issue.severity = Severity::Warning;
124                    self.warnings.push(issue);
125                }
126                Some(RuleSeverity::Error) => {
127                    issue.severity = Severity::Error;
128                    self.errors.push(issue);
129                }
130                Some(RuleSeverity::Critical) => {
131                    issue.severity = Severity::Critical;
132                    self.critical.push(issue);
133                }
134                None => match issue.severity {
135                    Severity::Critical => self.critical.push(issue),
136                    Severity::Error => self.errors.push(issue),
137                    Severity::Warning => self.warnings.push(issue),
138                    Severity::Info => self.info.push(issue),
139                },
140            }
141        }
142
143        self.is_playable = self.critical.is_empty();
144        self.is_compliant = self.critical.is_empty() && self.errors.is_empty();
145        self
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn rules_config_accessors() {
155        let mut rules = RulesConfig::default();
156        assert!(rules.is_empty());
157        assert_eq!(rules.len(), 0);
158
159        rules.set(
160            crate::assetmap::codes::St2067_2_2020::FileNotFound,
161            RuleSeverity::Critical,
162        );
163        assert!(!rules.is_empty());
164        assert_eq!(rules.len(), 1);
165        assert_eq!(rules.iter().count(), 1);
166    }
167
168    #[test]
169    fn rules_config_serde_round_trip() {
170        let mut rules = RulesConfig::default();
171        rules.set(
172            crate::assetmap::codes::St2067_2_2020::FileNotFound,
173            RuleSeverity::Off,
174        );
175        let json = serde_json::to_string(&rules).unwrap();
176        let deserialized: RulesConfig = serde_json::from_str(&json).unwrap();
177        assert_eq!(deserialized.len(), 1);
178    }
179}