1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
5#[serde(rename_all = "snake_case")]
6pub enum EventType {
7 Open,
8 Hypothesis,
9 Finding,
10 Evidence,
11 Decision,
12 Rejection,
13 Constraint,
14 Correction,
15 Reopen,
16 Supersede,
17 Close,
18 Redirect,
19}
20
21impl EventType {
22 pub const ALL: &'static [Self] = &[
23 Self::Open,
24 Self::Hypothesis,
25 Self::Finding,
26 Self::Evidence,
27 Self::Decision,
28 Self::Rejection,
29 Self::Constraint,
30 Self::Correction,
31 Self::Reopen,
32 Self::Supersede,
33 Self::Close,
34 Self::Redirect,
35 ];
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum Author {
41 User,
42 Agent,
43 Classifier,
44 Hook,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum Source {
50 Chat,
51 Hook,
52 Manual,
53 Cli,
54 Dream,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum EventStatus {
60 Confirmed,
61 Suggested,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(rename_all = "snake_case")]
66pub enum EvidenceStrength {
67 Weak,
68 Medium,
69 Strong,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73pub struct Refs {
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub commits: Vec<String>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub files: Vec<String>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub events: Vec<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
83pub struct Event {
84 pub event_id: String,
85 pub schema_version: String,
86 pub task_id: String,
87 #[serde(rename = "type")]
88 pub event_type: EventType,
89 pub timestamp: String,
90 pub author: Author,
91 pub source: Source,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub confidence: Option<f64>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub evidence_strength: Option<EvidenceStrength>,
96 pub text: String,
97 #[serde(default)]
98 pub refs: Refs,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub corrects: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub supersedes: Option<String>,
103 pub status: EventStatus,
104 #[serde(default)]
105 pub meta: serde_json::Value,
106}
107
108impl Event {
109 pub fn new(
110 task_id: impl Into<String>,
111 event_type: EventType,
112 author: Author,
113 source: Source,
114 text: String,
115 ) -> Self {
116 Event {
117 event_id: ulid::Ulid::new().to_string(),
118 schema_version: crate::SCHEMA_VERSION.to_string(),
119 task_id: task_id.into(),
120 event_type,
121 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
122 author,
123 source,
124 confidence: None,
125 evidence_strength: None,
126 text,
127 refs: Refs::default(),
128 corrects: None,
129 supersedes: None,
130 status: EventStatus::Confirmed,
131 meta: serde_json::json!({}),
132 }
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn event_type_serializes_to_snake_case() {
142 let t = EventType::Decision;
143 let s = serde_json::to_string(&t).unwrap();
144 assert_eq!(s, "\"decision\"");
145 }
146
147 #[test]
148 fn event_type_round_trip_all_variants() {
149 for ty in EventType::ALL {
150 let s = serde_json::to_string(&ty).unwrap();
151 let back: EventType = serde_json::from_str(&s).unwrap();
152 assert_eq!(*ty, back);
153 }
154 }
155
156 #[test]
157 fn author_source_status_strength_serialize_snake_case() {
158 assert_eq!(
159 serde_json::to_string(&Author::Classifier).unwrap(),
160 "\"classifier\""
161 );
162 assert_eq!(serde_json::to_string(&Source::Hook).unwrap(), "\"hook\"");
163 assert_eq!(
164 serde_json::to_string(&EventStatus::Suggested).unwrap(),
165 "\"suggested\""
166 );
167 assert_eq!(
168 serde_json::to_string(&EvidenceStrength::Strong).unwrap(),
169 "\"strong\""
170 );
171 }
172
173 #[test]
174 fn source_dream_serializes_to_snake_case() {
175 let j = serde_json::to_string(&Source::Dream).unwrap();
176 assert_eq!(j, "\"dream\"");
177 let back: Source = serde_json::from_str("\"dream\"").unwrap();
178 assert_eq!(back, Source::Dream);
179 }
180
181 #[test]
182 fn event_new_assigns_ulid_and_now() {
183 let a = Event::new(
184 "tj-1",
185 EventType::Open,
186 Author::User,
187 Source::Manual,
188 "first".into(),
189 );
190 let b = Event::new(
191 "tj-1",
192 EventType::Open,
193 Author::User,
194 Source::Manual,
195 "second".into(),
196 );
197 assert_ne!(a.event_id, b.event_id);
198 assert_eq!(a.event_id.len(), 26);
199 assert!(
202 a.event_id[..10] <= b.event_id[..10],
203 "ULID timestamp prefix must be monotonic"
204 );
205 assert_eq!(a.schema_version, "1.0");
206 assert_eq!(a.status, EventStatus::Confirmed);
207 chrono::DateTime::parse_from_rfc3339(&a.timestamp).expect("RFC3339");
208 }
209
210 #[test]
211 fn event_round_trip_all_fields() {
212 let e = Event {
213 event_id: "01HZX5K8000000000000000000".to_string(),
214 schema_version: "1.0".to_string(),
215 task_id: "tj-7f3a".to_string(),
216 event_type: EventType::Decision,
217 timestamp: "2026-05-14T12:00:00+04:00".to_string(),
218 author: Author::Agent,
219 source: Source::Chat,
220 confidence: Some(0.92),
221 evidence_strength: Some(EvidenceStrength::Strong),
222 text: "Adopt Rust + rmcp.".to_string(),
223 refs: Refs {
224 commits: vec!["a3f2dd".into()],
225 files: vec!["Cargo.toml".into()],
226 events: vec![],
227 },
228 corrects: None,
229 supersedes: None,
230 status: EventStatus::Confirmed,
231 meta: serde_json::json!({}),
232 };
233 let s = serde_json::to_string(&e).unwrap();
234 let back: Event = serde_json::from_str(&s).unwrap();
235 assert_eq!(e.event_id, back.event_id);
236 assert_eq!(e.event_type, back.event_type);
237 assert_eq!(e.refs.commits, back.refs.commits);
238 assert_eq!(e.confidence, back.confidence);
239 }
240}