use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const CONVERSATION_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub v: u32,
pub ts: DateTime<Utc>,
pub src: Source,
pub conv: String,
pub role: Role,
pub content: Content,
#[serde(default)]
pub meta: serde_json::Value,
#[serde(default)]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
ClaudeCode,
Cursor,
Gemini,
Aider,
Slack,
Telegram,
Discord,
CommanderEngine,
}
impl Source {
pub fn file_prefix(&self) -> &'static str {
match self {
Source::ClaudeCode => "cc",
Source::Cursor => "cursor",
Source::Gemini => "gemini",
Source::Aider => "aider",
Source::Slack => "slack",
Source::Telegram => "telegram",
Source::Discord => "discord",
Source::CommanderEngine => "commander",
}
}
pub fn from_prefix(s: &str) -> Option<Self> {
match s {
"cc" => Some(Source::ClaudeCode),
"cursor" => Some(Source::Cursor),
"gemini" => Some(Source::Gemini),
"aider" => Some(Source::Aider),
"slack" => Some(Source::Slack),
"telegram" => Some(Source::Telegram),
"discord" => Some(Source::Discord),
"commander" => Some(Source::CommanderEngine),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Content {
Text {
#[serde(rename = "v")]
value: String,
},
ToolRef {
sha256: String,
path: String,
bytes: u64,
#[serde(default)]
desc: String,
},
ImageRef {
sha256: String,
path: String,
#[serde(default)]
desc: String,
},
}
impl Content {
pub fn text(s: impl Into<String>) -> Self {
Content::Text { value: s.into() }
}
pub fn as_text(&self) -> &str {
match self {
Content::Text { value } => value,
Content::ToolRef { desc, .. } | Content::ImageRef { desc, .. } => desc,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn message_text_roundtrip() {
let m = Message {
v: 1,
ts: chrono::Utc
.with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
.unwrap(),
src: Source::ClaudeCode,
conv: "3a8786a0".into(),
role: Role::User,
content: Content::text("hello"),
meta: serde_json::json!({"project": "mur"}),
refs: vec!["pattern:atomic-yaml-write".into()],
};
let line = serde_json::to_string(&m).unwrap();
let back: Message = serde_json::from_str(&line).unwrap();
assert_eq!(back.conv, "3a8786a0");
assert!(matches!(back.content, Content::Text { ref value } if value == "hello"));
assert!(matches!(back.src, Source::ClaudeCode));
assert_eq!(back.refs, vec!["pattern:atomic-yaml-write".to_string()]);
}
#[test]
fn message_tool_ref_roundtrip() {
let m = Message {
v: 1,
ts: chrono::Utc
.with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
.unwrap(),
src: Source::ClaudeCode,
conv: "x".into(),
role: Role::Tool,
content: Content::ToolRef {
sha256: "abc".into(),
path: "src/main.rs".into(),
bytes: 1234,
desc: "read main.rs".into(),
},
meta: serde_json::Value::Null,
refs: vec![],
};
let line = serde_json::to_string(&m).unwrap();
let back: Message = serde_json::from_str(&line).unwrap();
assert!(matches!(back.content, Content::ToolRef { ref sha256, .. } if sha256 == "abc"));
}
#[test]
fn message_image_ref_roundtrip() {
let m = Message {
v: 1,
ts: chrono::Utc
.with_ymd_and_hms(2026, 4, 19, 11, 30, 45)
.unwrap(),
src: Source::Cursor,
conv: "x".into(),
role: Role::Tool,
content: Content::ImageRef {
sha256: "def".into(),
path: "attachments/diagram.png".into(),
desc: "architecture diagram".into(),
},
meta: serde_json::Value::Null,
refs: vec![],
};
let line = serde_json::to_string(&m).unwrap();
assert!(line.contains("\"t\":\"image_ref\""));
let back: Message = serde_json::from_str(&line).unwrap();
assert!(matches!(back.content, Content::ImageRef { ref sha256, .. } if sha256 == "def"));
}
#[test]
fn source_file_prefix_is_stable() {
assert_eq!(Source::ClaudeCode.file_prefix(), "cc");
assert_eq!(Source::Cursor.file_prefix(), "cursor");
assert_eq!(Source::Gemini.file_prefix(), "gemini");
assert_eq!(Source::Aider.file_prefix(), "aider");
assert_eq!(Source::Slack.file_prefix(), "slack");
assert_eq!(Source::Telegram.file_prefix(), "telegram");
assert_eq!(Source::Discord.file_prefix(), "discord");
assert_eq!(Source::CommanderEngine.file_prefix(), "commander");
}
#[test]
fn message_deserializes_with_meta_and_refs_absent() {
let minimal = r#"{"v":1,"ts":"2026-04-19T11:30:45Z","src":"aider","conv":"c","role":"user","content":{"t":"text","v":"hi"}}"#;
let m: Message = serde_json::from_str(minimal).unwrap();
assert!(m.meta.is_null());
assert!(m.refs.is_empty());
}
#[test]
fn source_has_ord_and_hash_for_use_in_collections() {
use std::collections::{BTreeSet, HashSet};
let set_b: BTreeSet<Source> = [Source::ClaudeCode, Source::Cursor, Source::ClaudeCode]
.into_iter()
.collect();
assert_eq!(set_b.len(), 2);
let set_h: HashSet<Source> = [Source::Slack, Source::Slack, Source::Telegram]
.into_iter()
.collect();
assert_eq!(set_h.len(), 2);
}
#[test]
fn commander_turn_is_subset() {
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":[]}"#;
let _m: Message = serde_json::from_str(commander_json).unwrap();
}
#[test]
fn source_from_prefix_roundtrips_all_known() {
for src in [
Source::ClaudeCode,
Source::Cursor,
Source::Gemini,
Source::Aider,
Source::Slack,
Source::Telegram,
Source::Discord,
Source::CommanderEngine,
] {
let p = src.file_prefix();
assert_eq!(Source::from_prefix(p), Some(src));
}
}
#[test]
fn source_from_prefix_unknown_is_none() {
assert_eq!(Source::from_prefix("bogus"), None);
assert_eq!(Source::from_prefix(""), None);
assert_eq!(Source::from_prefix("CC"), None); }
}