1use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20use crate::diagnostics::codes::ValidationCode;
21use crate::diagnostics::IssueSource;
22use crate::{Severity, ValidationReport};
23
24#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum RuleSeverity {
32 Off,
34 Info,
36 Warn,
38 Error,
40 Critical,
42}
43
44#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct RuleValidationWarning {
52 pub key: String,
54 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 UnknownSource { variant: String },
64 MatchesNothing,
66 UnsupportedPattern { hint: String },
68}
69
70#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct RulesConfig(HashMap<String, RuleSeverity>);
84
85impl RulesConfig {
86 pub fn set(&mut self, code: impl ValidationCode, severity: RuleSeverity) {
96 self.0.insert(code.code().to_string(), severity);
97 }
98
99 pub fn set_raw(&mut self, key: String, severity: RuleSeverity) {
101 self.0.insert(key, severity);
102 }
103
104 pub fn is_empty(&self) -> bool {
106 self.0.is_empty()
107 }
108
109 pub fn len(&self) -> usize {
111 self.0.len()
112 }
113
114 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 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 pub fn iter(&self) -> impl Iterator<Item = (&String, &RuleSeverity)> {
175 self.0.iter()
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
188enum Specificity {
189 SourcePrefix,
191 Suffix,
194 Glob(usize, usize),
197 FullCode,
199}
200
201fn match_specificity(code: &str, key: &str) -> Option<Specificity> {
204 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 if key.contains('*') {
214 return glob_match(code, key)
215 .then_some(Specificity::Glob(literal_prefix_len(key), key.len()));
216 }
217
218 if code == key {
220 return Some(Specificity::FullCode);
221 }
222
223 if code.rsplit('/').next() == Some(key) {
225 return Some(Specificity::Suffix);
226 }
227
228 None
229}
230
231fn 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
253fn 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 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 return code_seg == first;
273 }
274 if code_seg.len() < first.len() + last.len() {
278 return false;
279 }
280
281 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
297fn literal_prefix_len(key: &str) -> usize {
301 key.find('*').unwrap_or(key.len())
302}
303
304fn 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 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 .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 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 assert!(match_specificity("XSD/TypeInvalid/IssueDate", "XSD/*").is_none());
450 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 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 assert!(match_specificity("XSD/X", "source:NotAVariant").is_none());
483 assert_eq!(
485 IssueSource::from_code("XSD/TypeInvalid/IssueDate"),
486 IssueSource::XsdLayer,
487 );
488 }
489
490 #[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 #[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 #[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 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 #[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 #[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 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 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 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 let mut rules = RulesConfig::default();
657 rules.set_raw("XSD/A*".into(), RuleSeverity::Warn);
658 rules.set_raw("XSD/B*".into(), RuleSeverity::Error);
659 let code_neither = "XSD/CFoo";
661 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 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}