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