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