use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::diagnostics::codes::ValidationCode;
use crate::diagnostics::IssueSource;
use crate::{Severity, ValidationReport};
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleSeverity {
Off,
Info,
Warn,
Error,
Critical,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleValidationWarning {
pub key: String,
pub reason: RuleValidationReason,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RuleValidationReason {
UnknownSource { variant: String },
MatchesNothing,
UnsupportedPattern { hint: String },
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RulesConfig(HashMap<String, RuleSeverity>);
impl RulesConfig {
pub fn set(&mut self, code: impl ValidationCode, severity: RuleSeverity) {
self.0.insert(code.code().to_string(), severity);
}
pub fn set_raw(&mut self, key: String, severity: RuleSeverity) {
self.0.insert(key, severity);
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn validate<I, S>(&self, known_codes: I) -> Vec<RuleValidationWarning>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let codes: Vec<String> = known_codes
.into_iter()
.map(|c| c.as_ref().to_string())
.collect();
let mut warnings = Vec::new();
for key in self.0.keys() {
if let Some(rest) = key.strip_prefix("source:") {
if parse_source(rest).is_none() {
warnings.push(RuleValidationWarning {
key: key.clone(),
reason: RuleValidationReason::UnknownSource {
variant: rest.to_string(),
},
});
}
continue;
}
if key.contains("**") {
warnings.push(RuleValidationWarning {
key: key.clone(),
reason: RuleValidationReason::UnsupportedPattern {
hint: "`**` (any-depth wildcard) is not supported; use `*/*` or `source:<Variant>` for broader scopes".to_string(),
},
});
continue;
}
let matches_any = codes.iter().any(|c| match_specificity(c, key).is_some());
if !matches_any {
warnings.push(RuleValidationWarning {
key: key.clone(),
reason: RuleValidationReason::MatchesNothing,
});
}
}
warnings.sort_by(|a, b| a.key.cmp(&b.key));
warnings
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &RuleSeverity)> {
self.0.iter()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Specificity {
SourcePrefix,
Suffix,
Glob(usize, usize),
FullCode,
}
fn match_specificity(code: &str, key: &str) -> Option<Specificity> {
if let Some(rest) = key.strip_prefix("source:") {
return parse_source(rest)
.filter(|src| IssueSource::from_code(code) == *src)
.map(|_| Specificity::SourcePrefix);
}
if key.contains('*') {
return glob_match(code, key)
.then_some(Specificity::Glob(literal_prefix_len(key), key.len()));
}
if code == key {
return Some(Specificity::FullCode);
}
if code.rsplit('/').next() == Some(key) {
return Some(Specificity::Suffix);
}
None
}
fn glob_match(code: &str, key: &str) -> bool {
let code_parts: Vec<&str> = code.split('/').collect();
let key_parts: Vec<&str> = key.split('/').collect();
if code_parts.len() != key_parts.len() {
return false;
}
code_parts
.iter()
.zip(key_parts.iter())
.all(|(c, k)| segment_matches(c, k))
}
fn segment_matches(code_seg: &str, key_seg: &str) -> bool {
if !key_seg.contains('*') {
return code_seg == key_seg;
}
let pieces: Vec<&str> = key_seg.split('*').collect();
let first = pieces.first().copied().unwrap_or("");
let last = pieces.last().copied().unwrap_or("");
if !code_seg.starts_with(first) || !code_seg.ends_with(last) {
return false;
}
if pieces.len() == 1 {
return code_seg == first;
}
if code_seg.len() < first.len() + last.len() {
return false;
}
let mut cursor = first.len();
let end = code_seg.len() - last.len();
for piece in &pieces[1..pieces.len() - 1] {
if piece.is_empty() {
continue;
}
match code_seg[cursor..end].find(piece) {
Some(offset) => cursor += offset + piece.len(),
None => return false,
}
}
true
}
fn literal_prefix_len(key: &str) -> usize {
key.find('*').unwrap_or(key.len())
}
fn parse_source(name: &str) -> Option<IssueSource> {
if name.eq_ignore_ascii_case("XsdLayer") {
Some(IssueSource::XsdLayer)
} else if name.eq_ignore_ascii_case("ProseRule") {
Some(IssueSource::ProseRule)
} else if name.eq_ignore_ascii_case("EngineInternal") {
Some(IssueSource::EngineInternal)
} else {
None
}
}
impl ValidationReport {
pub fn apply_rules(mut self, rules: &RulesConfig) -> Self {
if rules.is_empty() {
return self;
}
let all: Vec<_> = self
.critical
.drain(..)
.chain(self.errors.drain(..))
.chain(self.warnings.drain(..))
.chain(self.info.drain(..))
.collect();
for mut issue in all {
let matched = rules
.iter()
.filter_map(|(k, v)| match_specificity(&issue.code, k).map(|s| (s, k, v)))
.max_by(|(a, ak, _), (b, bk, _)| a.cmp(b).then_with(|| ak.len().cmp(&bk.len())));
match matched {
Some((_, key, RuleSeverity::Off)) => {
issue
.context
.insert("suppressed_by".to_string(), key.clone());
issue.severity = Severity::Info;
self.suppressed.push(issue);
}
Some((_, _, RuleSeverity::Info)) => {
issue.severity = Severity::Info;
self.info.push(issue);
}
Some((_, _, RuleSeverity::Warn)) => {
issue.severity = Severity::Warning;
self.warnings.push(issue);
}
Some((_, _, RuleSeverity::Error)) => {
issue.severity = Severity::Error;
self.errors.push(issue);
}
Some((_, _, RuleSeverity::Critical)) => {
issue.severity = Severity::Critical;
self.critical.push(issue);
}
None => match issue.severity {
Severity::Critical => self.critical.push(issue),
Severity::Error => self.errors.push(issue),
Severity::Warning => self.warnings.push(issue),
Severity::Info => self.info.push(issue),
},
}
}
self.is_playable = self.critical.is_empty();
self.is_compliant = self.critical.is_empty() && self.errors.is_empty();
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules_config_accessors() {
let mut rules = RulesConfig::default();
assert!(rules.is_empty());
assert_eq!(rules.len(), 0);
rules.set(
crate::assetmap::codes::St2067_2_2020::FileNotFound,
RuleSeverity::Critical,
);
assert!(!rules.is_empty());
assert_eq!(rules.len(), 1);
assert_eq!(rules.iter().count(), 1);
}
#[test]
fn rules_config_serde_round_trip() {
let mut rules = RulesConfig::default();
rules.set(
crate::assetmap::codes::St2067_2_2020::FileNotFound,
RuleSeverity::Off,
);
let json = serde_json::to_string(&rules).unwrap();
let deserialized: RulesConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.len(), 1);
}
use crate::diagnostics::{Category, IssueSource, Location, ValidationIssue, ValidationProfile};
fn issue(code: &str, severity: Severity) -> ValidationIssue {
ValidationIssue::new(severity, Category::Schema, code, "test")
.with_location(Location::new())
}
fn report_with(issues: Vec<ValidationIssue>) -> ValidationReport {
let mut r = ValidationReport::new(ValidationProfile::SMPTE);
for i in issues {
r.add(i);
}
r
}
#[test]
fn rule_matches_supports_single_segment_glob() {
assert!(match_specificity("XSD/TypeInvalid", "XSD/*").is_some());
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "XSD/*").is_none());
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "XSD/*/*").is_some());
}
#[test]
fn rule_matches_supports_multi_segment_glob() {
assert!(match_specificity("XSD/PatternInvalid/UUID", "XSD/*/UUID").is_some());
assert!(match_specificity("XSD/TypeInvalid/UUID", "XSD/*/UUID").is_some());
assert!(match_specificity("XSD/PatternInvalid/Number", "XSD/*/UUID").is_none());
}
#[test]
fn rule_matches_supports_smpte_section_globs() {
assert!(
match_specificity("ST2067-2:2020:6.4.2/EditRate", "ST2067-*:2020:*/EditRate",)
.is_some()
);
assert!(match_specificity(
"ST2067-3:2020:5.5.1.2/ContentKindUnknown",
"ST2067-*:2020:*/EditRate",
)
.is_none());
}
#[test]
fn rule_matches_supports_source_prefix() {
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XsdLayer").is_some());
assert!(match_specificity("IMFERNO:Package/X", "source:XsdLayer").is_none());
assert!(match_specificity("IMFERNO:Package/X", "source:EngineInternal").is_some());
assert!(match_specificity("ST2067-3:2016:5/X", "source:ProseRule").is_some());
assert!(match_specificity("XSD/X", "source:NotAVariant").is_none());
assert_eq!(
IssueSource::from_code("XSD/TypeInvalid/IssueDate"),
IssueSource::XsdLayer,
);
}
#[test]
fn validate_returns_no_warnings_for_clean_config() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/TypeInvalid/IssueDate".into(), RuleSeverity::Warn);
rules.set_raw("source:XsdLayer".into(), RuleSeverity::Off);
rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
let warnings = rules.validate(["XSD/TypeInvalid/IssueDate", "XSD/PatternInvalid/UUID"]);
assert!(
warnings.is_empty(),
"expected no warnings, got: {warnings:#?}"
);
}
#[test]
fn validate_flags_unknown_source_variant() {
let mut rules = RulesConfig::default();
rules.set_raw("source:NotAVariant".into(), RuleSeverity::Off);
let warnings = rules.validate::<_, &str>([]);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].key, "source:NotAVariant");
assert_eq!(
warnings[0].reason,
RuleValidationReason::UnknownSource {
variant: "NotAVariant".to_string()
}
);
}
#[test]
fn validate_flags_match_nothing_keys() {
let mut rules = RulesConfig::default();
rules.set_raw("Doesnotexist".into(), RuleSeverity::Warn);
rules.set_raw("XSD/Madeup/*".into(), RuleSeverity::Off);
let warnings = rules.validate(["XSD/TypeInvalid/IssueDate"]);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].key, "Doesnotexist");
assert_eq!(warnings[0].reason, RuleValidationReason::MatchesNothing);
assert_eq!(warnings[1].key, "XSD/Madeup/*");
assert_eq!(warnings[1].reason, RuleValidationReason::MatchesNothing);
}
#[test]
fn validate_flags_double_star_with_hint() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/**/UUID".into(), RuleSeverity::Off);
let warnings = rules.validate(["XSD/PatternInvalid/UUID"]);
assert_eq!(warnings.len(), 1);
assert!(matches!(
&warnings[0].reason,
RuleValidationReason::UnsupportedPattern { hint } if hint.contains("**")
));
}
#[test]
fn rule_matches_source_prefix_case_insensitively() {
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:xsdlayer").is_some());
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XSDLAYER").is_some());
assert!(match_specificity("XSD/TypeInvalid/IssueDate", "source:XsDlAyEr").is_some());
assert!(match_specificity("IMFERNO:Package/X", "source:engineinternal").is_some());
assert!(match_specificity("ST2067-3:2016:5/X", "source:proserule").is_some());
}
#[test]
fn apply_rules_specific_glob_beats_general_glob() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
rules.set_raw("XSD/PatternInvalid/*".into(), RuleSeverity::Error);
let report = report_with(vec![issue("XSD/PatternInvalid/UUID", Severity::Info)]);
let out = report.apply_rules(&rules);
assert_eq!(out.errors.len(), 1);
assert!(out.warnings.is_empty());
}
#[test]
fn apply_rules_full_code_beats_glob() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/*/*".into(), RuleSeverity::Warn);
rules.set_raw("XSD/PatternInvalid/UUID".into(), RuleSeverity::Critical);
let report = report_with(vec![issue("XSD/PatternInvalid/UUID", Severity::Info)]);
let out = report.apply_rules(&rules);
assert_eq!(out.critical.len(), 1);
assert!(out.warnings.is_empty());
}
#[test]
fn apply_rules_source_prefix_off_moves_to_suppressed_bucket() {
let mut rules = RulesConfig::default();
rules.set_raw("source:XsdLayer".into(), RuleSeverity::Off);
let report = report_with(vec![
issue("XSD/TypeInvalid/IssueDate", Severity::Error),
issue("ST2067-3:2020:5/X", Severity::Error),
]);
let out = report.apply_rules(&rules);
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].code.starts_with("ST2067-"));
assert_eq!(out.suppressed.len(), 1);
assert_eq!(out.suppressed[0].code, "XSD/TypeInvalid/IssueDate");
assert_eq!(out.suppressed[0].severity, Severity::Info);
assert_eq!(
out.suppressed[0]
.context
.get("suppressed_by")
.map(String::as_str),
Some("source:XsdLayer"),
);
}
#[test]
fn apply_rules_off_annotates_with_specific_key() {
let mut rules = RulesConfig::default();
rules.set_raw("source:XsdLayer".into(), RuleSeverity::Warn);
rules.set_raw("XSD/TypeInvalid/*".into(), RuleSeverity::Off);
let report = report_with(vec![issue("XSD/TypeInvalid/IssueDate", Severity::Error)]);
let out = report.apply_rules(&rules);
assert!(out.errors.is_empty());
assert_eq!(out.suppressed.len(), 1);
assert_eq!(
out.suppressed[0]
.context
.get("suppressed_by")
.map(String::as_str),
Some("XSD/TypeInvalid/*"),
);
}
#[test]
fn apply_rules_suppressed_bucket_does_not_affect_compliance() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/*/*".into(), RuleSeverity::Off);
let report = report_with(vec![
issue("XSD/TypeInvalid/IssueDate", Severity::Critical),
issue("XSD/PatternInvalid/UUID", Severity::Error),
]);
let out = report.apply_rules(&rules);
assert_eq!(out.suppressed.len(), 2);
assert!(
out.is_playable,
"suppressed Critical should not block playability"
);
assert!(
out.is_compliant,
"suppressed Error should not block compliance"
);
}
#[test]
fn apply_rules_remains_deterministic_across_runs() {
let mut rules = RulesConfig::default();
rules.set_raw("XSD/A*".into(), RuleSeverity::Warn);
rules.set_raw("XSD/B*".into(), RuleSeverity::Error);
let code_neither = "XSD/CFoo";
let code_a = "XSD/Apple";
let mut first: Option<Severity> = None;
for _ in 0..100 {
let r = report_with(vec![
issue(code_a, Severity::Info),
issue(code_neither, Severity::Info),
])
.apply_rules(&rules);
let sev = if !r.warnings.is_empty() {
Severity::Warning
} else if !r.errors.is_empty() {
Severity::Error
} else {
Severity::Info
};
if first.is_none() {
first = Some(sev);
} else {
assert_eq!(first, Some(sev), "result drifted across runs");
}
}
assert_eq!(first, Some(Severity::Warning));
}
}