use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
fn default_result_schema_version() -> u32 {
0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SpecCheckResult {
#[serde(default = "default_result_schema_version")]
pub result_schema_version: u32,
pub snapshot_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_sha256: Option<String>,
pub spec_content_hash: String,
pub spec_version: String,
pub page_id: String,
pub state_results: Vec<StateMatchResult>,
pub summary: SpecCheckSummary,
pub bridge_fingerprint: BridgeFingerprint,
pub evaluated_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SpecCheckSummary {
pub match_outcome: MatchOutcome,
pub overall_match_rate: f32,
pub severity_counts: AssertionSeverityCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommended_state: Option<RecommendedState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommendation_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct RecommendedState {
pub state_id: String,
pub confidence: Confidence,
pub reason: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MatchOutcome {
FullMatch,
PartialMatch,
NoMatch,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[schemars(title = "SpecCheckConfidence")]
pub enum Confidence {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct StateMatchResult {
pub state_id: String,
pub state_name: String,
pub match_rate: f32,
pub assertions: Vec<AssertionResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct AssertionResult {
pub assertion_id: String,
pub description: String,
pub severity: String,
pub category: String,
pub outcome: AssertionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum AssertionOutcome {
Pass {
matched: MatchedElement,
},
Fail {
miss: AssertionMiss,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct MatchedElement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct AssertionMiss {
pub reason: MissReason,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<CandidateMiss>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct CandidateMiss {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub field_diffs: Vec<FieldDiff>,
pub score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct FieldDiff {
pub field: String,
pub expected: serde_json::Value,
pub actual: serde_json::Value,
pub similarity: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MissReason {
NoCandidates,
RoleMismatch,
TextMismatch,
VisibilityMismatch,
AttributeMismatch,
MultipleMatches,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct AssertionSeverityCounts {
#[serde(default)]
pub critical: u32,
#[serde(default)]
pub error: u32,
#[serde(default)]
pub warning: u32,
#[serde(default)]
pub info: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct BridgeFingerprint {
pub app_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_version: Option<String>,
pub snapshot_timestamp: String,
pub element_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SpecValidation {
pub page_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub degenerate_state_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub indistinguishable_state_pairs: Vec<[String; 2]>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SpecCheckStepConfig {
pub page_id: String,
pub policy: SpecCheckPolicy,
#[serde(default)]
pub fail_when_no_app: bool,
#[serde(default)]
pub fail_when_no_spec: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fail_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct SpecCheckPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conjuncts: Vec<PolicyConjunct>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct PolicyConjunct {
pub name: String,
pub scope: AssertionScope,
pub rule: ConjunctRule,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct AssertionScope {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub states: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub severities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assertion_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConjunctRule {
AllPass,
MaxFailures {
count: u32,
},
FailureRateBelow {
rate: f32,
},
StateMatchRateAtLeast {
rate: f32,
},
AnyStateMatchRateAtLeast {
rate: f32,
},
MatchOutcomeAtLeast {
outcome: MatchOutcome,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct PolicyEvaluation {
pub overall_status: PolicyStatus,
pub conjunct_results: Vec<ConjunctEvaluation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct ConjunctEvaluation {
pub name: String,
pub status: PolicyStatus,
pub evidence: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum PolicyStatus {
Pass,
Fail,
Indeterminate,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conjunct_rule_round_trip_all_variants() {
let cases = vec![
serde_json::json!({"kind": "all_pass"}),
serde_json::json!({"kind": "max_failures", "count": 3}),
serde_json::json!({"kind": "failure_rate_below", "rate": 0.125}),
serde_json::json!({"kind": "state_match_rate_at_least", "rate": 0.5}),
serde_json::json!({"kind": "any_state_match_rate_at_least", "rate": 0.75}),
serde_json::json!({"kind": "match_outcome_at_least", "outcome": "partial_match"}),
];
for input in cases {
let rule: ConjunctRule = serde_json::from_value(input.clone()).expect("deserialize");
let re_serialized = serde_json::to_value(&rule).expect("serialize");
assert_eq!(re_serialized, input, "round-trip mismatch for {input}");
}
}
}