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:
5//! - Full normalised code — `"ST2067-3:2020:7.2.2/SegmentDuration"`.
6//! - Rule suffix — `"SegmentDuration"`.
7//! - Glob pattern with `*` as a single-segment wildcard —
8//!   `"XSD/*"`, `"XSD/PatternInvalid/*"`, `"ST2067-*:2020:*/EditRate"`.
9//! - Source prefix — `"source:XsdLayer"`, `"source:ProseRule"`,
10//!   `"source:EngineInternal"` — selects every issue whose
11//!   [`IssueSource`] inference matches.
12//!
13//! When multiple keys match a single issue, the most-specific key wins
14//! (full code > suffix > glob > source-prefix). Within glob keys, the
15//! one with the longer literal prefix is more specific.
16
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20use crate::diagnostics::codes::ValidationCode;
21use crate::diagnostics::IssueSource;
22use crate::{Severity, ValidationReport};
23
24/// Per-rule severity override.
25///
26/// Mirrors ESLint's `"off"` / `"warn"` / `"error"` vocabulary, extended with
27/// the two IMF severity levels.
28#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum RuleSeverity {
32    /// Suppress this rule entirely — matching issues are removed from output.
33    Off,
34    /// Remap to `Info`.
35    Info,
36    /// Remap to `Warning`.
37    Warn,
38    /// Remap to `Error`.
39    Error,
40    /// Remap to `Critical`.
41    Critical,
42}
43
44/// A diagnostic about a `RulesConfig` key that the engine couldn't match.
45///
46/// Produced by [`RulesConfig::validate`]. Operators can use these at
47/// config-load time to catch typos and unsupported syntax before any
48/// validation work runs.
49#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct RuleValidationWarning {
52    /// The configured key that triggered the warning.
53    pub key: String,
54    /// Why it triggered.
55    pub reason: RuleValidationReason,
56}
57
58#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum RuleValidationReason {
62    /// `source:<variant>` — `<variant>` isn't a known [`IssueSource`].
63    UnknownSource { variant: String },
64    /// The key parsed but matched zero codes in the supplied universe.
65    MatchesNothing,
66    /// The key used syntax the matcher doesn't support (e.g. `**`).
67    UnsupportedPattern { hint: String },
68}
69
70/// ESLint-style per-rule severity overrides.
71///
72/// Keys are either:
73/// - A rule suffix — `"SegmentDuration"` — matched against the part of the
74///   issue code after the last `/`.
75/// - A full normalised code — `"ST2067-3:2020:7.2.2/SegmentDuration"`.
76///
77/// Values are the desired [`RuleSeverity`], or [`RuleSeverity::Off`] to
78/// suppress the rule entirely.
79///
80/// An empty map (the default) is a no-op.
81#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct RulesConfig(HashMap<String, RuleSeverity>);
84
85impl RulesConfig {
86    /// Set the severity for a typed validation code.
87    ///
88    /// ```
89    /// use imferno_core::diagnostics::rules::{RulesConfig, RuleSeverity};
90    /// use imferno_core::assetmap::codes::St2067_2_2020;
91    ///
92    /// let mut rules = RulesConfig::default();
93    /// rules.set(St2067_2_2020::FileNotFound, RuleSeverity::Critical);
94    /// ```
95    pub fn set(&mut self, code: impl ValidationCode, severity: RuleSeverity) {
96        self.0.insert(code.code().to_string(), severity);
97    }
98
99    /// Set severity by raw string key (rule suffix or full code).
100    pub fn set_raw(&mut self, key: String, severity: RuleSeverity) {
101        self.0.insert(key, severity);
102    }
103
104    /// Returns `true` if no overrides are configured.
105    pub fn is_empty(&self) -> bool {
106        self.0.is_empty()
107    }
108
109    /// Number of configured overrides.
110    pub fn len(&self) -> usize {
111        self.0.len()
112    }
113
114    /// Check the configured keys against a known-code universe and return
115    /// a list of warnings for keys that match nothing. Useful at config-load
116    /// time so operators get fast feedback on typos and unsupported syntax.
117    ///
118    /// The caller supplies the known-code universe (typically obtained from
119    /// the `listRules` enumerator on the NAPI/wasm boundary, or by iterating
120    /// every typed code enum's `ALL` const).
121    ///
122    /// Warning categories:
123    /// - `UnknownSource` — `source:Foo` where `Foo` isn't a known variant.
124    /// - `MatchesNothing` — the key parsed fine but didn't match any code
125    ///   in `known_codes` (typo, removed rule, or fictional namespace).
126    /// - `UnsupportedPattern` — syntax we don't implement (e.g. `**`).
127    pub fn validate<I, S>(&self, known_codes: I) -> Vec<RuleValidationWarning>
128    where
129        I: IntoIterator<Item = S>,
130        S: AsRef<str>,
131    {
132        let codes: Vec<String> = known_codes
133            .into_iter()
134            .map(|c| c.as_ref().to_string())
135            .collect();
136        let mut warnings = Vec::new();
137        for key in self.0.keys() {
138            if let Some(rest) = key.strip_prefix("source:") {
139                if parse_source(rest).is_none() {
140                    warnings.push(RuleValidationWarning {
141                        key: key.clone(),
142                        reason: RuleValidationReason::UnknownSource {
143                            variant: rest.to_string(),
144                        },
145                    });
146                }
147                continue;
148            }
149            // `**` is reserved for any-depth wildcards but unsupported by
150            // `glob_match` (each `*` matches a single segment). Flag rather
151            // than silently fail to match.
152            if key.contains("**") {
153                warnings.push(RuleValidationWarning {
154                    key: key.clone(),
155                    reason: RuleValidationReason::UnsupportedPattern {
156                        hint: "`**` (any-depth wildcard) is not supported; use `*/*` or `source:<Variant>` for broader scopes".to_string(),
157                    },
158                });
159                continue;
160            }
161            let matches_any = codes.iter().any(|c| match_specificity(c, key).is_some());
162            if !matches_any {
163                warnings.push(RuleValidationWarning {
164                    key: key.clone(),
165                    reason: RuleValidationReason::MatchesNothing,
166                });
167            }
168        }
169        warnings.sort_by(|a, b| a.key.cmp(&b.key));
170        warnings
171    }
172
173    /// Iterate over configured overrides.
174    pub fn iter(&self) -> impl Iterator<Item = (&String, &RuleSeverity)> {
175        self.0.iter()
176    }
177}
178
179/// How specific a matching rule key is. Higher specificity wins when
180/// multiple keys match the same issue. Variants are ordered from least
181/// to most specific so `Ord` reflects "more specific = greater".
182///
183/// Ranking rationale: full-code keys carry the most caller intent
184/// (exact target). Globs are anchored to a position in the code path,
185/// so they beat bare suffix matches (which match anywhere named X).
186/// Source-prefix keys are the broadest backstop.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
188enum Specificity {
189    /// `source:XsdLayer` etc. — broadest scope, lowest specificity.
190    SourcePrefix,
191    /// Bare suffix match (e.g. `SegmentDuration` — matches anywhere
192    /// the rule code ends with that segment).
193    Suffix,
194    /// Glob pattern with `*`. Inner tuple breaks ties: longer literal
195    /// prefix wins, then longer total key length.
196    Glob(usize, usize),
197    /// Full-code exact match — most specific.
198    FullCode,
199}
200
201/// Test whether `key` matches `code` and, if so, how specific the match is.
202/// Returns `None` for no match.
203fn match_specificity(code: &str, key: &str) -> Option<Specificity> {
204    // 1. Source-prefix keys: `source:XsdLayer` etc. Lowest specificity
205    //    (intentional — operators write these as a broad backstop).
206    if let Some(rest) = key.strip_prefix("source:") {
207        return parse_source(rest)
208            .filter(|src| IssueSource::from_code(code) == *src)
209            .map(|_| Specificity::SourcePrefix);
210    }
211
212    // 2. Glob match: any `*` segment matches one path segment.
213    if key.contains('*') {
214        return glob_match(code, key)
215            .then_some(Specificity::Glob(literal_prefix_len(key), key.len()));
216    }
217
218    // 3. Full-code exact match (key with `/` or `:` reads as a full code).
219    if code == key {
220        return Some(Specificity::FullCode);
221    }
222
223    // 4. Suffix match — final segment after the last `/`.
224    if code.rsplit('/').next() == Some(key) {
225        return Some(Specificity::Suffix);
226    }
227
228    None
229}
230
231/// Match a code against a glob key using `*` as a single-segment wildcard.
232/// `*` matches exactly one segment between `/`s; e.g. `XSD/*` matches
233/// `XSD/TypeInvalid` but not `XSD/TypeInvalid/IssueDate`. A trailing `*`
234/// segment can be expanded to match the remaining tail by writing it as
235/// the last segment with nothing after — but for explicit
236/// "match-anything-deeper" use `XSD/*/*`, etc. To match any depth,
237/// callers can use the source-prefix form (`source:XsdLayer`).
238///
239/// Mid-segment wildcards (e.g. `ST2067-*:2020`) are supported via
240/// prefix/suffix anchoring around a single `*` per segment.
241fn glob_match(code: &str, key: &str) -> bool {
242    let code_parts: Vec<&str> = code.split('/').collect();
243    let key_parts: Vec<&str> = key.split('/').collect();
244    if code_parts.len() != key_parts.len() {
245        return false;
246    }
247    code_parts
248        .iter()
249        .zip(key_parts.iter())
250        .all(|(c, k)| segment_matches(c, k))
251}
252
253/// Match one path segment, supporting any number of `*` wildcards. Each
254/// `*` is a "match-any-substring" wildcard anchored by the literals
255/// before and after it. Standard left-to-right greedy glob matching.
256fn segment_matches(code_seg: &str, key_seg: &str) -> bool {
257    if !key_seg.contains('*') {
258        return code_seg == key_seg;
259    }
260    let pieces: Vec<&str> = key_seg.split('*').collect();
261    // `split` on `*` yields N+1 pieces for N stars. The first must be a
262    // prefix of the code segment; the last must be a suffix; the rest
263    // must appear in order in between.
264    let first = pieces.first().copied().unwrap_or("");
265    let last = pieces.last().copied().unwrap_or("");
266    if !code_seg.starts_with(first) || !code_seg.ends_with(last) {
267        return false;
268    }
269    if pieces.len() == 1 {
270        // No `*` at all — handled by the early-return above, but keep
271        // this branch defensive.
272        return code_seg == first;
273    }
274    // Ensure prefix + suffix don't overlap (e.g. key "a*b" against
275    // code "ab" — len check confirms there's room for the middle
276    // pieces, even if there are none).
277    if code_seg.len() < first.len() + last.len() {
278        return false;
279    }
280
281    // Walk the middle pieces in order. Each must appear after the
282    // previous match. Skip empties (consecutive `**` collapses to one).
283    let mut cursor = first.len();
284    let end = code_seg.len() - last.len();
285    for piece in &pieces[1..pieces.len() - 1] {
286        if piece.is_empty() {
287            continue;
288        }
289        match code_seg[cursor..end].find(piece) {
290            Some(offset) => cursor += offset + piece.len(),
291            None => return false,
292        }
293    }
294    true
295}
296
297/// Length of the literal prefix in a glob key (characters before the
298/// first `*`). Used to break ties between glob keys: `XSD/PatternInvalid/*`
299/// (prefix len 17) outranks `XSD/*` (prefix len 4).
300fn literal_prefix_len(key: &str) -> usize {
301    key.find('*').unwrap_or(key.len())
302}
303
304/// Parse a source-prefix variant name (e.g. `XsdLayer`) into an [`IssueSource`].
305///
306/// Matching is case-insensitive so operator-friendly keys like
307/// `source:xsdlayer`, `source:XSDLAYER`, and `source:XsdLayer` are all
308/// accepted. Returns `None` for unknown variants — `RulesConfig::validate()`
309/// (FIX-13) surfaces those as unmatchable-pattern warnings.
310fn parse_source(name: &str) -> Option<IssueSource> {
311    if name.eq_ignore_ascii_case("XsdLayer") {
312        Some(IssueSource::XsdLayer)
313    } else if name.eq_ignore_ascii_case("ProseRule") {
314        Some(IssueSource::ProseRule)
315    } else if name.eq_ignore_ascii_case("EngineInternal") {
316        Some(IssueSource::EngineInternal)
317    } else {
318        None
319    }
320}
321
322impl ValidationReport {
323    /// Apply ESLint-style per-rule severity overrides.
324    ///
325    /// Issues whose rule matches a [`RuleSeverity::Off`] entry are removed.
326    /// All other matching issues have their severity remapped and re-bucketed.
327    /// `is_playable` and `is_compliant` are recomputed from the updated buckets.
328    ///
329    /// When multiple keys match the same issue, the most-specific match
330    /// wins (see module docs). Selection is deterministic across runs.
331    ///
332    /// An empty [`RulesConfig`] is a no-op (fast path, no allocation).
333    pub fn apply_rules(mut self, rules: &RulesConfig) -> Self {
334        if rules.is_empty() {
335            return self;
336        }
337
338        let all: Vec<_> = self
339            .critical
340            .drain(..)
341            .chain(self.errors.drain(..))
342            .chain(self.warnings.drain(..))
343            .chain(self.info.drain(..))
344            .collect();
345
346        for mut issue in all {
347            let matched = rules
348                .iter()
349                .filter_map(|(k, v)| match_specificity(&issue.code, k).map(|s| (s, k, v)))
350                // Pick the highest specificity; on a tie, the longer key
351                // wins (already encoded in `Specificity::Glob`). For all
352                // other tiers, ties are impossible given the grammar.
353                .max_by(|(a, ak, _), (b, bk, _)| a.cmp(b).then_with(|| ak.len().cmp(&bk.len())));
354
355            match matched {
356                Some((_, key, RuleSeverity::Off)) => {
357                    // Suppressed — annotate with the matching rule key
358                    // and park in the suppressed bucket so operators can
359                    // `--show-suppressed` to debug their config.
360                    // Severity is demoted to Info so any iteration over
361                    // `suppressed` doesn't show misleading severities.
362                    issue
363                        .context
364                        .insert("suppressed_by".to_string(), key.clone());
365                    issue.severity = Severity::Info;
366                    self.suppressed.push(issue);
367                }
368                Some((_, _, RuleSeverity::Info)) => {
369                    issue.severity = Severity::Info;
370                    self.info.push(issue);
371                }
372                Some((_, _, RuleSeverity::Warn)) => {
373                    issue.severity = Severity::Warning;
374                    self.warnings.push(issue);
375                }
376                Some((_, _, RuleSeverity::Error)) => {
377                    issue.severity = Severity::Error;
378                    self.errors.push(issue);
379                }
380                Some((_, _, RuleSeverity::Critical)) => {
381                    issue.severity = Severity::Critical;
382                    self.critical.push(issue);
383                }
384                None => match issue.severity {
385                    Severity::Critical => self.critical.push(issue),
386                    Severity::Error => self.errors.push(issue),
387                    Severity::Warning => self.warnings.push(issue),
388                    Severity::Info => self.info.push(issue),
389                },
390            }
391        }
392
393        self.is_playable = self.critical.is_empty();
394        self.is_compliant = self.critical.is_empty() && self.errors.is_empty();
395        self
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn rules_config_accessors() {
405        let mut rules = RulesConfig::default();
406        assert!(rules.is_empty());
407        assert_eq!(rules.len(), 0);
408
409        rules.set(
410            crate::assetmap::codes::St2067_2_2020::FileNotFound,
411            RuleSeverity::Critical,
412        );
413        assert!(!rules.is_empty());
414        assert_eq!(rules.len(), 1);
415        assert_eq!(rules.iter().count(), 1);
416    }
417
418    #[test]
419    fn rules_config_serde_round_trip() {
420        let mut rules = RulesConfig::default();
421        rules.set(
422            crate::assetmap::codes::St2067_2_2020::FileNotFound,
423            RuleSeverity::Off,
424        );
425        let json = serde_json::to_string(&rules).unwrap();
426        let deserialized: RulesConfig = serde_json::from_str(&json).unwrap();
427        assert_eq!(deserialized.len(), 1);
428    }
429
430    use crate::diagnostics::{Category, IssueSource, Location, ValidationIssue, ValidationProfile};
431
432    fn issue(code: &str, severity: Severity) -> ValidationIssue {
433        ValidationIssue::new(severity, Category::Schema, code, "test")
434            .with_location(Location::new())
435    }
436
437    fn report_with(issues: Vec<ValidationIssue>) -> ValidationReport {
438        let mut r = ValidationReport::new(ValidationProfile::SMPTE);
439        for i in issues {
440            r.add(i);
441        }
442        r
443    }
444
445    #[test]
446    fn rule_matches_supports_single_segment_glob() {
447        assert!(match_specificity("XSD/TypeInvalid", "XSD/*").is_some());
448        // Different depth — `XSD/*` is one segment past XSD, not arbitrary depth.
449        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "XSD/*").is_none());
450        // But `XSD/*/*` matches two levels deep.
451        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "XSD/*/*").is_some());
452    }
453
454    #[test]
455    fn rule_matches_supports_multi_segment_glob() {
456        assert!(match_specificity("XSD/PatternInvalid/UUID", "XSD/*/UUID").is_some());
457        assert!(match_specificity("XSD/TypeInvalid/UUID", "XSD/*/UUID").is_some());
458        assert!(match_specificity("XSD/PatternInvalid/Number", "XSD/*/UUID").is_none());
459    }
460
461    #[test]
462    fn rule_matches_supports_smpte_section_globs() {
463        // Mid-segment wildcards — anchored at both ends of the segment.
464        assert!(
465            match_specificity("ST2067-2:2020:6.4.2/EditRate", "ST2067-*:2020:*/EditRate",)
466                .is_some()
467        );
468        assert!(match_specificity(
469            "ST2067-3:2020:5.5.1.2/ContentKindUnknown",
470            "ST2067-*:2020:*/EditRate",
471        )
472        .is_none());
473    }
474
475    #[test]
476    fn rule_matches_supports_source_prefix() {
477        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XsdLayer").is_some());
478        assert!(match_specificity("IMFERNO:Package/X", "source:XsdLayer").is_none());
479        assert!(match_specificity("IMFERNO:Package/X", "source:EngineInternal").is_some());
480        assert!(match_specificity("ST2067-3:2016:5/X", "source:ProseRule").is_some());
481        // Unknown source name — no match (silently ignored, doesn't panic).
482        assert!(match_specificity("XSD/X", "source:NotAVariant").is_none());
483        // Sanity: matched issue inherits the SourcePrefix tier.
484        assert_eq!(
485            IssueSource::from_code("XSD/TypeInvalid/IssueDate"),
486            IssueSource::XsdLayer,
487        );
488    }
489
490    // ── FIX-13: validate() unmatchable-pattern helper ─────────────────────
491
492    /// A clean config (every key resolves) returns no warnings.
493    #[test]
494    fn validate_returns_no_warnings_for_clean_config() {
495        let mut rules = RulesConfig::default();
496        rules.set_raw("XSD/TypeInvalid/IssueDate".into(), RuleSeverity::Warn);
497        rules.set_raw("source:XsdLayer".into(), RuleSeverity::Off);
498        rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
499        let warnings = rules.validate(["XSD/TypeInvalid/IssueDate", "XSD/PatternInvalid/UUID"]);
500        assert!(
501            warnings.is_empty(),
502            "expected no warnings, got: {warnings:#?}"
503        );
504    }
505
506    /// `source:Foo` where `Foo` isn't a known `IssueSource` variant.
507    #[test]
508    fn validate_flags_unknown_source_variant() {
509        let mut rules = RulesConfig::default();
510        rules.set_raw("source:NotAVariant".into(), RuleSeverity::Off);
511        let warnings = rules.validate::<_, &str>([]);
512        assert_eq!(warnings.len(), 1);
513        assert_eq!(warnings[0].key, "source:NotAVariant");
514        assert_eq!(
515            warnings[0].reason,
516            RuleValidationReason::UnknownSource {
517                variant: "NotAVariant".to_string()
518            }
519        );
520    }
521
522    /// A key that parses fine but matches none of the supplied codes.
523    #[test]
524    fn validate_flags_match_nothing_keys() {
525        let mut rules = RulesConfig::default();
526        rules.set_raw("Doesnotexist".into(), RuleSeverity::Warn);
527        rules.set_raw("XSD/Madeup/*".into(), RuleSeverity::Off);
528        let warnings = rules.validate(["XSD/TypeInvalid/IssueDate"]);
529        assert_eq!(warnings.len(), 2);
530        // sorted by key
531        assert_eq!(warnings[0].key, "Doesnotexist");
532        assert_eq!(warnings[0].reason, RuleValidationReason::MatchesNothing);
533        assert_eq!(warnings[1].key, "XSD/Madeup/*");
534        assert_eq!(warnings[1].reason, RuleValidationReason::MatchesNothing);
535    }
536
537    /// `**` is reserved but not implemented; flag it with a hint.
538    #[test]
539    fn validate_flags_double_star_with_hint() {
540        let mut rules = RulesConfig::default();
541        rules.set_raw("XSD/**/UUID".into(), RuleSeverity::Off);
542        let warnings = rules.validate(["XSD/PatternInvalid/UUID"]);
543        assert_eq!(warnings.len(), 1);
544        assert!(matches!(
545            &warnings[0].reason,
546            RuleValidationReason::UnsupportedPattern { hint } if hint.contains("**")
547        ));
548    }
549
550    /// FIX-5 regression: source-prefix variant names are matched
551    /// case-insensitively so config keys like `source:xsdlayer` and
552    /// `source:XSDLAYER` work as expected.
553    #[test]
554    fn rule_matches_source_prefix_case_insensitively() {
555        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:xsdlayer").is_some());
556        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XSDLAYER").is_some());
557        assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XsDlAyEr").is_some());
558        assert!(match_specificity("IMFERNO:Package/X", "source:engineinternal").is_some());
559        assert!(match_specificity("ST2067-3:2016:5/X", "source:proserule").is_some());
560    }
561
562    #[test]
563    fn apply_rules_specific_glob_beats_general_glob() {
564        let mut rules = RulesConfig::default();
565        rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
566        rules.set_raw("XSD/PatternInvalid/*".into(), RuleSeverity::Error);
567        let report = report_with(vec![issue("XSD/PatternInvalid/UUID", Severity::Info)]);
568        let out = report.apply_rules(&rules);
569        assert_eq!(out.errors.len(), 1);
570        assert!(out.warnings.is_empty());
571    }
572
573    #[test]
574    fn apply_rules_full_code_beats_glob() {
575        let mut rules = RulesConfig::default();
576        rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
577        rules.set_raw("XSD/PatternInvalid/UUID".into(), RuleSeverity::Critical);
578        let report = report_with(vec![issue("XSD/PatternInvalid/UUID", Severity::Info)]);
579        let out = report.apply_rules(&rules);
580        assert_eq!(out.critical.len(), 1);
581        assert!(out.warnings.is_empty());
582    }
583
584    #[test]
585    fn apply_rules_source_prefix_off_moves_to_suppressed_bucket() {
586        let mut rules = RulesConfig::default();
587        rules.set_raw("source:XsdLayer".into(), RuleSeverity::Off);
588        let report = report_with(vec![
589            issue("XSD/TypeInvalid/IssueDate", Severity::Error),
590            issue("ST2067-3:2020:5/X", Severity::Error),
591        ]);
592        let out = report.apply_rules(&rules);
593        // XSD issue removed from errors, retained in suppressed bucket
594        // with an audit annotation. Prose issue stays in errors.
595        assert_eq!(out.errors.len(), 1);
596        assert!(out.errors[0].code.starts_with("ST2067-"));
597        assert_eq!(out.suppressed.len(), 1);
598        assert_eq!(out.suppressed[0].code, "XSD/TypeInvalid/IssueDate");
599        assert_eq!(out.suppressed[0].severity, Severity::Info);
600        assert_eq!(
601            out.suppressed[0]
602                .context
603                .get("suppressed_by")
604                .map(String::as_str),
605            Some("source:XsdLayer"),
606        );
607    }
608
609    #[test]
610    fn apply_rules_off_annotates_with_specific_key() {
611        // When a more-specific key wins over a broader source-prefix,
612        // the annotation should name the actual winning key.
613        let mut rules = RulesConfig::default();
614        rules.set_raw("source:XsdLayer".into(), RuleSeverity::Warn);
615        rules.set_raw("XSD/TypeInvalid/*".into(), RuleSeverity::Off);
616        let report = report_with(vec![issue("XSD/TypeInvalid/IssueDate", Severity::Error)]);
617        let out = report.apply_rules(&rules);
618        assert!(out.errors.is_empty());
619        assert_eq!(out.suppressed.len(), 1);
620        assert_eq!(
621            out.suppressed[0]
622                .context
623                .get("suppressed_by")
624                .map(String::as_str),
625            Some("XSD/TypeInvalid/*"),
626        );
627    }
628
629    #[test]
630    fn apply_rules_suppressed_bucket_does_not_affect_compliance() {
631        // Suppressed issues must not flip `is_playable`/`is_compliant`.
632        let mut rules = RulesConfig::default();
633        rules.set_raw("XSD/*/*".into(), RuleSeverity::Off);
634        let report = report_with(vec![
635            issue("XSD/TypeInvalid/IssueDate", Severity::Critical),
636            issue("XSD/PatternInvalid/UUID", Severity::Error),
637        ]);
638        let out = report.apply_rules(&rules);
639        assert_eq!(out.suppressed.len(), 2);
640        assert!(
641            out.is_playable,
642            "suppressed Critical should not block playability"
643        );
644        assert!(
645            out.is_compliant,
646            "suppressed Error should not block compliance"
647        );
648    }
649
650    #[test]
651    fn apply_rules_remains_deterministic_across_runs() {
652        // Two overlapping patterns at the same specificity tier-bucket
653        // (both globs with same prefix length and total length). The
654        // tie-breaker (longer key length) plus stable comparator must
655        // give the same answer every run regardless of HashMap order.
656        let mut rules = RulesConfig::default();
657        rules.set_raw("XSD/A*".into(), RuleSeverity::Warn);
658        rules.set_raw("XSD/B*".into(), RuleSeverity::Error);
659        // Issue matches neither — sanity check that no panic / drop happens.
660        let code_neither = "XSD/CFoo";
661        // Issue matches one — must be the same one every time.
662        let code_a = "XSD/Apple";
663
664        let mut first: Option<Severity> = None;
665        for _ in 0..100 {
666            let r = report_with(vec![
667                issue(code_a, Severity::Info),
668                issue(code_neither, Severity::Info),
669            ])
670            .apply_rules(&rules);
671            // The Apple issue should always land in warnings (matches XSD/A*).
672            let sev = if !r.warnings.is_empty() {
673                Severity::Warning
674            } else if !r.errors.is_empty() {
675                Severity::Error
676            } else {
677                Severity::Info
678            };
679            if first.is_none() {
680                first = Some(sev);
681            } else {
682                assert_eq!(first, Some(sev), "result drifted across runs");
683            }
684        }
685        assert_eq!(first, Some(Severity::Warning));
686    }
687}