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