use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IrElementCriteria {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aria_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accessible_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IrProvenance {
pub source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub app_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<ProposalStatus>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ProposalStatus {
Proposed,
Pending,
Promoted,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IrMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_elements: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IrCrossRef {
pub doc: String,
#[serde(rename = "ref")]
pub r#ref: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrWaitSpec {
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<IrElementCriteria>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub property: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quiet_period_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrTransitionAction {
#[serde(rename = "type")]
pub kind: String,
pub target: IrElementCriteria,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_after: Option<IrWaitSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IrStateCondition {
pub element: IrElementCriteria,
pub property: String,
pub expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comparator: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrState {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub assertions: Vec<IrAssertion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excluded_elements: Option<Vec<IrElementCriteria>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conditions: Option<Vec<IrStateCondition>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_initial: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_terminal: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub precondition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_ids: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incoming_transitions: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<IrMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<IrProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cross_refs: Option<Vec<IrCrossRef>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrTransition {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub from_states: Vec<String>,
pub activate_states: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_states: Option<Vec<String>>,
pub actions: Vec<IrTransitionAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bidirectional: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<IrMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<IrProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cross_refs: Option<Vec<IrCrossRef>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrPageSpec {
pub version: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<IrMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<IrProvenance>,
pub states: Vec<IrState>,
pub transitions: Vec<IrTransition>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "synthesizedGroups"
)]
pub synthesized_groups: Option<Vec<IrGroup>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_assertions: Option<Vec<IrApiCheck>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrApiCheck {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub request: IrApiRequest,
#[serde(default = "default_read_effect")]
pub effect: String,
pub assertions: Vec<IrApiAssertion>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrApiRequest {
pub method: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type")]
pub enum IrApiAssertion {
#[serde(rename = "status_code")]
StatusCode {
expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
operator: Option<String>,
},
#[serde(rename = "json_path")]
JsonPath {
json_path: String,
expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
operator: Option<String>,
},
#[serde(rename = "header")]
Header {
header_name: String,
expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
operator: Option<String>,
},
#[serde(rename = "body_contains")]
BodyContains { expected: serde_json::Value },
#[serde(rename = "response_time")]
ResponseTime {
expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
operator: Option<String>,
},
#[serde(rename = "conforms_to")]
ConformsTo { schema: String },
}
fn default_read_effect() -> String {
"read".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct IrAssertionTarget {
#[serde(rename = "type")]
pub kind: String,
pub criteria: serde_json::Value,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IrAssertion {
pub id: String,
pub description: String,
pub category: String,
pub severity: String,
pub assertion_type: String,
pub target: IrAssertionTarget,
pub source: String,
pub reviewed: bool,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub precondition: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct IrGroup {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub assertions: Vec<IrAssertion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct LegacyAssertionTarget {
#[serde(rename = "type")]
pub kind: String,
pub criteria: serde_json::Value,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LegacyAssertion {
pub id: String,
pub description: String,
pub category: String,
pub severity: String,
pub assertion_type: String,
pub target: LegacyAssertionTarget,
pub source: String,
pub reviewed: bool,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub precondition: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct LegacyGroup {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub assertions: Vec<LegacyAssertion>,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LegacyProcessStep {
pub action: String,
pub target: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_after: Option<IrWaitSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LegacyTransition {
pub id: String,
pub name: String,
pub activate_states: Vec<String>,
pub deactivate_states: Vec<String>,
pub stays_visible: bool,
pub process: Vec<LegacyProcessStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LegacyStateMachineState {
pub id: String,
pub name: String,
pub description: String,
pub elements: Vec<serde_json::Value>,
pub is_initial: bool,
pub transitions: Vec<LegacyTransition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct LegacyStateMachine {
pub states: Vec<LegacyStateMachineState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LegacySpec {
pub version: String,
pub description: String,
pub groups: Vec<LegacyGroup>,
pub state_machine: LegacyStateMachine,
pub metadata: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provenance_without_status_round_trips_byte_identical() {
let json = r#"{"source":"hand-authored","file":"a.tsx","line":12}"#;
let parsed: IrProvenance = serde_json::from_str(json).unwrap();
assert_eq!(parsed.status, None);
let reserialized = serde_json::to_string(&parsed).unwrap();
assert_eq!(reserialized, json);
}
#[test]
fn provenance_with_proposed_status_round_trips() {
let json = r#"{"source":"ai-generated","status":"proposed"}"#;
let parsed: IrProvenance = serde_json::from_str(json).unwrap();
assert_eq!(parsed.status, Some(ProposalStatus::Proposed));
let reserialized = serde_json::to_string(&parsed).unwrap();
assert_eq!(reserialized, json);
}
#[test]
fn proposal_status_kebab_case_variants() {
for (wire, variant) in [
("\"proposed\"", ProposalStatus::Proposed),
("\"pending\"", ProposalStatus::Pending),
("\"promoted\"", ProposalStatus::Promoted),
] {
let parsed: ProposalStatus = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, variant);
assert_eq!(serde_json::to_string(&parsed).unwrap(), wire);
}
}
}