Skip to main content

tj_core/
event.rs

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        // ULID = 48-bit timestamp (10 base32 chars) + 80-bit random (16 base32 chars).
202        // Random portion is independent per call, so only the timestamp prefix is monotonic.
203        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}