1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4pub struct Rect {
5 pub x: f64,
6 pub y: f64,
7 pub w: f64,
8 pub h: f64,
9}
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Target {
13 pub id: Option<String>,
14 pub selector: Option<String>,
15 pub rect: Rect,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum Source {
20 #[serde(rename = "domi.js")]
21 DomiJs,
22 #[serde(rename = "domi-audit.js")]
23 DomiAuditJs,
24 #[serde(rename = "browser-ext")]
25 BrowserExt,
26 #[serde(rename = "unknown")]
27 Unknown,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum Kind {
33 Click,
34 Input,
35 Submit,
36 RailAdd,
37 RailResolve,
38 Custom,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(untagged)]
43pub enum EventData {
44 RailAdd { body: String, #[serde(rename = "targetId")] target_id: Option<String> },
51 Click { value: Option<String> },
52 Input { name: String, value: String },
53 Submit {
54 #[serde(rename = "formId")]
55 form_id: String,
56 fields: serde_json::Map<String, serde_json::Value>,
57 },
58 RailResolve {
59 #[serde(rename = "entryId")]
60 entry_id: ulid::Ulid,
61 },
62 Custom {
63 payload: serde_json::Map<String, serde_json::Value>,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct Event {
69 pub v: u8,
70 pub id: ulid::Ulid,
71 pub ts: chrono::DateTime<chrono::Utc>,
72 pub src: Source,
73 pub doc: String,
74 pub kind: Kind,
75 pub target: Target,
76 pub data: EventData,
77}
78
79#[cfg(test)]
80mod tests {
81 fn sample(kind: super::Kind, data: super::EventData) -> super::Event {
82 super::Event {
83 v: 2,
84 id: ulid::Ulid::from_string("01H8XZQ5K2J9Z9Q4X5Y6Z7XYZ0").unwrap(),
85 ts: chrono::DateTime::parse_from_rfc3339("2026-07-05T18:21:00Z")
86 .unwrap()
87 .with_timezone(&chrono::Utc),
88 src: super::Source::DomiJs,
89 doc: "onboarding-v2".to_string(),
90 kind,
91 target: super::Target {
92 id: Some("btn-save".into()),
93 selector: Some("main > .domi-card:nth-of-type(1)".into()),
94 rect: super::Rect { x: 120.0, y: 480.0, w: 200.0, h: 32.0 },
95 },
96 data,
97 }
98 }
99
100 #[test]
101 fn click_round_trips_byte_identical() {
102 let ev = sample(
103 super::Kind::Click,
104 super::EventData::Click { value: Some("Save".into()) },
105 );
106 let s = serde_json::to_string(&ev).unwrap();
107 let back: super::Event = serde_json::from_str(&s).unwrap();
108 assert_eq!(ev.doc, back.doc);
109 assert_eq!(ev.kind, back.kind);
110 match (&ev.data, &back.data) {
111 (super::EventData::Click { value: a }, super::EventData::Click { value: b }) => assert_eq!(a, b),
112 _ => panic!("kind mismatch after round-trip"),
113 }
114 }
115
116 #[test]
117 fn all_six_kinds_serialize() {
118 for (kind, data) in [
119 (super::Kind::Click, super::EventData::Click { value: Some("x".into()) }),
120 (super::Kind::Input, super::EventData::Input { name: "k".into(), value: "v".into() }),
121 (super::Kind::Submit, super::EventData::Submit { form_id: "f".into(), fields: serde_json::Map::new().into() }),
122 (super::Kind::RailAdd, super::EventData::RailAdd { body: "x".into(), target_id: Some("btn-save".into()) }),
123 (super::Kind::RailResolve, super::EventData::RailResolve {
124 entry_id: ulid::Ulid::from_string("01H8XZQ5K2J9Z9Q4X5Y6Z7XYZ9").unwrap(),
125 }),
126 (super::Kind::Custom, super::EventData::Custom { payload: serde_json::Map::new().into() }),
127 ] {
128 let ev = sample(kind, data);
129 let s = serde_json::to_string(&ev).expect("serialize");
130 let back: super::Event = serde_json::from_str(&s).expect("deserialize");
131 assert_eq!(ev.id, back.id);
132 }
133 }
134}