1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9pub type SessionId = String;
11
12pub type MessageId = String;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum MessageRole {
19 User,
21 Assistant,
23 System,
25 Tool,
27 #[serde(other)]
29 Other,
30}
31
32impl From<&str> for MessageRole {
33 fn from(s: &str) -> Self {
34 match s.to_lowercase().as_str() {
35 "user" | "human" => Self::User,
36 "assistant" | "ai" | "bot" | "model" => Self::Assistant,
37 "system" => Self::System,
38 "tool" | "tool_result" => Self::Tool,
39 _ => Self::Other,
40 }
41 }
42}
43
44impl std::fmt::Display for MessageRole {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::User => write!(f, "user"),
48 Self::Assistant => write!(f, "assistant"),
49 Self::System => write!(f, "system"),
50 Self::Tool => write!(f, "tool"),
51 Self::Other => write!(f, "other"),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum ContentBlock {
60 Text { text: String },
62 ToolUse {
64 id: String,
65 name: String,
66 input: serde_json::Value,
67 },
68 ToolResult {
70 tool_use_id: String,
71 content: String,
72 is_error: bool,
73 },
74 Image { source: String },
76}
77
78impl ContentBlock {
79 pub fn as_text(&self) -> Option<&str> {
81 match self {
82 Self::Text { text } => Some(text),
83 _ => None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Message {
91 pub idx: usize,
93 pub role: MessageRole,
95 pub author: Option<String>,
97 pub content: String,
99 #[serde(default)]
101 pub blocks: Vec<ContentBlock>,
102 pub created_at: Option<jiff::Timestamp>,
104 #[serde(default)]
106 pub extra: serde_json::Value,
107}
108
109impl Message {
110 pub fn text(idx: usize, role: MessageRole, content: impl Into<String>) -> Self {
112 let content = content.into();
113 Self {
114 idx,
115 role,
116 author: None,
117 content: content.clone(),
118 blocks: vec![ContentBlock::Text { text: content }],
119 created_at: None,
120 extra: serde_json::Value::Null,
121 }
122 }
123
124 pub fn has_tool_use(&self) -> bool {
126 self.blocks
127 .iter()
128 .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
129 }
130
131 pub fn tool_names(&self) -> Vec<&str> {
133 self.blocks
134 .iter()
135 .filter_map(|b| match b {
136 ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
137 _ => None,
138 })
139 .collect()
140 }
141}
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145pub struct SessionMetadata {
146 pub project_path: Option<String>,
148 pub model: Option<String>,
150 #[serde(default)]
152 pub tags: Vec<String>,
153 #[serde(flatten)]
155 pub extra: serde_json::Value,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Session {
161 pub id: SessionId,
163 pub source: String,
165 pub external_id: String,
167 pub title: Option<String>,
169 pub source_path: PathBuf,
171 pub started_at: Option<jiff::Timestamp>,
173 pub ended_at: Option<jiff::Timestamp>,
175 pub messages: Vec<Message>,
177 pub metadata: SessionMetadata,
179}
180
181impl Session {
182 pub fn duration_ms(&self) -> Option<i64> {
184 match (self.started_at, self.ended_at) {
185 (Some(start), Some(end)) => {
186 let span = end - start;
187 span.total(jiff::Unit::Millisecond).ok().map(|ms| ms as i64)
188 }
189 _ => None,
190 }
191 }
192
193 pub fn message_count(&self) -> usize {
195 self.messages.len()
196 }
197
198 pub fn user_message_count(&self) -> usize {
200 self.messages
201 .iter()
202 .filter(|m| m.role == MessageRole::User)
203 .count()
204 }
205
206 pub fn assistant_message_count(&self) -> usize {
208 self.messages
209 .iter()
210 .filter(|m| m.role == MessageRole::Assistant)
211 .count()
212 }
213
214 pub fn tools_used(&self) -> Vec<String> {
216 let mut tools: std::collections::HashSet<String> = std::collections::HashSet::new();
217 for msg in &self.messages {
218 for name in msg.tool_names() {
219 tools.insert(name.to_string());
220 }
221 }
222 let mut sorted: Vec<String> = tools.into_iter().collect();
223 sorted.sort();
224 sorted
225 }
226
227 pub fn summary(&self) -> Option<String> {
229 self.messages
230 .iter()
231 .find(|m| m.role == MessageRole::User)
232 .map(|m| {
233 if m.content.len() > 100 {
234 format!("{}...", &m.content[..100])
235 } else {
236 m.content.clone()
237 }
238 })
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_message_role_from_str() {
248 assert_eq!(MessageRole::from("user"), MessageRole::User);
249 assert_eq!(MessageRole::from("User"), MessageRole::User);
250 assert_eq!(MessageRole::from("human"), MessageRole::User);
251 assert_eq!(MessageRole::from("assistant"), MessageRole::Assistant);
252 assert_eq!(MessageRole::from("AI"), MessageRole::Assistant);
253 assert_eq!(MessageRole::from("system"), MessageRole::System);
254 assert_eq!(MessageRole::from("tool"), MessageRole::Tool);
255 assert_eq!(MessageRole::from("unknown"), MessageRole::Other);
256 }
257
258 #[test]
259 fn test_message_text() {
260 let msg = Message::text(0, MessageRole::User, "Hello, world!");
261 assert_eq!(msg.content, "Hello, world!");
262 assert_eq!(msg.role, MessageRole::User);
263 assert!(!msg.has_tool_use());
264 }
265
266 #[test]
267 fn test_session_counts() {
268 let session = Session {
269 id: "test".to_string(),
270 source: "test".to_string(),
271 external_id: "test".to_string(),
272 title: None,
273 source_path: PathBuf::from("."),
274 started_at: None,
275 ended_at: None,
276 messages: vec![
277 Message::text(0, MessageRole::User, "Hello"),
278 Message::text(1, MessageRole::Assistant, "Hi there"),
279 Message::text(2, MessageRole::User, "How are you?"),
280 ],
281 metadata: SessionMetadata::default(),
282 };
283
284 assert_eq!(session.message_count(), 3);
285 assert_eq!(session.user_message_count(), 2);
286 assert_eq!(session.assistant_message_count(), 1);
287 }
288}