Skip to main content

opendev_models/
session.rs

1//! Session management models.
2
3use chrono::{DateTime, Utc};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use uuid::Uuid;
8
9use crate::file_change::{FileChange, FileChangeType};
10use crate::message::ChatMessage;
11
12/// Session metadata for listing and searching.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMetadata {
15    pub id: String,
16    #[serde(with = "crate::datetime_compat")]
17    pub created_at: DateTime<Utc>,
18    #[serde(with = "crate::datetime_compat")]
19    pub updated_at: DateTime<Utc>,
20    pub message_count: usize,
21    pub total_tokens: u64,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub title: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub summary: Option<String>,
26    #[serde(default)]
27    pub tags: Vec<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub working_directory: Option<String>,
30    #[serde(default)]
31    pub has_session_model: bool,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub owner_id: Option<String>,
34
35    // Summary stats
36    #[serde(default)]
37    pub summary_additions: u64,
38    #[serde(default)]
39    pub summary_deletions: u64,
40    #[serde(default)]
41    pub summary_files: u64,
42
43    // Multi-channel fields
44    #[serde(default = "default_channel")]
45    pub channel: String,
46    #[serde(default)]
47    pub channel_user_id: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub thread_id: Option<String>,
50}
51
52fn default_channel() -> String {
53    "cli".to_string()
54}
55
56fn generate_session_id() -> String {
57    Uuid::new_v4().to_string()[..12].to_string()
58}
59
60/// Represents a conversation session.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Session {
63    #[serde(default = "generate_session_id")]
64    pub id: String,
65    #[serde(default = "Utc::now", with = "crate::datetime_compat")]
66    pub created_at: DateTime<Utc>,
67    #[serde(default = "Utc::now", with = "crate::datetime_compat")]
68    pub updated_at: DateTime<Utc>,
69    #[serde(default)]
70    pub messages: Vec<ChatMessage>,
71    #[serde(default)]
72    pub context_files: Vec<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub working_directory: Option<String>,
75    #[serde(default)]
76    pub metadata: HashMap<String, serde_json::Value>,
77    /// Serialized ACE Playbook.
78    #[serde(default)]
79    pub playbook: Option<HashMap<String, serde_json::Value>>,
80    /// Track file changes in this session.
81    #[serde(default)]
82    pub file_changes: Vec<FileChange>,
83
84    // Multi-channel fields
85    #[serde(default = "default_channel")]
86    pub channel: String,
87    #[serde(default = "default_chat_type")]
88    pub chat_type: String,
89    #[serde(default)]
90    pub channel_user_id: String,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub thread_id: Option<String>,
93    #[serde(default)]
94    pub delivery_context: HashMap<String, serde_json::Value>,
95    #[serde(
96        default,
97        skip_serializing_if = "Option::is_none",
98        with = "crate::datetime_compat::option"
99    )]
100    pub last_activity: Option<DateTime<Utc>>,
101    #[serde(default)]
102    pub workspace_confirmed: bool,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub owner_id: Option<String>,
105    /// ID of parent session (if forked).
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub parent_id: Option<String>,
108    /// tool_call_id -> child session_id
109    #[serde(default)]
110    pub subagent_sessions: HashMap<String, String>,
111    #[serde(
112        default,
113        skip_serializing_if = "Option::is_none",
114        with = "crate::datetime_compat::option"
115    )]
116    pub time_archived: Option<DateTime<Utc>>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub slug: Option<String>,
119}
120
121fn default_chat_type() -> String {
122    "direct".to_string()
123}
124
125impl Session {
126    /// Create a new session with defaults.
127    pub fn new() -> Self {
128        Self {
129            id: generate_session_id(),
130            created_at: Utc::now(),
131            updated_at: Utc::now(),
132            messages: Vec::new(),
133            context_files: Vec::new(),
134            working_directory: None,
135            metadata: HashMap::new(),
136            playbook: Some(HashMap::new()),
137            file_changes: Vec::new(),
138            channel: "cli".to_string(),
139            chat_type: "direct".to_string(),
140            channel_user_id: String::new(),
141            thread_id: None,
142            delivery_context: HashMap::new(),
143            last_activity: None,
144            workspace_confirmed: false,
145            owner_id: None,
146            parent_id: None,
147            subagent_sessions: HashMap::new(),
148            time_archived: None,
149            slug: None,
150        }
151    }
152
153    /// Total lines added across all file changes.
154    pub fn summary_additions(&self) -> u64 {
155        self.file_changes.iter().map(|fc| fc.lines_added).sum()
156    }
157
158    /// Total lines removed across all file changes.
159    pub fn summary_deletions(&self) -> u64 {
160        self.file_changes.iter().map(|fc| fc.lines_removed).sum()
161    }
162
163    /// Number of unique files changed.
164    pub fn summary_files(&self) -> usize {
165        let unique: std::collections::HashSet<&str> = self
166            .file_changes
167            .iter()
168            .map(|fc| fc.file_path.as_str())
169            .collect();
170        unique.len()
171    }
172
173    /// Soft-archive this session.
174    pub fn archive(&mut self) {
175        self.time_archived = Some(Utc::now());
176        self.updated_at = Utc::now();
177    }
178
179    /// Restore an archived session.
180    pub fn unarchive(&mut self) {
181        self.time_archived = None;
182        self.updated_at = Utc::now();
183    }
184
185    /// Check if session is archived.
186    pub fn is_archived(&self) -> bool {
187        self.time_archived.is_some()
188    }
189
190    /// Generate URL-friendly slug from title.
191    pub fn generate_slug(&self, title: Option<&str>) -> String {
192        let text = title
193            .or_else(|| self.metadata.get("title").and_then(|v| v.as_str()))
194            .unwrap_or("");
195
196        if text.is_empty() {
197            return self.id[..self.id.len().min(8)].to_string();
198        }
199
200        let re = Regex::new(r"[^a-z0-9]+").unwrap();
201        let lowered = text.to_lowercase();
202        let slug = re.replace_all(&lowered, "-");
203        let slug = slug.trim_matches('-');
204        let slug = if slug.len() > 50 {
205            slug[..50].trim_end_matches('-')
206        } else {
207            slug
208        };
209
210        if slug.is_empty() {
211            self.id[..self.id.len().min(8)].to_string()
212        } else {
213            slug.to_string()
214        }
215    }
216
217    /// Add a file change to the session.
218    pub fn add_file_change(&mut self, file_change: FileChange) {
219        // Check if this is a modification of an existing file
220        for existing in &mut self.file_changes {
221            if existing.file_path == file_change.file_path
222                && existing.change_type == FileChangeType::Modified
223                && file_change.change_type == FileChangeType::Modified
224            {
225                existing.lines_added += file_change.lines_added;
226                existing.lines_removed += file_change.lines_removed;
227                existing.timestamp = file_change.timestamp;
228                existing.description = file_change.description.clone();
229                return;
230            }
231        }
232
233        // Remove any previous change for the same file (for non-modifications)
234        self.file_changes
235            .retain(|fc| fc.file_path != file_change.file_path);
236
237        let mut fc = file_change;
238        fc.session_id = Some(self.id.clone());
239        self.file_changes.push(fc);
240        self.updated_at = Utc::now();
241    }
242
243    /// Get a summary of file changes in this session.
244    pub fn get_file_changes_summary(&self) -> FileChangesSummary {
245        let created = self
246            .file_changes
247            .iter()
248            .filter(|fc| fc.change_type == FileChangeType::Created)
249            .count();
250        let modified = self
251            .file_changes
252            .iter()
253            .filter(|fc| fc.change_type == FileChangeType::Modified)
254            .count();
255        let deleted = self
256            .file_changes
257            .iter()
258            .filter(|fc| fc.change_type == FileChangeType::Deleted)
259            .count();
260        let renamed = self
261            .file_changes
262            .iter()
263            .filter(|fc| fc.change_type == FileChangeType::Renamed)
264            .count();
265        let total_lines_added: u64 = self.file_changes.iter().map(|fc| fc.lines_added).sum();
266        let total_lines_removed: u64 = self.file_changes.iter().map(|fc| fc.lines_removed).sum();
267
268        FileChangesSummary {
269            total: self.file_changes.len(),
270            created,
271            modified,
272            deleted,
273            renamed,
274            total_lines_added,
275            total_lines_removed,
276            net_lines: total_lines_added as i64 - total_lines_removed as i64,
277        }
278    }
279
280    /// Calculate total token count.
281    pub fn total_tokens(&self) -> u64 {
282        self.messages.iter().map(|msg| msg.token_estimate()).sum()
283    }
284
285    /// Get session metadata.
286    pub fn get_metadata(&self) -> SessionMetadata {
287        SessionMetadata {
288            id: self.id.clone(),
289            created_at: self.created_at,
290            updated_at: self.updated_at,
291            message_count: self.messages.len(),
292            total_tokens: self.total_tokens(),
293            title: self
294                .metadata
295                .get("title")
296                .and_then(|v| v.as_str())
297                .map(String::from),
298            summary: self
299                .metadata
300                .get("summary")
301                .and_then(|v| v.as_str())
302                .map(String::from),
303            tags: self
304                .metadata
305                .get("tags")
306                .and_then(|v| serde_json::from_value(v.clone()).ok())
307                .unwrap_or_default(),
308            working_directory: self.working_directory.clone(),
309            has_session_model: false,
310            owner_id: self.owner_id.clone(),
311            summary_additions: self.summary_additions(),
312            summary_deletions: self.summary_deletions(),
313            summary_files: self.summary_files() as u64,
314            channel: self.channel.clone(),
315            channel_user_id: self.channel_user_id.clone(),
316            thread_id: self.thread_id.clone(),
317        }
318    }
319}
320
321impl Default for Session {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327/// Summary of file changes in a session.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct FileChangesSummary {
330    pub total: usize,
331    pub created: usize,
332    pub modified: usize,
333    pub deleted: usize,
334    pub renamed: usize,
335    pub total_lines_added: u64,
336    pub total_lines_removed: u64,
337    pub net_lines: i64,
338}
339
340#[cfg(test)]
341#[path = "session_tests.rs"]
342mod tests;