1use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37pub const CONVERSATION_SCHEMA_VERSION: u32 = 1;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Message {
44 pub v: u32,
46 pub ts: DateTime<Utc>,
47 pub src: Source,
48 pub conv: String,
50 pub role: Role,
51 pub content: Content,
52 #[serde(default)]
53 pub meta: serde_json::Value,
54 #[serde(default)]
56 pub refs: Vec<String>,
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[serde(rename_all = "kebab-case")]
61pub enum Source {
62 ClaudeCode,
63 Cursor,
64 Gemini,
65 Aider,
66 Slack,
67 Telegram,
68 Discord,
69 CommanderEngine,
70}
71
72impl Source {
73 pub fn file_prefix(&self) -> &'static str {
75 match self {
76 Source::ClaudeCode => "cc",
77 Source::Cursor => "cursor",
78 Source::Gemini => "gemini",
79 Source::Aider => "aider",
80 Source::Slack => "slack",
81 Source::Telegram => "telegram",
82 Source::Discord => "discord",
83 Source::CommanderEngine => "commander",
84 }
85 }
86
87 pub fn from_prefix(s: &str) -> Option<Self> {
90 match s {
91 "cc" => Some(Source::ClaudeCode),
92 "cursor" => Some(Source::Cursor),
93 "gemini" => Some(Source::Gemini),
94 "aider" => Some(Source::Aider),
95 "slack" => Some(Source::Slack),
96 "telegram" => Some(Source::Telegram),
97 "discord" => Some(Source::Discord),
98 "commander" => Some(Source::CommanderEngine),
99 _ => None,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum Role {
107 User,
108 Assistant,
109 System,
110 Tool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(tag = "t", rename_all = "snake_case")]
118pub enum Content {
119 Text {
120 #[serde(rename = "v")]
121 value: String,
122 },
123 ToolRef {
124 sha256: String,
125 path: String,
126 bytes: u64,
127 #[serde(default)]
128 desc: String,
129 },
130 ImageRef {
131 sha256: String,
132 path: String,
133 #[serde(default)]
134 desc: String,
135 },
136}
137
138impl Content {
139 pub fn text(s: impl Into<String>) -> Self {
141 Content::Text { value: s.into() }
142 }
143
144 pub fn as_text(&self) -> &str {
150 match self {
151 Content::Text { value } => value,
152 Content::ToolRef { desc, .. } | Content::ImageRef { desc, .. } => desc,
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use chrono::TimeZone;
161
162 #[test]
163 fn message_text_roundtrip() {
164 let m = Message {
165 v: 1,
166 ts: chrono::Utc
167 .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
168 .unwrap(),
169 src: Source::ClaudeCode,
170 conv: "3a8786a0".into(),
171 role: Role::User,
172 content: Content::text("hello"),
173 meta: serde_json::json!({"project": "mur"}),
174 refs: vec!["pattern:atomic-yaml-write".into()],
175 };
176 let line = serde_json::to_string(&m).unwrap();
177 let back: Message = serde_json::from_str(&line).unwrap();
178 assert_eq!(back.conv, "3a8786a0");
179 assert!(matches!(back.content, Content::Text { ref value } if value == "hello"));
180 assert!(matches!(back.src, Source::ClaudeCode));
181 assert_eq!(back.refs, vec!["pattern:atomic-yaml-write".to_string()]);
182 }
183
184 #[test]
185 fn message_tool_ref_roundtrip() {
186 let m = Message {
187 v: 1,
188 ts: chrono::Utc
189 .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
190 .unwrap(),
191 src: Source::ClaudeCode,
192 conv: "x".into(),
193 role: Role::Tool,
194 content: Content::ToolRef {
195 sha256: "abc".into(),
196 path: "src/main.rs".into(),
197 bytes: 1234,
198 desc: "read main.rs".into(),
199 },
200 meta: serde_json::Value::Null,
201 refs: vec![],
202 };
203 let line = serde_json::to_string(&m).unwrap();
204 let back: Message = serde_json::from_str(&line).unwrap();
205 assert!(matches!(back.content, Content::ToolRef { ref sha256, .. } if sha256 == "abc"));
206 }
207
208 #[test]
209 fn message_image_ref_roundtrip() {
210 let m = Message {
211 v: 1,
212 ts: chrono::Utc
213 .with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
214 .unwrap(),
215 src: Source::Cursor,
216 conv: "x".into(),
217 role: Role::Tool,
218 content: Content::ImageRef {
219 sha256: "def".into(),
220 path: "attachments/diagram.png".into(),
221 desc: "architecture diagram".into(),
222 },
223 meta: serde_json::Value::Null,
224 refs: vec![],
225 };
226 let line = serde_json::to_string(&m).unwrap();
227 assert!(line.contains("\"t\":\"image_ref\""));
228 let back: Message = serde_json::from_str(&line).unwrap();
229 assert!(matches!(back.content, Content::ImageRef { ref sha256, .. } if sha256 == "def"));
230 }
231
232 #[test]
233 fn source_file_prefix_is_stable() {
234 assert_eq!(Source::ClaudeCode.file_prefix(), "cc");
235 assert_eq!(Source::Cursor.file_prefix(), "cursor");
236 assert_eq!(Source::Gemini.file_prefix(), "gemini");
237 assert_eq!(Source::Aider.file_prefix(), "aider");
238 assert_eq!(Source::Slack.file_prefix(), "slack");
239 assert_eq!(Source::Telegram.file_prefix(), "telegram");
240 assert_eq!(Source::Discord.file_prefix(), "discord");
241 assert_eq!(Source::CommanderEngine.file_prefix(), "commander");
242 }
243
244 #[test]
245 fn message_deserializes_with_meta_and_refs_absent() {
246 let minimal = r#"{"v":1,"ts":"2026-04-19T11:30:45Z","src":"aider","conv":"c","role":"user","content":{"t":"text","v":"hi"}}"#;
249 let m: Message = serde_json::from_str(minimal).unwrap();
250 assert!(m.meta.is_null());
251 assert!(m.refs.is_empty());
252 }
253
254 #[test]
255 fn source_has_ord_and_hash_for_use_in_collections() {
256 use std::collections::{BTreeSet, HashSet};
257 let set_b: BTreeSet<Source> = [Source::ClaudeCode, Source::Cursor, Source::ClaudeCode]
258 .into_iter()
259 .collect();
260 assert_eq!(set_b.len(), 2);
261 let set_h: HashSet<Source> = [Source::Slack, Source::Slack, Source::Telegram]
262 .into_iter()
263 .collect();
264 assert_eq!(set_h.len(), 2);
265 }
266
267 #[test]
268 fn commander_turn_is_subset() {
269 let commander_json = r#"{"v":1,"ts":"2026-04-19T11:30:45Z","src":"slack","conv":"c","role":"user","content":{"t":"text","v":"hi"},"meta":{},"refs":[]}"#;
272 let _m: Message = serde_json::from_str(commander_json).unwrap();
273 }
274
275 #[test]
276 fn source_from_prefix_roundtrips_all_known() {
277 for src in [
278 Source::ClaudeCode,
279 Source::Cursor,
280 Source::Gemini,
281 Source::Aider,
282 Source::Slack,
283 Source::Telegram,
284 Source::Discord,
285 Source::CommanderEngine,
286 ] {
287 let p = src.file_prefix();
288 assert_eq!(Source::from_prefix(p), Some(src));
289 }
290 }
291
292 #[test]
293 fn source_from_prefix_unknown_is_none() {
294 assert_eq!(Source::from_prefix("bogus"), None);
295 assert_eq!(Source::from_prefix(""), None);
296 assert_eq!(Source::from_prefix("CC"), None); }
298}