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 && self.catalog != catalog.identity()
73 {
74 return Err(invalid(
75 "capture catalog identity does not match the loaded catalog",
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(
218 DiffCategory::RuntimeUncertainty,
219 current,
220 "result is not a comparable semantic match",
221 ));
222 } else {
223 changes.push(DiffEntry::result(
224 DiffCategory::SemanticSchema,
225 current,
226 format!(
227 "conformance status changed from {:?} to {:?}",
228 previous.status, current.status
229 ),
230 ));
231 }
232 } else if is_runtime_uncertain(current) {
233 runtime_uncertainty.push(DiffEntry::result(
234 DiffCategory::RuntimeUncertainty,
235 current,
236 "result is not a comparable semantic match",
237 ));
238 } else if previous.comparison.mode != current.comparison.mode {
239 changes.push(DiffEntry::result(
240 DiffCategory::SemanticSchema,
241 current,
242 "comparison mode changed",
243 ));
244 } else if previous.comparison != current.comparison {
245 changes.push(DiffEntry::result(
246 DiffCategory::Content,
247 current,
248 "expected or observed feature artifact changed",
249 ));
250 }
251 }
252 (None, None) => unreachable!("case ID came from one of the result maps"),
253 }
254 }
255
256 Ok(LiveCaptureDiff {
257 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
258 prior_capture_id: self.capture_id.clone(),
259 new_capture_id: newer.capture_id.clone(),
260 changes,
261 runtime_uncertainty,
262 })
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum DiffCategory {
270 Locale,
271 Catalog,
272 Content,
273 SemanticSchema,
274 RuntimeUncertainty,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct DiffEntry {
281 pub category: DiffCategory,
282 pub case_id: Option<String>,
283 pub features: Vec<FeatureId>,
284 pub detail: String,
285}
286
287impl DiffEntry {
288 fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
289 Self {
290 category,
291 case_id: None,
292 features: Vec::new(),
293 detail: detail.into(),
294 }
295 }
296
297 fn result(
298 category: DiffCategory,
299 result: &ConformanceResult,
300 detail: impl Into<String>,
301 ) -> Self {
302 Self {
303 category,
304 case_id: Some(result.case_id.clone()),
305 features: result.features.clone(),
306 detail: detail.into(),
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct LiveCaptureDiff {
315 pub schema_version: u32,
316 pub prior_capture_id: String,
317 pub new_capture_id: String,
318 pub changes: Vec<DiffEntry>,
319 pub runtime_uncertainty: Vec<DiffEntry>,
322}
323
324impl LiveCaptureDiff {
325 pub fn to_json(&self) -> Result<String, LiveCaptureError> {
326 serde_json::to_string_pretty(self)
327 .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
328 }
329
330 pub fn human_summary(&self) -> String {
331 let mut output = format!(
332 "live capture diff schema {}\n{} -> {}\n",
333 self.schema_version, self.prior_capture_id, self.new_capture_id
334 );
335 for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
336 output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
337 }
338 if self.changes.is_empty() {
339 output.push_str("no import/export changes classified\n");
340 }
341 output
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct LiveCaptureError {
348 pub message: String,
349}
350
351impl std::fmt::Display for LiveCaptureError {
352 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 formatter.write_str(&self.message)
354 }
355}
356
357impl std::error::Error for LiveCaptureError {}
358
359fn invalid(message: impl Into<String>) -> LiveCaptureError {
360 LiveCaptureError {
361 message: message.into(),
362 }
363}
364
365fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
366 if value.trim().is_empty() || value.chars().any(char::is_control) {
367 Err(invalid(format!("{field} must be non-empty and printable")))
368 } else {
369 Ok(())
370 }
371}
372
373fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
374 validate_name("capturedAt", value)?;
375 let Some(time) = value.split_once('T').map(|(_, time)| time) else {
376 return Err(invalid(
377 "capturedAt must use an ISO-8601 timestamp with a timezone",
378 ));
379 };
380 let has_offset = time.ends_with('Z') || time.ends_with('z') || time.contains(['+', '-']);
381 if !has_offset {
382 return Err(invalid(
383 "capturedAt must use an ISO-8601 timestamp with a timezone",
384 ));
385 }
386 Ok(())
387}
388
389fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
390 validate_name(
391 "catalog.implementationVersion",
392 &catalog.implementation_version,
393 )?;
394 validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
395 let digest = catalog
396 .catalog_digest
397 .as_deref()
398 .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
399 validate_sha256("catalog.catalogDigest", digest)?;
400 validate_name("catalog.target.game", &catalog.target.game)?;
401 validate_name("catalog.target.format", &catalog.target.format)?;
402 validate_name("catalog.target.surface", &catalog.target.surface)?;
403 if catalog.locale_coverage.is_empty() {
404 return Err(invalid("catalog.localeCoverage must not be empty"));
405 }
406 Ok(())
407}
408
409fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
410 if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
411 return Err(invalid(format!(
412 "unsupported census identity schema version {}; expected {}",
413 census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
414 )));
415 }
416 validate_sha256("census.digest", &census.digest)?;
417 if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
418 return Err(invalid(
419 "census.shards must be non-empty and strictly sorted",
420 ));
421 }
422 Ok(())
423}
424
425fn validate_artifact(
426 field: &str,
427 artifact: &TestArtifact,
428 require_pin: bool,
429) -> Result<(), LiveCaptureError> {
430 validate_name(&format!("{field}.name"), &artifact.name)?;
431 if require_pin {
432 validate_name(
433 &format!("{field}.revision"),
434 artifact.revision.as_deref().unwrap_or_default(),
435 )?;
436 validate_name(
437 &format!("{field}.path"),
438 artifact.path.as_deref().unwrap_or_default(),
439 )?;
440 validate_sha256(
441 &format!("{field}.sha256"),
442 artifact.sha256.as_deref().unwrap_or_default(),
443 )?;
444 }
445 Ok(())
446}
447
448fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
449 if !is_sha256_digest(digest) {
450 return Err(invalid(format!(
451 "{field} must be a 64-character hexadecimal SHA-256 digest"
452 )));
453 }
454 Ok(())
455}
456
457fn normalize_game(value: &str) -> String {
458 value
459 .chars()
460 .filter(char::is_ascii_alphanumeric)
461 .flat_map(char::to_lowercase)
462 .collect()
463}
464
465fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
466 left.features.len() == right.features.len()
467 && left
468 .features
469 .iter()
470 .all(|feature| right.features.contains(feature))
471}
472
473fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
474 !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
475}
476
477#[cfg(test)]
478mod tests {
479 use super::super::conformance::{
480 CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
481 ReasonCode,
482 };
483 use super::*;
484 use workshop_rs::catalog::Catalog;
485
486 const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
487 const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
488
489 fn catalog() -> CatalogIdentity {
490 Catalog::builtin()
491 .expect("built-in catalog for constructed unit data")
492 .identity()
493 }
494
495 fn raw(digest: &str) -> TestArtifact {
496 TestArtifact {
497 name: "constructed-unit-test/raw.ws".to_string(),
498 revision: Some("unit-test-revision".to_string()),
499 path: Some("constructed-unit-test/raw.ws".to_string()),
500 sha256: Some(digest.to_string()),
501 license: Some("MIT".to_string()),
502 }
503 }
504
505 fn feature(name: &str) -> FeatureId {
506 FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
507 .expect("constructed feature identity")
508 }
509
510 fn result(
511 identity: &CatalogIdentity,
512 locale: &Locale,
513 raw: &TestArtifact,
514 case_id: &str,
515 status: ConformanceStatus,
516 feature_name: &str,
517 ) -> ConformanceResult {
518 let matched = status == ConformanceStatus::Matched;
519 ConformanceResult {
520 schema_version: CONFORMANCE_SCHEMA_VERSION,
521 case_id: case_id.to_string(),
522 features: vec![feature(feature_name)],
523 status,
524 comparison: Comparison {
525 mode: if matched {
526 Equivalence::Normalized
527 } else {
528 Equivalence::NotComparable
529 },
530 expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
531 observed: matched.then(|| TestArtifact {
532 name: "constructed-unit-test/observed.ws".to_string(),
533 revision: Some("unit-test-revision".to_string()),
534 path: Some("constructed-unit-test/raw.ws".to_string()),
535 sha256: Some(raw.sha256.clone().unwrap()),
536 license: Some("MIT".to_string()),
537 }),
538 normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
539 },
540 source: raw.clone(),
541 catalog: identity.clone(),
542 locale: Some(locale.clone()),
543 reason: (!matched).then(|| ConformanceReason {
544 code: ReasonCode::Inconclusive,
545 detail: "constructed unit uncertainty".to_string(),
546 }),
547 }
548 }
549
550 fn make_capture(
551 capture_id: &str,
552 locale: &str,
553 digest: &str,
554 result_status: ConformanceStatus,
555 feature_name: &str,
556 ) -> LiveCapture {
557 let identity = catalog();
558 let locale = Locale::new(locale);
559 let raw = raw(digest);
560 LiveCapture {
561 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
562 capture_id: capture_id.to_string(),
563 game: "overwatch-2".to_string(),
564 client: "constructed-unit-test-client".to_string(),
565 season: "constructed-unit-test-season".to_string(),
566 captured_at: "2026-08-18T00:00:00Z".to_string(),
567 environment: "constructed schema/diff unit test; not a client capture".to_string(),
568 locale: locale.clone(),
569 catalog: identity.clone(),
570 census: CensusIdentity {
571 schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
572 digest: DIGEST.to_string(),
573 shards: vec!["constructed-unit-test-shard".to_string()],
574 },
575 raw_artifact: raw.clone(),
576 results: vec![result(
577 &identity,
578 &locale,
579 &raw,
580 "constructed-unit-test/case",
581 result_status,
582 feature_name,
583 )],
584 }
585 }
586
587 #[test]
588 fn constructed_capture_schema_round_trips_without_runtime_claim() {
589 let capture = make_capture(
590 "capture-a",
591 "en-US",
592 DIGEST,
593 ConformanceStatus::Matched,
594 "one",
595 );
596 let json = capture.to_json().expect("constructed schema serializes");
597 let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
598 assert_eq!(decoded, capture);
599 }
600
601 #[test]
602 fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
603 let mut capture = make_capture(
604 "capture-a",
605 "en-US",
606 DIGEST,
607 ConformanceStatus::Matched,
608 "one",
609 );
610 capture.raw_artifact.sha256 = None;
611 assert!(capture.validate().is_err());
612
613 let mut capture = make_capture(
614 "capture-a",
615 "en-US",
616 DIGEST,
617 ConformanceStatus::Matched,
618 "one",
619 );
620 capture.game = "not-overwatch".to_string();
621 assert!(capture.validate().is_err());
622
623 let mut capture = make_capture(
624 "capture-a",
625 "en-US",
626 DIGEST,
627 ConformanceStatus::Matched,
628 "one",
629 );
630 capture.results[0].source.name = "different source".to_string();
631 assert!(capture.validate().is_err());
632 }
633
634 #[test]
635 fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
636 let prior = make_capture(
637 "capture-a",
638 "en-US",
639 DIGEST,
640 ConformanceStatus::Matched,
641 "one",
642 );
643 let mut newer = make_capture(
644 "capture-b",
645 "zh-CN",
646 OTHER_DIGEST,
647 ConformanceStatus::Inconclusive,
648 "two",
649 );
650 newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
651 newer.results[0].catalog = newer.catalog.clone();
652 let diff = prior.diff(&newer).expect("constructed diff validates");
653 let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
654 assert!(categories.contains(&DiffCategory::Locale));
655 assert!(categories.contains(&DiffCategory::Catalog));
656 assert!(categories.contains(&DiffCategory::Content));
657 assert!(categories.contains(&DiffCategory::SemanticSchema));
658 assert!(
659 diff.changes
660 .iter()
661 .any(|entry| entry.detail.contains("conformance status changed")),
662 "status drift from a comparable baseline must be a change: {:?}",
663 diff.changes
664 );
665 assert!(
666 diff.runtime_uncertainty
667 .iter()
668 .all(|entry| !entry.detail.contains("comparable semantic match")),
669 "a certain baseline reports drift as a change, not uncertainty"
670 );
671 let json = diff.to_json().expect("structured diff serializes");
672 let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
673 assert!(document["changes"].is_array());
674 assert!(document["runtimeUncertainty"].is_array());
675 }
676
677 #[test]
678 fn diff_reports_uncertainty_when_baseline_not_comparable() {
679 let prior = make_capture(
680 "capture-a",
681 "en-US",
682 DIGEST,
683 ConformanceStatus::Inconclusive,
684 "one",
685 );
686 let newer = make_capture(
687 "capture-b",
688 "en-US",
689 DIGEST,
690 ConformanceStatus::Matched,
691 "one",
692 );
693 let diff = prior.diff(&newer).expect("constructed diff validates");
694 assert!(
695 diff.runtime_uncertainty
696 .iter()
697 .any(|entry| entry.category == DiffCategory::RuntimeUncertainty),
698 "an uncertain baseline cannot attribute drift to a real change"
699 );
700 assert!(
701 !diff
702 .changes
703 .iter()
704 .any(|entry| entry.detail.contains("conformance status changed")),
705 "status drift on an uncertain baseline is not a change: {:?}",
706 diff.changes
707 );
708 }
709
710 #[test]
711 fn timestamp_accepts_rfc3339_offsets() {
712 for valid in [
713 "2026-08-18T00:00:00Z",
714 "2026-08-18T00:00:00z",
715 "2026-08-18T00:00:00-08:00",
716 "2026-08-18T00:00:00+08:00",
717 ] {
718 validate_timestamp(valid).unwrap_or_else(|_| panic!("{valid} must validate"));
719 }
720 for invalid in ["2026-08-18", "2026-08-18T00:00:00", "not+a+timestamp"] {
721 assert!(validate_timestamp(invalid).is_err(), "{invalid} must fail");
722 }
723 }
724
725 #[test]
726 fn diff_refuses_capture_with_invalid_result_metadata() {
727 let prior = make_capture(
728 "capture-a",
729 "en-US",
730 DIGEST,
731 ConformanceStatus::Matched,
732 "one",
733 );
734 let mut newer = make_capture(
735 "capture-b",
736 "en-US",
737 DIGEST,
738 ConformanceStatus::Matched,
739 "one",
740 );
741 newer.results[0].catalog.catalog_digest = Some("different".to_string());
742 assert!(prior.diff(&newer).is_err());
743 }
744}