terraphim_sessions/
model.rs

1//! Core data models for session management
2//!
3//! These models provide a unified representation of sessions and messages
4//! from various AI coding assistants.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Unique identifier for a session
10pub type SessionId = String;
11
12/// Unique identifier for a message
13pub type MessageId = String;
14
15/// The role of a message participant
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum MessageRole {
19    /// User/human message
20    User,
21    /// AI assistant message
22    Assistant,
23    /// System message
24    System,
25    /// Tool result message
26    Tool,
27    /// Unknown or other role
28    #[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/// Content block within a message
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum ContentBlock {
60    /// Plain text content
61    Text { text: String },
62    /// Tool use request
63    ToolUse {
64        id: String,
65        name: String,
66        input: serde_json::Value,
67    },
68    /// Tool result
69    ToolResult {
70        tool_use_id: String,
71        content: String,
72        is_error: bool,
73    },
74    /// Image content
75    Image { source: String },
76}
77
78impl ContentBlock {
79    /// Extract text content from block
80    pub fn as_text(&self) -> Option<&str> {
81        match self {
82            Self::Text { text } => Some(text),
83            _ => None,
84        }
85    }
86}
87
88/// A message within a session
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Message {
91    /// Message index within the session
92    pub idx: usize,
93    /// Message role
94    pub role: MessageRole,
95    /// Author identifier (model name, user, etc.)
96    pub author: Option<String>,
97    /// Message content (text representation)
98    pub content: String,
99    /// Structured content blocks (if available)
100    #[serde(default)]
101    pub blocks: Vec<ContentBlock>,
102    /// Creation timestamp
103    pub created_at: Option<jiff::Timestamp>,
104    /// Additional metadata
105    #[serde(default)]
106    pub extra: serde_json::Value,
107}
108
109impl Message {
110    /// Create a new text message
111    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    /// Check if message contains tool usage
125    pub fn has_tool_use(&self) -> bool {
126        self.blocks
127            .iter()
128            .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
129    }
130
131    /// Get tool names used in this message
132    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/// Metadata about a session
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145pub struct SessionMetadata {
146    /// Project path or working directory
147    pub project_path: Option<String>,
148    /// Model used in session
149    pub model: Option<String>,
150    /// Custom tags
151    #[serde(default)]
152    pub tags: Vec<String>,
153    /// Additional fields
154    #[serde(flatten)]
155    pub extra: serde_json::Value,
156}
157
158/// A coding assistant session
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Session {
161    /// Unique session identifier
162    pub id: SessionId,
163    /// Source connector ID (e.g., "claude-code", "cursor")
164    pub source: String,
165    /// External ID from the source system
166    pub external_id: String,
167    /// Session title or description
168    pub title: Option<String>,
169    /// Path to source file/database
170    pub source_path: PathBuf,
171    /// Session start time
172    pub started_at: Option<jiff::Timestamp>,
173    /// Session end time
174    pub ended_at: Option<jiff::Timestamp>,
175    /// Messages in the session
176    pub messages: Vec<Message>,
177    /// Session metadata
178    pub metadata: SessionMetadata,
179}
180
181impl Session {
182    /// Calculate session duration in milliseconds
183    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    /// Get message count
194    pub fn message_count(&self) -> usize {
195        self.messages.len()
196    }
197
198    /// Get user message count
199    pub fn user_message_count(&self) -> usize {
200        self.messages
201            .iter()
202            .filter(|m| m.role == MessageRole::User)
203            .count()
204    }
205
206    /// Get assistant message count
207    pub fn assistant_message_count(&self) -> usize {
208        self.messages
209            .iter()
210            .filter(|m| m.role == MessageRole::Assistant)
211            .count()
212    }
213
214    /// Get all unique tool names used in session
215    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    /// Get first user message as summary
228    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}