Skip to main content

domi_server/events/
event.rs

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    // Order matters: serde untagged tries variants in source order. Click
45    // and RailAdd are both optional-everything (their required fields
46    // all have defaults or Option types). Listed before Click so a
47    // `{body, targetId}` payload — the rail-add wire shape from
48    // domi-audit.js — binds to RailAdd rather than accidentally binding
49    // to Click with `body` ignored as an unknown field.
50    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}