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