Skip to main content

workshop_rs/
conformance.rs

1//! Public contracts for canonical Workshop conformance evidence.
2//!
3//! This module identifies Workshop capabilities by locale-independent
4//! canonical names and records results against evidence that is independent
5//! from the implementation producing the observed output. It deliberately
6//! does not define source-language provider identities or semantics.
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11use crate::catalog::{Catalog, CatalogIdentity, Kind, Locale};
12
13/// The current machine-readable conformance schema version.
14pub const CONFORMANCE_SCHEMA_VERSION: u32 = 1;
15
16/// The owner namespace of a Workshop capability identity.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum FeatureNamespace {
20    /// An identity declared by the canonical Workshop catalog.
21    Catalog,
22    /// A Workshop IR or structural identity owned by this crate.
23    Wir,
24    /// A canonical custom-game settings path owned by this crate.
25    Settings,
26    /// A locale conversion or localization capability owned by this crate.
27    Localization,
28}
29
30impl FeatureNamespace {
31    /// The stable serialized spelling of this namespace.
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Catalog => "catalog",
35            Self::Wir => "wir",
36            Self::Settings => "settings",
37            Self::Localization => "localization",
38        }
39    }
40}
41
42/// The canonical category of a Workshop capability.
43///
44/// Catalog-backed categories use the canonical catalog identity as `name`.
45/// The remaining categories identify WIR or Workshop structural capabilities;
46/// none of these names are localized or borrowed from a source-language
47/// provider.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum FeatureKind {
51    /// A Workshop event declaration.
52    Event,
53    /// A Workshop action.
54    Action,
55    /// A Workshop value.
56    Value,
57    /// A Workshop operator.
58    Operator,
59    /// An enumerated value domain.
60    Enum,
61    /// A member of an enumerated value domain.
62    EnumMember,
63    /// A Workshop custom-game setting.
64    Setting,
65    /// A Workshop variable declaration or reference.
66    Variable,
67    /// A Workshop subroutine declaration or call.
68    Subroutine,
69    /// A control-flow construct represented by Workshop IR.
70    ControlFlow,
71    /// A user-visible Workshop string value.
72    String,
73    /// A locale or localized spelling operation.
74    Localization,
75    /// A Workshop content identity such as a hero, map, or mode.
76    ContentId,
77    /// A structural Workshop construct not covered by another category.
78    Structural,
79}
80
81impl FeatureKind {
82    /// The stable serialized spelling of this category.
83    pub const fn as_str(self) -> &'static str {
84        match self {
85            Self::Event => "event",
86            Self::Action => "action",
87            Self::Value => "value",
88            Self::Operator => "operator",
89            Self::Enum => "enum",
90            Self::EnumMember => "enum-member",
91            Self::Setting => "setting",
92            Self::Variable => "variable",
93            Self::Subroutine => "subroutine",
94            Self::ControlFlow => "control-flow",
95            Self::String => "string",
96            Self::Localization => "localization",
97            Self::ContentId => "content-id",
98            Self::Structural => "structural",
99        }
100    }
101}
102
103impl From<Kind> for FeatureKind {
104    fn from(kind: Kind) -> Self {
105        match kind {
106            Kind::Structural => Self::Structural,
107            Kind::Action => Self::Action,
108            Kind::Value => Self::Value,
109            Kind::Event => Self::Event,
110            Kind::Operator => Self::Operator,
111            Kind::Enum => Self::Enum,
112            Kind::Setting => Self::Setting,
113        }
114    }
115}
116
117/// A stable, locale-independent Workshop feature identity.
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
119pub struct FeatureId {
120    /// The WrightKit repository-owned namespace for this identity.
121    pub namespace: FeatureNamespace,
122    /// The semantic category of the feature.
123    pub kind: FeatureKind,
124    /// The canonical name within the category.
125    pub name: String,
126}
127
128impl FeatureId {
129    /// Construct a feature identity from a canonical category and name.
130    ///
131    /// Names are intentionally opaque to this contract because catalog and
132    /// WIR owners define their canonical names. Whitespace and control
133    /// characters are rejected so serialized identities remain unambiguous.
134    pub fn new(
135        namespace: FeatureNamespace,
136        kind: FeatureKind,
137        name: impl Into<String>,
138    ) -> Result<Self, ConformanceError> {
139        let name = name.into();
140        if name.is_empty() {
141            return Err(ConformanceError::invalid(
142                "feature.name",
143                "must not be empty",
144            ));
145        }
146        if name
147            .chars()
148            .any(|character| character.is_whitespace() || character.is_control())
149        {
150            return Err(ConformanceError::invalid(
151                "feature.name",
152                "must not contain whitespace or control characters",
153            ));
154        }
155        Ok(Self {
156            namespace,
157            kind,
158            name,
159        })
160    }
161
162    /// Construct a feature identity from a canonical catalog kind and id.
163    pub fn from_catalog(kind: Kind, id: impl Into<String>) -> Result<Self, ConformanceError> {
164        Self::new(FeatureNamespace::Catalog, kind.into(), id)
165    }
166
167    /// Construct a canonical enum-member identity that retains its domain.
168    pub fn from_enum_member(
169        domain: impl Into<String>,
170        member: impl Into<String>,
171    ) -> Result<Self, ConformanceError> {
172        let domain = domain.into();
173        let member = member.into();
174        if domain.is_empty() || member.is_empty() {
175            return Err(ConformanceError::invalid(
176                "feature.name",
177                "enum member identities require a domain and member",
178            ));
179        }
180        Self::new(
181            FeatureNamespace::Catalog,
182            FeatureKind::EnumMember,
183            format!("{domain}/{member}"),
184        )
185    }
186
187    /// Construct a feature identity owned by Workshop IR or another
188    /// `workshop-rs` namespace.
189    pub fn owned(
190        namespace: FeatureNamespace,
191        kind: FeatureKind,
192        name: impl Into<String>,
193    ) -> Result<Self, ConformanceError> {
194        Self::new(namespace, kind, name)
195    }
196}
197
198/// The kind of evidence supporting a conformance expectation.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum EvidenceBasis {
202    /// A reproducible observation from the Overwatch Workshop client.
203    WorkshopClient,
204    /// A pinned, independently maintained compatibility oracle.
205    PinnedExternalOracle,
206    /// An accepted semantic or public API contract.
207    SemanticContract,
208    /// A preserved behavior from a provenance-linked real project.
209    PreservedRegression,
210}
211
212/// The corpus/evidence layer containing a conformance case.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum EvidenceClass {
216    /// A small synthetic, unit, or property case.
217    Synthetic,
218    /// A minimized regression extracted from a real project.
219    MinimizedRegression,
220    /// A complete real-world project or corpus case.
221    RealProject,
222    /// An observation captured from a live Workshop client.
223    LiveClient,
224}
225
226/// An immutable source or artifact identity.
227#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
228pub struct EvidenceArtifact {
229    /// Repository, fixture, oracle, or captured-artifact name.
230    pub name: String,
231    /// Immutable revision, release, or capture identifier where available.
232    pub revision: Option<String>,
233    /// Source path or artifact path within the named source.
234    pub path: Option<String>,
235    /// Content digest when the source is a materialized artifact.
236    #[serde(rename = "sha256")]
237    pub sha256: Option<String>,
238    /// License or redistribution note for preserved source material.
239    pub license: Option<String>,
240}
241
242impl EvidenceArtifact {
243    /// Construct a source with no optional provenance fields.
244    pub fn new(name: impl Into<String>) -> Self {
245        Self {
246            name: name.into(),
247            revision: None,
248            path: None,
249            sha256: None,
250            license: None,
251        }
252    }
253}
254
255/// The independent source that defines the expected behavior.
256#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ExpectationSource {
259    pub basis: EvidenceBasis,
260    pub artifact: EvidenceArtifact,
261    /// Issue, review, or other tracking reference for a known classification.
262    pub tracking_ref: Option<String>,
263}
264
265/// The live-client metadata attached to a client observation.
266#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ClientEvidence {
269    /// The game identity, normally `overwatch-2`.
270    pub game: String,
271    /// Client version when observable.
272    pub client_version: Option<String>,
273    /// Season or equivalent release identifier when observable.
274    pub season: Option<String>,
275    /// Capture date in an explicit ISO-8601 representation.
276    pub captured_at: String,
277    /// Environment notes that affect interpretation of the capture.
278    pub environment: Option<String>,
279}
280
281/// The implementation identity that produced the observed output.
282///
283/// This is measurement metadata only. It is never an [`EvidenceBasis`] and
284/// therefore cannot serve as its own correctness oracle.
285#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
286pub struct ImplementationIdentity {
287    pub name: String,
288    pub version: String,
289    pub revision: Option<String>,
290    /// The materialized implementation artifact, when one is recorded.
291    pub artifact: Option<EvidenceArtifact>,
292}
293
294/// Provenance attached to one conformance result.
295#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
296#[serde(rename_all = "camelCase")]
297pub struct Evidence {
298    pub class: EvidenceClass,
299    /// The fixture or observation being executed.
300    pub fixture: EvidenceArtifact,
301    /// The independent source that defines the expectation.
302    pub expectation: ExpectationSource,
303    /// The catalog identity used to interpret the case.
304    pub catalog: CatalogIdentity,
305    /// The source locale, when the case has localized input or output.
306    pub locale: Option<Locale>,
307    /// Live-client provenance, required for `LiveClient` evidence.
308    pub client: Option<ClientEvidence>,
309    /// The implementation under observation, when applicable.
310    pub implementation: Option<ImplementationIdentity>,
311}
312
313/// The equivalence contract used to compare expected and observed behavior.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
315#[serde(rename_all = "kebab-case")]
316pub enum Equivalence {
317    /// Compare canonical Workshop semantics, ignoring presentation details.
318    Semantic,
319    /// Compare a normalized representation that preserves the claimed
320    /// observable behavior.
321    Normalized,
322    /// Compare exact text only when text identity is part of the contract.
323    ExactText,
324    /// No comparison is claimed for this unsupported, gap, or inconclusive
325    /// result.
326    NotComparable,
327}
328
329/// The structured comparison attached to a conformance result.
330#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
331#[serde(rename_all = "camelCase")]
332pub struct Comparison {
333    pub mode: Equivalence,
334    pub expected: Option<EvidenceArtifact>,
335    pub observed: Option<EvidenceArtifact>,
336    /// The semantic/normalization procedure used for the comparison.
337    pub normalizer: Option<String>,
338}
339
340/// A stable reason code for a non-matching result.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
342#[serde(rename_all = "kebab-case")]
343pub enum ReasonCode {
344    Unsupported,
345    KnownGap,
346    UnexpectedRegression,
347    Inconclusive,
348}
349
350/// Structured detail for a non-matching result.
351#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
352#[serde(rename_all = "camelCase")]
353pub struct ConformanceReason {
354    pub code: ReasonCode,
355    pub detail: String,
356    pub tracking_ref: Option<String>,
357}
358
359/// The conformance state of one case for one or more Workshop features.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
361#[serde(rename_all = "kebab-case")]
362pub enum ConformanceStatus {
363    /// Observed behavior satisfies the declared comparison contract.
364    Matched,
365    /// The feature is outside the implementation's declared supported
366    /// surface.
367    Unsupported,
368    /// A known mismatch remains tracked and visible.
369    KnownGap,
370    /// Behavior diverged unexpectedly from the independent expectation.
371    UnexpectedRegression,
372    /// Available evidence is insufficient to classify the behavior.
373    Inconclusive,
374}
375
376impl ConformanceStatus {
377    /// Whether this status is a successful conformance match.
378    pub const fn is_match(self) -> bool {
379        matches!(self, Self::Matched)
380    }
381}
382
383/// One machine-readable Workshop conformance result.
384#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ConformanceResult {
387    pub schema_version: u32,
388    /// Stable identity for the fixture or probe case.
389    pub case_id: String,
390    /// Features exercised or implicated by this result.
391    pub features: Vec<FeatureId>,
392    pub status: ConformanceStatus,
393    pub comparison: Comparison,
394    pub evidence: Evidence,
395    /// Required for non-matching states. This is a diagnostic, not an
396    /// expected-output oracle.
397    pub reason: Option<ConformanceReason>,
398}
399
400impl ConformanceResult {
401    /// Validate the cross-field invariants of a serialized result.
402    pub fn validate(&self) -> Result<(), ConformanceError> {
403        if self.schema_version != CONFORMANCE_SCHEMA_VERSION {
404            return Err(ConformanceError::invalid(
405                "schemaVersion",
406                format!(
407                    "unsupported schema version {}; expected {}",
408                    self.schema_version, CONFORMANCE_SCHEMA_VERSION
409                ),
410            ));
411        }
412        validate_non_empty("caseId", &self.case_id)?;
413        if self.features.is_empty() {
414            return Err(ConformanceError::invalid(
415                "features",
416                "must contain at least one feature",
417            ));
418        }
419        let mut seen_features: HashSet<&FeatureId> = HashSet::with_capacity(self.features.len());
420        for (index, feature) in self.features.iter().enumerate() {
421            FeatureId::new(feature.namespace, feature.kind, feature.name.clone())
422                .map_err(|error| error.at(format!("features[{index}]")))?;
423            if !seen_features.insert(feature) {
424                return Err(ConformanceError::invalid(
425                    format!("features[{index}]"),
426                    "must not contain duplicate feature identities",
427                ));
428            }
429        }
430        validate_evidence(&self.evidence)?;
431        if self.status.is_match() && self.comparison.mode == Equivalence::NotComparable {
432            return Err(ConformanceError::invalid(
433                "comparison.mode",
434                "matched results must declare semantic, normalized, or exact-text equivalence",
435            ));
436        }
437        validate_comparison(&self.comparison, &self.evidence)?;
438        if self.status.is_match() {
439            if self.comparison.expected.is_none() || self.comparison.observed.is_none() {
440                return Err(ConformanceError::invalid(
441                    "comparison",
442                    "matched results require expected and observed artifacts",
443                ));
444            }
445        } else {
446            let reason = self.reason.as_ref().ok_or_else(|| {
447                ConformanceError::invalid(
448                    "reason",
449                    "non-matching results require a structured reason",
450                )
451            })?;
452            validate_reason(self.status, reason)?;
453            if self.status == ConformanceStatus::UnexpectedRegression
454                && self.comparison.mode == Equivalence::NotComparable
455            {
456                return Err(ConformanceError::invalid(
457                    "comparison.mode",
458                    "an unexpected regression must identify the comparison contract",
459                ));
460            }
461        }
462        Ok(())
463    }
464
465    /// Validate this result against the actual canonical catalog used for the
466    /// case. Plain [`Self::validate`] checks the serialized contract only; it
467    /// cannot prove that a catalog identity name exists without the catalog.
468    pub fn validate_against(&self, catalog: &Catalog) -> Result<(), ConformanceError> {
469        self.validate()?;
470        if self.evidence.catalog != catalog.identity() {
471            return Err(ConformanceError::invalid(
472                "evidence.catalog",
473                "must match the catalog supplied to validate_against",
474            ));
475        }
476        for (index, feature) in self.features.iter().enumerate() {
477            if feature.namespace != FeatureNamespace::Catalog {
478                continue;
479            }
480            match feature.kind {
481                FeatureKind::Enum => {
482                    if catalog.enum_domain(&feature.name).is_none() {
483                        return Err(ConformanceError::invalid(
484                            format!("features[{index}]"),
485                            format!("unknown canonical enum domain '{}'", feature.name),
486                        ));
487                    }
488                }
489                FeatureKind::EnumMember => {
490                    let (domain, member) = feature.name.split_once('/').ok_or_else(|| {
491                        ConformanceError::invalid(
492                            format!("features[{index}]"),
493                            "enum-member identity must contain domain/member",
494                        )
495                    })?;
496                    let known = catalog.enum_domain(domain).is_some_and(|candidate| {
497                        candidate.members.iter().any(|item| item.member == member)
498                    });
499                    if !known {
500                        return Err(ConformanceError::invalid(
501                            format!("features[{index}]"),
502                            format!("unknown canonical enum member '{domain}/{member}'"),
503                        ));
504                    }
505                }
506                kind => {
507                    let catalog_kind = match kind {
508                        FeatureKind::Event => Kind::Event,
509                        FeatureKind::Action => Kind::Action,
510                        FeatureKind::Value => Kind::Value,
511                        FeatureKind::Operator => Kind::Operator,
512                        FeatureKind::Setting => Kind::Setting,
513                        FeatureKind::Structural => Kind::Structural,
514                        _ => {
515                            return Err(ConformanceError::invalid(
516                                format!("features[{index}]"),
517                                "this feature kind cannot use the catalog namespace",
518                            ));
519                        }
520                    };
521                    if catalog.entry(catalog_kind, &feature.name).is_none() {
522                        return Err(ConformanceError::invalid(
523                            format!("features[{index}]"),
524                            format!(
525                                "unknown canonical {} '{}'",
526                                catalog_kind.as_str(),
527                                feature.name
528                            ),
529                        ));
530                    }
531                }
532            }
533        }
534        Ok(())
535    }
536
537    /// Deserialize and validate a JSON result in one operation.
538    pub fn from_json(json: &str) -> Result<Self, ConformanceDecodeError> {
539        let result: Self = serde_json::from_str(json).map_err(ConformanceDecodeError::Json)?;
540        result.validate().map_err(ConformanceDecodeError::Invalid)?;
541        Ok(result)
542    }
543
544    /// Whether this result contributes to a successful conformance count.
545    pub const fn is_match(&self) -> bool {
546        self.status.is_match()
547    }
548}
549
550fn validate_evidence(evidence: &Evidence) -> Result<(), ConformanceError> {
551    validate_artifact(
552        "evidence.fixture",
553        &evidence.fixture,
554        false,
555        evidence.class == EvidenceClass::LiveClient,
556    )?;
557    validate_artifact(
558        "evidence.expectation.artifact",
559        &evidence.expectation.artifact,
560        matches!(
561            evidence.expectation.basis,
562            EvidenceBasis::PinnedExternalOracle | EvidenceBasis::PreservedRegression
563        ),
564        false,
565    )?;
566    if evidence.class == EvidenceClass::LiveClient
567        && evidence.expectation.basis != EvidenceBasis::WorkshopClient
568    {
569        return Err(ConformanceError::invalid(
570            "evidence.expectation.basis",
571            "live-client evidence must use workshop-client evidence basis",
572        ));
573    }
574    if matches!(
575        evidence.expectation.basis,
576        EvidenceBasis::PinnedExternalOracle | EvidenceBasis::PreservedRegression
577    ) && evidence
578        .expectation
579        .artifact
580        .revision
581        .as_deref()
582        .is_none_or(str::is_empty)
583    {
584        return Err(ConformanceError::invalid(
585            "evidence.expectation.artifact.revision",
586            "pinned oracle and preserved regression evidence require an immutable revision",
587        ));
588    }
589    if evidence.class == EvidenceClass::LiveClient {
590        let client = evidence.client.as_ref().ok_or_else(|| {
591            ConformanceError::invalid(
592                "evidence.client",
593                "live-client evidence requires client provenance",
594            )
595        })?;
596        validate_non_empty("evidence.client.game", &client.game)?;
597        validate_non_empty("evidence.client.capturedAt", &client.captured_at)?;
598        if evidence.locale.is_none() {
599            return Err(ConformanceError::invalid(
600                "evidence.locale",
601                "live-client evidence requires a client locale",
602            ));
603        }
604    } else if evidence.client.is_some() {
605        return Err(ConformanceError::invalid(
606            "evidence.client",
607            "client provenance is only valid for live-client evidence",
608        ));
609    }
610    if evidence.class == EvidenceClass::MinimizedRegression
611        && evidence.expectation.basis != EvidenceBasis::PreservedRegression
612    {
613        return Err(ConformanceError::invalid(
614            "evidence.expectation.basis",
615            "minimized-regression evidence must use preserved-regression basis",
616        ));
617    }
618    validate_non_empty(
619        "evidence.catalog.implementationVersion",
620        &evidence.catalog.implementation_version,
621    )?;
622    validate_non_empty(
623        "evidence.catalog.catalogVersion",
624        &evidence.catalog.catalog_version,
625    )?;
626    if evidence
627        .catalog
628        .catalog_digest
629        .as_deref()
630        .is_none_or(str::is_empty)
631    {
632        return Err(ConformanceError::invalid(
633            "evidence.catalog.catalogDigest",
634            "conformance evidence requires a pinned catalog digest",
635        ));
636    }
637    Ok(())
638}
639
640fn validate_artifact(
641    field: &str,
642    artifact: &EvidenceArtifact,
643    require_revision: bool,
644    require_digest: bool,
645) -> Result<(), ConformanceError> {
646    validate_non_empty(&format!("{field}.name"), &artifact.name)?;
647    if require_revision && artifact.revision.as_deref().is_none_or(str::is_empty) {
648        return Err(ConformanceError::invalid(
649            format!("{field}.revision"),
650            "must identify an immutable revision",
651        ));
652    }
653    if let Some(digest) = &artifact.sha256 {
654        if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
655            return Err(ConformanceError::invalid(
656                format!("{field}.sha256"),
657                "must be a 64-character hexadecimal SHA-256 digest",
658            ));
659        }
660    }
661    if require_digest && artifact.sha256.is_none() {
662        return Err(ConformanceError::invalid(
663            format!("{field}.sha256"),
664            "materialized evidence requires a SHA-256 digest",
665        ));
666    }
667    Ok(())
668}
669
670fn validate_comparison(
671    comparison: &Comparison,
672    evidence: &Evidence,
673) -> Result<(), ConformanceError> {
674    if comparison.mode == Equivalence::Normalized
675        && comparison
676            .normalizer
677            .as_deref()
678            .is_none_or(|normalizer| normalizer.trim().is_empty())
679    {
680        return Err(ConformanceError::invalid(
681            "comparison.normalizer",
682            "normalized comparisons require a named normalizer",
683        ));
684    }
685    if let Some(expected) = &comparison.expected {
686        validate_artifact("comparison.expected", expected, false, false)?;
687    }
688    if let Some(observed) = &comparison.observed {
689        validate_artifact("comparison.observed", observed, false, false)?;
690    }
691    if let (Some(expected), Some(observed)) = (&comparison.expected, &comparison.observed) {
692        if expected == observed {
693            return Err(ConformanceError::invalid(
694                "comparison",
695                "expected and observed artifacts must be distinct",
696            ));
697        }
698    }
699    if let Some(expected) = &comparison.expected {
700        if Some(expected) == Some(&evidence.fixture) {
701            return Err(ConformanceError::invalid(
702                "comparison.expected",
703                "expected artifact must not be the executed fixture",
704            ));
705        }
706        if evidence
707            .implementation
708            .as_ref()
709            .and_then(|implementation| implementation.artifact.as_ref())
710            == Some(expected)
711        {
712            return Err(ConformanceError::invalid(
713                "comparison.expected",
714                "expected artifact must not be the implementation artifact",
715            ));
716        }
717    }
718    if let Some(observed) = &comparison.observed {
719        if Some(observed) == Some(&evidence.fixture)
720            || Some(observed) == Some(&evidence.expectation.artifact)
721            || evidence
722                .implementation
723                .as_ref()
724                .and_then(|implementation| implementation.artifact.as_ref())
725                == Some(observed)
726        {
727            return Err(ConformanceError::invalid(
728                "comparison.observed",
729                "observed artifact must be distinct from fixture, expectation, and implementation artifacts",
730            ));
731        }
732    }
733    Ok(())
734}
735
736fn validate_reason(
737    status: ConformanceStatus,
738    reason: &ConformanceReason,
739) -> Result<(), ConformanceError> {
740    validate_non_empty("reason.detail", &reason.detail)?;
741    let expected = match status {
742        ConformanceStatus::Unsupported => ReasonCode::Unsupported,
743        ConformanceStatus::KnownGap => ReasonCode::KnownGap,
744        ConformanceStatus::UnexpectedRegression => ReasonCode::UnexpectedRegression,
745        ConformanceStatus::Inconclusive => ReasonCode::Inconclusive,
746        ConformanceStatus::Matched => {
747            return Err(ConformanceError::invalid(
748                "reason",
749                "matched results must not carry a non-matching reason",
750            ));
751        }
752    };
753    if reason.code != expected {
754        return Err(ConformanceError::invalid(
755            "reason.code",
756            "reason code must match conformance status",
757        ));
758    }
759    if reason.code == ReasonCode::KnownGap
760        && reason
761            .tracking_ref
762            .as_deref()
763            .is_none_or(|tracking_ref| tracking_ref.trim().is_empty())
764    {
765        return Err(ConformanceError::invalid(
766            "reason.trackingRef",
767            "known gaps require a tracking reference",
768        ));
769    }
770    Ok(())
771}
772
773fn validate_non_empty(field: &str, value: &str) -> Result<(), ConformanceError> {
774    if value.trim().is_empty() {
775        Err(ConformanceError::invalid(field, "must not be empty"))
776    } else {
777        Ok(())
778    }
779}
780
781/// A validation error for the public conformance contract.
782#[derive(Debug, Clone, PartialEq, Eq)]
783pub struct ConformanceError {
784    pub field: String,
785    pub message: String,
786}
787
788impl ConformanceError {
789    fn invalid(field: impl Into<String>, message: impl Into<String>) -> Self {
790        Self {
791            field: field.into(),
792            message: message.into(),
793        }
794    }
795
796    fn at(self, field: String) -> Self {
797        Self { field, ..self }
798    }
799}
800
801impl std::fmt::Display for ConformanceError {
802    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803        write!(
804            formatter,
805            "invalid conformance {}: {}",
806            self.field, self.message
807        )
808    }
809}
810
811impl std::error::Error for ConformanceError {}
812
813/// Errors returned by the validated JSON entry point.
814#[derive(Debug)]
815pub enum ConformanceDecodeError {
816    Json(serde_json::Error),
817    Invalid(ConformanceError),
818}
819
820impl std::fmt::Display for ConformanceDecodeError {
821    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822        match self {
823            Self::Json(error) => write!(formatter, "invalid conformance JSON: {error}"),
824            Self::Invalid(error) => error.fmt(formatter),
825        }
826    }
827}
828
829impl std::error::Error for ConformanceDecodeError {}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::catalog::{Catalog, Locale};
835
836    fn catalog() -> CatalogIdentity {
837        Catalog::builtin().expect("built-in catalog").identity()
838    }
839
840    fn evidence(class: EvidenceClass, basis: EvidenceBasis) -> Evidence {
841        Evidence {
842            class,
843            fixture: EvidenceArtifact {
844                name: "fixture".to_string(),
845                revision: Some("abc123".to_string()),
846                path: Some("cases/basic.ws".to_string()),
847                sha256: Some(
848                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
849                ),
850                license: Some("MIT".to_string()),
851            },
852            expectation: ExpectationSource {
853                basis,
854                artifact: EvidenceArtifact {
855                    name: "semantic-contract".to_string(),
856                    revision: Some("contract-1".to_string()),
857                    path: Some("docs/adr/0002-conformance-contract.md".to_string()),
858                    sha256: None,
859                    license: Some("MIT".to_string()),
860                },
861                tracking_ref: None,
862            },
863            catalog: catalog(),
864            locale: Some(Locale::new("en-US")),
865            client: None,
866            implementation: Some(ImplementationIdentity {
867                name: "workshop-rs".to_string(),
868                version: "0.1.0".to_string(),
869                revision: Some("impl123".to_string()),
870                artifact: None,
871            }),
872        }
873    }
874
875    fn hashed_artifact(name: &str) -> EvidenceArtifact {
876        EvidenceArtifact {
877            name: name.to_string(),
878            sha256: Some(
879                "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
880            ),
881            ..EvidenceArtifact::new(name)
882        }
883    }
884
885    fn matched() -> ConformanceResult {
886        ConformanceResult {
887            schema_version: CONFORMANCE_SCHEMA_VERSION,
888            case_id: "basic-action".to_string(),
889            features: vec![
890                FeatureId::from_catalog(Kind::Action, "setHealth").expect("valid feature"),
891            ],
892            status: ConformanceStatus::Matched,
893            comparison: Comparison {
894                mode: Equivalence::Semantic,
895                expected: Some(hashed_artifact("expected")),
896                observed: Some(hashed_artifact("observed")),
897                normalizer: Some("canonical-wir".to_string()),
898            },
899            evidence: evidence(EvidenceClass::Synthetic, EvidenceBasis::SemanticContract),
900            reason: None,
901        }
902    }
903
904    #[test]
905    fn feature_ids_are_locale_and_provider_independent() {
906        let feature = FeatureId::from_catalog(Kind::Action, "setHealth").expect("valid feature");
907        assert_eq!(feature.kind, FeatureKind::Action);
908        assert_eq!(feature.name, "setHealth");
909        assert_eq!(
910            serde_json::to_string(&feature).unwrap(),
911            r#"{"namespace":"catalog","kind":"action","name":"setHealth"}"#
912        );
913        let member = FeatureId::from_enum_member("Hero", "ANA").expect("valid member");
914        assert_eq!(member.name, "Hero/ANA");
915    }
916
917    #[test]
918    fn result_serializes_and_round_trips() {
919        let result = matched();
920        result.validate().expect("valid result");
921        result
922            .validate_against(&Catalog::builtin().expect("built-in catalog"))
923            .expect("catalog-backed feature exists");
924        let json = serde_json::to_string(&result).expect("serialize result");
925        let decoded = ConformanceResult::from_json(&json).expect("deserialize valid result");
926        assert_eq!(decoded, result);
927    }
928
929    #[test]
930    fn catalog_validation_rejects_fabricated_catalog_features() {
931        let mut result = matched();
932        result.features = vec![
933            FeatureId::from_catalog(Kind::Action, "notAWorkshopAction")
934                .expect("syntactically valid feature"),
935        ];
936        assert!(
937            result
938                .validate_against(&Catalog::builtin().expect("built-in catalog"))
939                .is_err()
940        );
941    }
942
943    #[test]
944    fn catalog_validation_rejects_mismatched_catalog_evidence() {
945        let mut result = matched();
946        result.evidence.catalog.catalog_digest = Some("f".repeat(64));
947        let catalog = Catalog::builtin().expect("built-in catalog");
948
949        let error = result
950            .validate_against(&catalog)
951            .expect_err("evidence must be bound to the supplied catalog");
952        assert_eq!(error.field, "evidence.catalog");
953    }
954
955    #[test]
956    fn known_gap_is_not_a_match_and_requires_detail() {
957        let mut result = matched();
958        result.status = ConformanceStatus::KnownGap;
959        result.comparison.mode = Equivalence::NotComparable;
960        assert!(result.validate().is_err());
961        result.reason = Some(ConformanceReason {
962            code: ReasonCode::KnownGap,
963            detail: "client spelling is not yet evidenced".to_string(),
964            tracking_ref: Some("#18".to_string()),
965        });
966        result.validate().expect("documented gap");
967        assert!(!result.is_match());
968    }
969
970    #[test]
971    fn duplicate_features_and_blank_details_are_invalid() {
972        let mut result = matched();
973        result.features.push(result.features[0].clone());
974        assert!(result.validate().is_err());
975
976        let mut result = matched();
977        result.status = ConformanceStatus::Inconclusive;
978        result.reason = Some(ConformanceReason {
979            code: ReasonCode::Inconclusive,
980            detail: "  \n".to_string(),
981            tracking_ref: None,
982        });
983        assert!(result.validate().is_err());
984    }
985
986    #[test]
987    fn matched_artifacts_cannot_reuse_fixture_or_implementation_output() {
988        let mut result = matched();
989        result.comparison.observed = Some(result.evidence.fixture.clone());
990        assert!(result.validate().is_err());
991
992        let mut result = matched();
993        let implementation_artifact = hashed_artifact("implementation-output");
994        result.evidence.implementation.as_mut().unwrap().artifact =
995            Some(implementation_artifact.clone());
996        result.comparison.observed = Some(implementation_artifact);
997        assert!(result.validate().is_err());
998
999        let mut result = matched();
1000        let implementation_artifact = hashed_artifact("implementation-output");
1001        result.evidence.implementation.as_mut().unwrap().artifact =
1002            Some(implementation_artifact.clone());
1003        result.comparison.expected = Some(implementation_artifact);
1004        assert!(result.validate().is_err());
1005    }
1006
1007    #[test]
1008    fn live_client_requires_client_and_locale_provenance() {
1009        let mut result = matched();
1010        result.evidence.class = EvidenceClass::LiveClient;
1011        result.evidence.expectation.basis = EvidenceBasis::WorkshopClient;
1012        result.evidence.client = Some(ClientEvidence {
1013            game: "overwatch-2".to_string(),
1014            client_version: Some("season-1".to_string()),
1015            season: Some("season-1".to_string()),
1016            captured_at: "2026-08-18T00:00:00Z".to_string(),
1017            environment: None,
1018        });
1019        result.validate().expect("complete live evidence");
1020        result.evidence.locale = None;
1021        assert!(result.validate().is_err());
1022    }
1023
1024    #[test]
1025    fn implementation_output_cannot_be_the_evidence_basis() {
1026        let mut result = matched();
1027        result.evidence.implementation = Some(ImplementationIdentity {
1028            name: "changed-implementation".to_string(),
1029            version: "dev".to_string(),
1030            revision: None,
1031            artifact: None,
1032        });
1033        result
1034            .validate()
1035            .expect("implementation metadata is allowed");
1036        assert_ne!(
1037            result.evidence.expectation.basis,
1038            EvidenceBasis::PinnedExternalOracle,
1039            "implementation metadata is not an oracle"
1040        );
1041    }
1042
1043    #[test]
1044    fn validated_json_rejects_an_invalid_status_reason() {
1045        let mut value = serde_json::to_value(matched()).expect("serialize result");
1046        value["status"] = serde_json::json!("known-gap");
1047        let json = serde_json::to_string(&value).expect("serialize invalid result");
1048        assert!(ConformanceResult::from_json(&json).is_err());
1049    }
1050}