use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub type SessionId = String;
pub type MessageId = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
#[serde(other)]
Other,
}
impl From<&str> for MessageRole {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"user" | "human" => Self::User,
"assistant" | "ai" | "bot" | "model" => Self::Assistant,
"system" => Self::System,
"tool" | "tool_result" => Self::Tool,
_ => Self::Other,
}
}
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "user"),
Self::Assistant => write!(f, "assistant"),
Self::System => write!(f, "system"),
Self::Tool => write!(f, "tool"),
Self::Other => write!(f, "other"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text { text: String },
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
Image { source: String },
}
impl ContentBlock {
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub idx: usize,
pub role: MessageRole,
pub author: Option<String>,
pub content: String,
#[serde(default)]
pub blocks: Vec<ContentBlock>,
pub created_at: Option<jiff::Timestamp>,
#[serde(default)]
pub extra: serde_json::Value,
}
impl Message {
pub fn text(idx: usize, role: MessageRole, content: impl Into<String>) -> Self {
let content = content.into();
Self {
idx,
role,
author: None,
content: content.clone(),
blocks: vec![ContentBlock::Text { text: content }],
created_at: None,
extra: serde_json::Value::Null,
}
}
pub fn has_tool_use(&self) -> bool {
self.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }))
}
pub fn tool_names(&self) -> Vec<&str> {
self.blocks
.iter()
.filter_map(|b| match b {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
_ => None,
})
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionMetadata {
pub project_path: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub source: String,
pub external_id: String,
pub title: Option<String>,
pub source_path: PathBuf,
pub started_at: Option<jiff::Timestamp>,
pub ended_at: Option<jiff::Timestamp>,
pub messages: Vec<Message>,
pub metadata: SessionMetadata,
}
impl Session {
pub fn duration_ms(&self) -> Option<i64> {
match (self.started_at, self.ended_at) {
(Some(start), Some(end)) => {
let span = end - start;
span.total(jiff::Unit::Millisecond).ok().map(|ms| ms as i64)
}
_ => None,
}
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn user_message_count(&self) -> usize {
self.messages
.iter()
.filter(|m| m.role == MessageRole::User)
.count()
}
pub fn assistant_message_count(&self) -> usize {
self.messages
.iter()
.filter(|m| m.role == MessageRole::Assistant)
.count()
}
pub fn tools_used(&self) -> Vec<String> {
let mut tools: std::collections::HashSet<String> = std::collections::HashSet::new();
for msg in &self.messages {
for name in msg.tool_names() {
tools.insert(name.to_string());
}
}
let mut sorted: Vec<String> = tools.into_iter().collect();
sorted.sort();
sorted
}
pub fn summary(&self) -> Option<String> {
self.messages
.iter()
.find(|m| m.role == MessageRole::User)
.map(|m| {
if m.content.len() > 100 {
format!("{}...", &m.content[..100])
} else {
m.content.clone()
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_role_from_str() {
assert_eq!(MessageRole::from("user"), MessageRole::User);
assert_eq!(MessageRole::from("User"), MessageRole::User);
assert_eq!(MessageRole::from("human"), MessageRole::User);
assert_eq!(MessageRole::from("assistant"), MessageRole::Assistant);
assert_eq!(MessageRole::from("AI"), MessageRole::Assistant);
assert_eq!(MessageRole::from("system"), MessageRole::System);
assert_eq!(MessageRole::from("tool"), MessageRole::Tool);
assert_eq!(MessageRole::from("unknown"), MessageRole::Other);
}
#[test]
fn test_message_text() {
let msg = Message::text(0, MessageRole::User, "Hello, world!");
assert_eq!(msg.content, "Hello, world!");
assert_eq!(msg.role, MessageRole::User);
assert!(!msg.has_tool_use());
}
#[test]
fn test_session_counts() {
let session = Session {
id: "test".to_string(),
source: "test".to_string(),
external_id: "test".to_string(),
title: None,
source_path: PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![
Message::text(0, MessageRole::User, "Hello"),
Message::text(1, MessageRole::Assistant, "Hi there"),
Message::text(2, MessageRole::User, "How are you?"),
],
metadata: SessionMetadata::default(),
};
assert_eq!(session.message_count(), 3);
assert_eq!(session.user_message_count(), 2);
assert_eq!(session.assistant_message_count(), 1);
}
}