1use std::collections::{BTreeMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7pub use super::census::{CENSUS_IDENTITY_SCHEMA_VERSION, CensusIdentity};
8use super::conformance::{
9 ConformanceResult, ConformanceStatus, Equivalence, FeatureId, TestArtifact, is_sha256_digest,
10};
11use workshop_rs::catalog::{Catalog, CatalogIdentity, Locale};
12
13pub const LIVE_CAPTURE_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct LiveCapture {
20 pub schema_version: u32,
21 pub capture_id: String,
22 pub game: String,
23 pub client: String,
25 pub season: String,
26 pub captured_at: String,
27 pub environment: String,
30 pub locale: Locale,
31 pub catalog: CatalogIdentity,
32 pub census: CensusIdentity,
33 pub raw_artifact: TestArtifact,
36 pub results: Vec<ConformanceResult>,
38}
39
40impl LiveCapture {
41 pub fn validate(&self) -> Result<(), LiveCaptureError> {
45 self.validate_structural(None)
46 }
47
48 pub fn validate_against(&self, catalog: &Catalog) -> Result<(), LiveCaptureError> {
50 self.validate_structural(Some(catalog))
51 }
52
53 fn validate_structural(&self, catalog: Option<&Catalog>) -> Result<(), LiveCaptureError> {
54 if self.schema_version != LIVE_CAPTURE_SCHEMA_VERSION {
55 return Err(invalid(format!(
56 "unsupported live capture schema version {}; expected {}",
57 self.schema_version, LIVE_CAPTURE_SCHEMA_VERSION
58 )));
59 }
60 validate_name("captureId", &self.capture_id)?;
61 validate_name("game", &self.game)?;
62 validate_name("client", &self.client)?;
63 validate_name("season", &self.season)?;
64 validate_timestamp(&self.captured_at)?;
65 validate_name("environment", &self.environment)?;
66 validate_name("locale", self.locale.as_str())?;
67 validate_catalog(&self.catalog)?;
68 if normalize_game(&self.catalog.target.game) != normalize_game(&self.game) {
69 return Err(invalid("capture game does not match catalog target game"));
70 }
71 if let Some(catalog) = catalog {
72 if self.catalog != catalog.identity() {
73 return Err(invalid(
74 "capture catalog identity does not match the loaded catalog",
75 ));
76 }
77 }
78 validate_census(&self.census)?;
79 validate_artifact("rawArtifact", &self.raw_artifact, true)?;
80 if self.results.is_empty() {
81 return Err(invalid(
82 "results must contain at least one conformance result",
83 ));
84 }
85
86 let mut case_ids = HashSet::with_capacity(self.results.len());
87 for (index, result) in self.results.iter().enumerate() {
88 let validation = match catalog {
89 Some(catalog) => result.validate_against(catalog),
90 None => result.validate(),
91 };
92 validation.map_err(|error| invalid(format!("results[{index}]: {error}")))?;
93 if !case_ids.insert(&result.case_id) {
94 return Err(invalid(format!(
95 "results[{index}].caseId duplicates another capture result"
96 )));
97 }
98 if result.catalog != self.catalog {
99 return Err(invalid(format!(
100 "results[{index}].catalog does not match capture catalog"
101 )));
102 }
103 if result.locale.as_ref() != Some(&self.locale) {
104 return Err(invalid(format!(
105 "results[{index}].locale does not match capture locale"
106 )));
107 }
108 if result.source != self.raw_artifact {
109 return Err(invalid(format!(
110 "results[{index}].source must pin the capture raw artifact"
111 )));
112 }
113 if result.status == ConformanceStatus::Matched {
114 let observed = result.comparison.observed.as_ref().ok_or_else(|| {
115 invalid(format!(
116 "results[{index}].comparison.observed is required for matched results"
117 ))
118 })?;
119 if observed.sha256 != self.raw_artifact.sha256 {
120 return Err(invalid(format!(
121 "results[{index}].comparison.observed must pin the raw capture digest"
122 )));
123 }
124 }
125 }
126 Ok(())
127 }
128
129 pub fn from_json(json: &str) -> Result<Self, LiveCaptureError> {
131 let capture: Self = serde_json::from_str(json)
132 .map_err(|error| invalid(format!("invalid live capture JSON: {error}")))?;
133 capture.validate()?;
134 Ok(capture)
135 }
136
137 pub fn to_json(&self) -> Result<String, LiveCaptureError> {
139 self.validate()?;
140 serde_json::to_string_pretty(self)
141 .map_err(|error| invalid(format!("cannot serialize live capture: {error}")))
142 }
143
144 pub fn diff(&self, newer: &Self) -> Result<LiveCaptureDiff, LiveCaptureError> {
146 self.validate()?;
147 newer.validate()?;
148
149 let mut changes = Vec::new();
150 if self.locale != newer.locale {
151 changes.push(DiffEntry::metadata(
152 DiffCategory::Locale,
153 format!("locale changed from {} to {}", self.locale, newer.locale),
154 ));
155 }
156 if self.catalog != newer.catalog {
157 changes.push(DiffEntry::metadata(
158 DiffCategory::Catalog,
159 "catalog identity changed",
160 ));
161 }
162 if self.census != newer.census {
163 changes.push(DiffEntry::metadata(
164 DiffCategory::SemanticSchema,
165 "census identity or shard set changed",
166 ));
167 }
168 if self.raw_artifact != newer.raw_artifact {
169 changes.push(DiffEntry::metadata(
170 DiffCategory::Content,
171 "raw client artifact provenance or content changed",
172 ));
173 }
174
175 let prior: BTreeMap<_, _> = self
176 .results
177 .iter()
178 .map(|result| (result.case_id.as_str(), result))
179 .collect();
180 let current: BTreeMap<_, _> = newer
181 .results
182 .iter()
183 .map(|result| (result.case_id.as_str(), result))
184 .collect();
185 let mut all_case_ids: Vec<&str> = prior.keys().chain(current.keys()).copied().collect();
186 all_case_ids.sort_unstable();
187 all_case_ids.dedup();
188
189 let mut runtime_uncertainty = vec![DiffEntry::metadata(
190 DiffCategory::RuntimeUncertainty,
191 "import/export capture does not establish gameplay or runtime behavior",
192 )];
193 for case_id in all_case_ids {
194 match (prior.get(case_id), current.get(case_id)) {
195 (None, Some(result)) => changes.push(DiffEntry::result(
196 DiffCategory::Content,
197 result,
198 "feature-attributed result was added",
199 )),
200 (Some(result), None) => changes.push(DiffEntry::result(
201 DiffCategory::Content,
202 result,
203 "feature-attributed result was removed",
204 )),
205 (Some(previous), Some(current)) => {
206 let features_changed = !same_features(previous, current);
207 if features_changed {
208 changes.push(DiffEntry::result(
209 DiffCategory::SemanticSchema,
210 current,
211 "feature attribution changed",
212 ));
213 }
214
215 if previous.status != current.status {
216 if is_runtime_uncertain(previous) {
217 runtime_uncertainty.push(DiffEntry::result(
220 DiffCategory::RuntimeUncertainty,
221 current,
222 "result is not a comparable semantic match",
223 ));
224 } else {
225 changes.push(DiffEntry::result(
226 DiffCategory::SemanticSchema,
227 current,
228 format!(
229 "conformance status changed from {:?} to {:?}",
230 previous.status, current.status
231 ),
232 ));
233 }
234 } else if is_runtime_uncertain(current) {
235 runtime_uncertainty.push(DiffEntry::result(
236 DiffCategory::RuntimeUncertainty,
237 current,
238 "result is not a comparable semantic match",
239 ));
240 } else if previous.comparison.mode != current.comparison.mode {
241 changes.push(DiffEntry::result(
242 DiffCategory::SemanticSchema,
243 current,
244 "comparison mode changed",
245 ));
246 } else if previous.comparison != current.comparison {
247 changes.push(DiffEntry::result(
248 DiffCategory::Content,
249 current,
250 "expected or observed feature artifact changed",
251 ));
252 }
253 }
254 (None, None) => unreachable!("case ID came from one of the result maps"),
255 }
256 }
257
258 Ok(LiveCaptureDiff {
259 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
260 prior_capture_id: self.capture_id.clone(),
261 new_capture_id: newer.capture_id.clone(),
262 changes,
263 runtime_uncertainty,
264 })
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub enum DiffCategory {
272 Locale,
273 Catalog,
274 Content,
275 SemanticSchema,
276 RuntimeUncertainty,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct DiffEntry {
283 pub category: DiffCategory,
284 pub case_id: Option<String>,
285 pub features: Vec<FeatureId>,
286 pub detail: String,
287}
288
289impl DiffEntry {
290 fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
291 Self {
292 category,
293 case_id: None,
294 features: Vec::new(),
295 detail: detail.into(),
296 }
297 }
298
299 fn result(
300 category: DiffCategory,
301 result: &ConformanceResult,
302 detail: impl Into<String>,
303 ) -> Self {
304 Self {
305 category,
306 case_id: Some(result.case_id.clone()),
307 features: result.features.clone(),
308 detail: detail.into(),
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct LiveCaptureDiff {
317 pub schema_version: u32,
318 pub prior_capture_id: String,
319 pub new_capture_id: String,
320 pub changes: Vec<DiffEntry>,
321 pub runtime_uncertainty: Vec<DiffEntry>,
324}
325
326impl LiveCaptureDiff {
327 pub fn to_json(&self) -> Result<String, LiveCaptureError> {
328 serde_json::to_string_pretty(self)
329 .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
330 }
331
332 pub fn human_summary(&self) -> String {
333 let mut output = format!(
334 "live capture diff schema {}\n{} -> {}\n",
335 self.schema_version, self.prior_capture_id, self.new_capture_id
336 );
337 for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
338 output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
339 }
340 if self.changes.is_empty() {
341 output.push_str("no import/export changes classified\n");
342 }
343 output
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct LiveCaptureError {
350 pub message: String,
351}
352
353impl std::fmt::Display for LiveCaptureError {
354 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 formatter.write_str(&self.message)
356 }
357}
358
359impl std::error::Error for LiveCaptureError {}
360
361fn invalid(message: impl Into<String>) -> LiveCaptureError {
362 LiveCaptureError {
363 message: message.into(),
364 }
365}
366
367fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
368 if value.trim().is_empty() || value.chars().any(char::is_control) {
369 Err(invalid(format!("{field} must be non-empty and printable")))
370 } else {
371 Ok(())
372 }
373}
374
375fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
376 validate_name("capturedAt", value)?;
377 let Some(time) = value.split_once('T').map(|(_, time)| time) else {
381 return Err(invalid(
382 "capturedAt must use an ISO-8601 timestamp with a timezone",
383 ));
384 };
385 let has_offset = time.ends_with('Z') || time.ends_with('z') || time.contains(['+', '-']);
386 if !has_offset {
387 return Err(invalid(
388 "capturedAt must use an ISO-8601 timestamp with a timezone",
389 ));
390 }
391 Ok(())
392}
393
394fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
395 validate_name(
396 "catalog.implementationVersion",
397 &catalog.implementation_version,
398 )?;
399 validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
400 let digest = catalog
401 .catalog_digest
402 .as_deref()
403 .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
404 validate_sha256("catalog.catalogDigest", digest)?;
405 validate_name("catalog.target.game", &catalog.target.game)?;
406 validate_name("catalog.target.format", &catalog.target.format)?;
407 validate_name("catalog.target.surface", &catalog.target.surface)?;
408 if catalog.locale_coverage.is_empty() {
409 return Err(invalid("catalog.localeCoverage must not be empty"));
410 }
411 Ok(())
412}
413
414fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
415 if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
416 return Err(invalid(format!(
417 "unsupported census identity schema version {}; expected {}",
418 census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
419 )));
420 }
421 validate_sha256("census.digest", &census.digest)?;
422 if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
423 return Err(invalid(
424 "census.shards must be non-empty and strictly sorted",
425 ));
426 }
427 Ok(())
428}
429
430fn validate_artifact(
431 field: &str,
432 artifact: &TestArtifact,
433 require_pin: bool,
434) -> Result<(), LiveCaptureError> {
435 validate_name(&format!("{field}.name"), &artifact.name)?;
436 if require_pin {
437 validate_name(
438 &format!("{field}.revision"),
439 artifact.revision.as_deref().unwrap_or_default(),
440 )?;
441 validate_name(
442 &format!("{field}.path"),
443 artifact.path.as_deref().unwrap_or_default(),
444 )?;
445 validate_sha256(
446 &format!("{field}.sha256"),
447 artifact.sha256.as_deref().unwrap_or_default(),
448 )?;
449 }
450 Ok(())
451}
452
453fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
454 if !is_sha256_digest(digest) {
455 return Err(invalid(format!(
456 "{field} must be a 64-character hexadecimal SHA-256 digest"
457 )));
458 }
459 Ok(())
460}
461
462fn normalize_game(value: &str) -> String {
463 value
464 .chars()
465 .filter(char::is_ascii_alphanumeric)
466 .flat_map(char::to_lowercase)
467 .collect()
468}
469
470fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
471 left.features.len() == right.features.len()
472 && left
473 .features
474 .iter()
475 .all(|feature| right.features.contains(feature))
476}
477
478fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
479 !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
480}
481
482#[cfg(test)]
483mod tests {
484 use super::super::conformance::{
485 CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
486 ReasonCode,
487 };
488 use super::*;
489 use workshop_rs::catalog::Catalog;
490
491 const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
492 const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
493
494 fn catalog() -> CatalogIdentity {
495 Catalog::builtin()
496 .expect("built-in catalog for constructed unit data")
497 .identity()
498 }
499
500 fn raw(digest: &str) -> TestArtifact {
501 TestArtifact {
502 name: "constructed-unit-test/raw.ws".to_string(),
503 revision: Some("unit-test-revision".to_string()),
504 path: Some("constructed-unit-test/raw.ws".to_string()),
505 sha256: Some(digest.to_string()),
506 license: Some("MIT".to_string()),
507 }
508 }
509
510 fn feature(name: &str) -> FeatureId {
511 FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
512 .expect("constructed feature identity")
513 }
514
515 fn result(
516 identity: &CatalogIdentity,
517 locale: &Locale,
518 raw: &TestArtifact,
519 case_id: &str,
520 status: ConformanceStatus,
521 feature_name: &str,
522 ) -> ConformanceResult {
523 let matched = status == ConformanceStatus::Matched;
524 ConformanceResult {
525 schema_version: CONFORMANCE_SCHEMA_VERSION,
526 case_id: case_id.to_string(),
527 features: vec![feature(feature_name)],
528 status,
529 comparison: Comparison {
530 mode: if matched {
531 Equivalence::Normalized
532 } else {
533 Equivalence::NotComparable
534 },
535 expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
536 observed: matched.then(|| TestArtifact {
537 name: "constructed-unit-test/observed.ws".to_string(),
538 revision: Some("unit-test-revision".to_string()),
539 path: Some("constructed-unit-test/raw.ws".to_string()),
540 sha256: Some(raw.sha256.clone().unwrap()),
541 license: Some("MIT".to_string()),
542 }),
543 normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
544 },
545 source: raw.clone(),
546 catalog: identity.clone(),
547 locale: Some(locale.clone()),
548 reason: (!matched).then(|| ConformanceReason {
549 code: ReasonCode::Inconclusive,
550 detail: "constructed unit uncertainty".to_string(),
551 }),
552 }
553 }
554
555 fn make_capture(
556 capture_id: &str,
557 locale: &str,
558 digest: &str,
559 result_status: ConformanceStatus,
560 feature_name: &str,
561 ) -> LiveCapture {
562 let identity = catalog();
563 let locale = Locale::new(locale);
564 let raw = raw(digest);
565 LiveCapture {
566 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
567 capture_id: capture_id.to_string(),
568 game: "overwatch-2".to_string(),
569 client: "constructed-unit-test-client".to_string(),
570 season: "constructed-unit-test-season".to_string(),
571 captured_at: "2026-08-18T00:00:00Z".to_string(),
572 environment: "constructed schema/diff unit test; not a client capture".to_string(),
573 locale: locale.clone(),
574 catalog: identity.clone(),
575 census: CensusIdentity {
576 schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
577 digest: DIGEST.to_string(),
578 shards: vec!["constructed-unit-test-shard".to_string()],
579 },
580 raw_artifact: raw.clone(),
581 results: vec![result(
582 &identity,
583 &locale,
584 &raw,
585 "constructed-unit-test/case",
586 result_status,
587 feature_name,
588 )],
589 }
590 }
591
592 #[test]
593 fn constructed_capture_schema_round_trips_without_runtime_claim() {
594 let capture = make_capture(
595 "capture-a",
596 "en-US",
597 DIGEST,
598 ConformanceStatus::Matched,
599 "one",
600 );
601 let json = capture.to_json().expect("constructed schema serializes");
602 let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
603 assert_eq!(decoded, capture);
604 }
605
606 #[test]
607 fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
608 let mut capture = make_capture(
609 "capture-a",
610 "en-US",
611 DIGEST,
612 ConformanceStatus::Matched,
613 "one",
614 );
615 capture.raw_artifact.sha256 = None;
616 assert!(capture.validate().is_err());
617
618 let mut capture = make_capture(
619 "capture-a",
620 "en-US",
621 DIGEST,
622 ConformanceStatus::Matched,
623 "one",
624 );
625 capture.game = "not-overwatch".to_string();
626 assert!(capture.validate().is_err());
627
628 let mut capture = make_capture(
629 "capture-a",
630 "en-US",
631 DIGEST,
632 ConformanceStatus::Matched,
633 "one",
634 );
635 capture.results[0].source.name = "different source".to_string();
636 assert!(capture.validate().is_err());
637 }
638
639 #[test]
640 fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
641 let prior = make_capture(
642 "capture-a",
643 "en-US",
644 DIGEST,
645 ConformanceStatus::Matched,
646 "one",
647 );
648 let mut newer = make_capture(
649 "capture-b",
650 "zh-CN",
651 OTHER_DIGEST,
652 ConformanceStatus::Inconclusive,
653 "two",
654 );
655 newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
656 newer.results[0].catalog = newer.catalog.clone();
657 let diff = prior.diff(&newer).expect("constructed diff validates");
658 let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
659 assert!(categories.contains(&DiffCategory::Locale));
660 assert!(categories.contains(&DiffCategory::Catalog));
661 assert!(categories.contains(&DiffCategory::Content));
662 assert!(categories.contains(&DiffCategory::SemanticSchema));
663 assert!(
666 diff.changes
667 .iter()
668 .any(|entry| entry.detail.contains("conformance status changed")),
669 "status drift from a comparable baseline must be a change: {:?}",
670 diff.changes
671 );
672 assert!(
673 diff.runtime_uncertainty
674 .iter()
675 .all(|entry| !entry.detail.contains("comparable semantic match")),
676 "a certain baseline reports drift as a change, not uncertainty"
677 );
678 let json = diff.to_json().expect("structured diff serializes");
679 let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
680 assert!(document["changes"].is_array());
681 assert!(document["runtimeUncertainty"].is_array());
682 }
683
684 #[test]
687 fn diff_reports_uncertainty_when_baseline_not_comparable() {
688 let prior = make_capture(
689 "capture-a",
690 "en-US",
691 DIGEST,
692 ConformanceStatus::Inconclusive,
693 "one",
694 );
695 let newer = make_capture(
696 "capture-b",
697 "en-US",
698 DIGEST,
699 ConformanceStatus::Matched,
700 "one",
701 );
702 let diff = prior.diff(&newer).expect("constructed diff validates");
703 assert!(
704 diff.runtime_uncertainty
705 .iter()
706 .any(|entry| entry.category == DiffCategory::RuntimeUncertainty),
707 "an uncertain baseline cannot attribute drift to a real change"
708 );
709 assert!(
710 !diff
711 .changes
712 .iter()
713 .any(|entry| entry.detail.contains("conformance status changed")),
714 "status drift on an uncertain baseline is not a change: {:?}",
715 diff.changes
716 );
717 }
718
719 #[test]
722 fn timestamp_accepts_rfc3339_offsets() {
723 for valid in [
724 "2026-08-18T00:00:00Z",
725 "2026-08-18T00:00:00z",
726 "2026-08-18T00:00:00-08:00",
727 "2026-08-18T00:00:00+08:00",
728 ] {
729 validate_timestamp(valid).unwrap_or_else(|_| panic!("{valid} must validate"));
730 }
731 for invalid in ["2026-08-18", "2026-08-18T00:00:00", "not+a+timestamp"] {
732 assert!(validate_timestamp(invalid).is_err(), "{invalid} must fail");
733 }
734 }
735
736 #[test]
737 fn diff_refuses_capture_with_invalid_result_metadata() {
738 let prior = make_capture(
739 "capture-a",
740 "en-US",
741 DIGEST,
742 ConformanceStatus::Matched,
743 "one",
744 );
745 let mut newer = make_capture(
746 "capture-b",
747 "en-US",
748 DIGEST,
749 ConformanceStatus::Matched,
750 "one",
751 );
752 newer.results[0].catalog.catalog_digest = Some("different".to_string());
753 assert!(prior.diff(&newer).is_err());
754 }
755}