1use 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#[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 #[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 #[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#[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 #[serde(default)]
79 pub playbook: Option<HashMap<String, serde_json::Value>>,
80 #[serde(default)]
82 pub file_changes: Vec<FileChange>,
83
84 #[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 #[serde(skip_serializing_if = "Option::is_none")]
107 pub parent_id: Option<String>,
108 #[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 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 pub fn summary_additions(&self) -> u64 {
155 self.file_changes.iter().map(|fc| fc.lines_added).sum()
156 }
157
158 pub fn summary_deletions(&self) -> u64 {
160 self.file_changes.iter().map(|fc| fc.lines_removed).sum()
161 }
162
163 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 pub fn archive(&mut self) {
175 self.time_archived = Some(Utc::now());
176 self.updated_at = Utc::now();
177 }
178
179 pub fn unarchive(&mut self) {
181 self.time_archived = None;
182 self.updated_at = Utc::now();
183 }
184
185 pub fn is_archived(&self) -> bool {
187 self.time_archived.is_some()
188 }
189
190 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 pub fn add_file_change(&mut self, file_change: FileChange) {
219 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 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 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 pub fn total_tokens(&self) -> u64 {
282 self.messages.iter().map(|msg| msg.token_estimate()).sum()
283 }
284
285 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#[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;