Skip to main content

workshop_rs_cli/
live_capture.rs

1//! Offline schema and comparison support for recorded client captures.
2
3use 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
13/// The current machine-readable live-capture schema version.
14pub const LIVE_CAPTURE_SCHEMA_VERSION: u32 = 1;
15
16/// One machine-readable capture from a manually operated Workshop client.
17#[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    /// The client version/build string as observed by the maintainer.
24    pub client: String,
25    pub season: String,
26    pub captured_at: String,
27    /// Environment notes, including platform and any conditions relevant to
28    /// interpreting import/export behavior.
29    pub environment: String,
30    pub locale: Locale,
31    pub catalog: CatalogIdentity,
32    pub census: CensusIdentity,
33    /// The exact exported Workshop text or archive, pinned by revision/path
34    /// and SHA-256. The bytes are intentionally not embedded in this schema.
35    pub raw_artifact: TestArtifact,
36    /// Feature-attributed results for the captured probes.
37    pub results: Vec<ConformanceResult>,
38}
39
40impl LiveCapture {
41    /// Validate a capture's structural and provenance contract. Historical
42    /// captures may refer to an older catalog identity, so this does not
43    /// silently substitute the current bundled catalog.
44    pub fn validate(&self) -> Result<(), LiveCaptureError> {
45        self.validate_structural(None)
46    }
47
48    /// Validate a current capture against the loaded canonical catalog.
49    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    /// Deserialize and validate a JSON capture in one operation.
130    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    /// Serialize a validated capture as stable, human-reviewable JSON.
138    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    /// Compare two validated captures without contacting a provider or client.
145    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/// The classification used by the offline capture comparison.
267#[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/// One feature-attributed or capture-level diff observation.
278#[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/// Machine-readable output of [`LiveCapture::diff`].
312#[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    /// Runtime/gameplay uncertainty is intentionally separate from
320    /// import/export changes and is always present for this workflow.
321    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/// A validation or serialization failure in the offline capture workflow.
346#[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}