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