1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::hash_map::DefaultHasher;
17use std::fs;
18use std::hash::{Hash, Hasher};
19use std::io::{self, BufRead, Write};
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use super::history::ToolCallRecord;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConversationRecord {
28 pub session_id: String,
30 pub project_hash: String,
32 pub start_time: DateTime<Utc>,
34 pub last_updated: DateTime<Utc>,
36 pub messages: Vec<MessageRecord>,
38 pub summary: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MessageRecord {
45 pub id: String,
47 pub timestamp: DateTime<Utc>,
49 pub role: MessageRole,
51 pub content: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub tool_calls: Option<Vec<SerializableToolCall>>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SerializableToolCall {
61 pub name: String,
62 pub args_summary: String,
63 pub result_summary: String,
64}
65
66impl From<&ToolCallRecord> for SerializableToolCall {
67 fn from(tc: &ToolCallRecord) -> Self {
68 Self {
69 name: tc.tool_name.clone(),
70 args_summary: tc.args_summary.clone(),
71 result_summary: tc.result_summary.clone(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "lowercase")]
79pub enum MessageRole {
80 User,
81 Assistant,
82 System,
83}
84
85#[derive(Debug, Clone)]
87pub struct SessionInfo {
88 pub id: String,
90 pub file_path: PathBuf,
92 pub start_time: DateTime<Utc>,
94 pub last_updated: DateTime<Utc>,
96 pub message_count: usize,
98 pub display_name: String,
100 pub index: usize,
102}
103
104pub struct SessionRecorder {
106 session_id: String,
107 file_path: PathBuf,
108 record: ConversationRecord,
109}
110
111impl SessionRecorder {
112 pub fn new(project_path: &Path) -> Self {
114 let session_id = Uuid::new_v4().to_string();
115 let project_hash = hash_project_path(project_path);
116 let start_time = Utc::now();
117
118 let timestamp = start_time.format("%Y%m%d-%H%M%S").to_string();
120 let uuid_short = &session_id[..8];
121 let filename = format!("session-{}-{}.json", timestamp, uuid_short);
122
123 let sessions_dir = get_sessions_dir(&project_hash);
125 let file_path = sessions_dir.join(filename);
126
127 let record = ConversationRecord {
128 session_id: session_id.clone(),
129 project_hash,
130 start_time,
131 last_updated: start_time,
132 messages: Vec::new(),
133 summary: None,
134 };
135
136 Self {
137 session_id,
138 file_path,
139 record,
140 }
141 }
142
143 pub fn session_id(&self) -> &str {
145 &self.session_id
146 }
147
148 pub fn record_user_message(&mut self, content: &str) {
150 let message = MessageRecord {
151 id: Uuid::new_v4().to_string(),
152 timestamp: Utc::now(),
153 role: MessageRole::User,
154 content: content.to_string(),
155 tool_calls: None,
156 };
157 self.record.messages.push(message);
158 self.record.last_updated = Utc::now();
159 }
160
161 pub fn record_assistant_message(
163 &mut self,
164 content: &str,
165 tool_calls: Option<&[ToolCallRecord]>,
166 ) {
167 let serializable_tools =
168 tool_calls.map(|calls| calls.iter().map(SerializableToolCall::from).collect());
169
170 let message = MessageRecord {
171 id: Uuid::new_v4().to_string(),
172 timestamp: Utc::now(),
173 role: MessageRole::Assistant,
174 content: content.to_string(),
175 tool_calls: serializable_tools,
176 };
177 self.record.messages.push(message);
178 self.record.last_updated = Utc::now();
179 }
180
181 pub fn save(&self) -> io::Result<()> {
183 if let Some(parent) = self.file_path.parent() {
185 fs::create_dir_all(parent)?;
186 }
187
188 let json = serde_json::to_string_pretty(&self.record)?;
190 fs::write(&self.file_path, json)?;
191 Ok(())
192 }
193
194 pub fn has_messages(&self) -> bool {
196 !self.record.messages.is_empty()
197 }
198
199 pub fn message_count(&self) -> usize {
201 self.record.messages.len()
202 }
203}
204
205pub struct SessionSelector {
207 #[allow(dead_code)]
208 project_path: PathBuf,
209 project_hash: String,
210}
211
212impl SessionSelector {
213 pub fn new(project_path: &Path) -> Self {
215 let project_hash = hash_project_path(project_path);
216 Self {
217 project_path: project_path.to_path_buf(),
218 project_hash,
219 }
220 }
221
222 pub fn list_sessions(&self) -> Vec<SessionInfo> {
224 let sessions_dir = get_sessions_dir(&self.project_hash);
225 if !sessions_dir.exists() {
226 return Vec::new();
227 }
228
229 let mut sessions: Vec<SessionInfo> = fs::read_dir(&sessions_dir)
230 .ok()
231 .into_iter()
232 .flatten()
233 .filter_map(|entry| entry.ok())
234 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
235 .filter_map(|entry| self.load_session_info(&entry.path()))
236 .collect();
237
238 sessions.sort_by(|a, b| b.last_updated.cmp(&a.last_updated));
240
241 for (i, session) in sessions.iter_mut().enumerate() {
243 session.index = i + 1;
244 }
245
246 sessions
247 }
248
249 pub fn find_session(&self, identifier: &str) -> Option<SessionInfo> {
251 let sessions = self.list_sessions();
252
253 if let Ok(index) = identifier.parse::<usize>()
255 && index > 0
256 && index <= sessions.len()
257 {
258 return sessions.into_iter().nth(index - 1);
259 }
260
261 sessions
263 .into_iter()
264 .find(|s| s.id == identifier || s.id.starts_with(identifier))
265 }
266
267 pub fn resolve_session(&self, arg: &str) -> Option<SessionInfo> {
269 if arg == "latest" {
270 self.list_sessions().into_iter().next()
271 } else {
272 self.find_session(arg)
273 }
274 }
275
276 pub fn load_conversation(&self, session_info: &SessionInfo) -> io::Result<ConversationRecord> {
278 let content = fs::read_to_string(&session_info.file_path)?;
279 serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
280 }
281
282 fn load_session_info(&self, file_path: &Path) -> Option<SessionInfo> {
284 let content = fs::read_to_string(file_path).ok()?;
285 let record: ConversationRecord = serde_json::from_str(&content).ok()?;
286
287 let display_name = record.summary.clone().unwrap_or_else(|| {
289 record
290 .messages
291 .iter()
292 .find(|m| m.role == MessageRole::User)
293 .map(|m| truncate_message(&m.content, 60))
294 .unwrap_or_else(|| "Empty session".to_string())
295 });
296
297 Some(SessionInfo {
298 id: record.session_id,
299 file_path: file_path.to_path_buf(),
300 start_time: record.start_time,
301 last_updated: record.last_updated,
302 message_count: record.messages.len(),
303 display_name,
304 index: 0, })
306 }
307}
308
309fn get_sessions_dir(project_hash: &str) -> PathBuf {
311 dirs::home_dir()
312 .unwrap_or_else(|| PathBuf::from("."))
313 .join(".syncable")
314 .join("sessions")
315 .join(project_hash)
316}
317
318fn hash_project_path(project_path: &Path) -> String {
320 let canonical = project_path
321 .canonicalize()
322 .unwrap_or_else(|_| project_path.to_path_buf());
323 let mut hasher = DefaultHasher::new();
324 canonical.hash(&mut hasher);
325 format!("{:016x}", hasher.finish())[..8].to_string()
326}
327
328fn truncate_message(msg: &str, max_len: usize) -> String {
330 let clean = msg.lines().next().unwrap_or(msg).trim();
332
333 if clean.len() <= max_len {
334 clean.to_string()
335 } else {
336 format!("{}...", &clean[..max_len.saturating_sub(3)])
337 }
338}
339
340pub fn format_relative_time(time: DateTime<Utc>) -> String {
342 let now = Utc::now();
343 let duration = now.signed_duration_since(time);
344
345 if duration.num_seconds() < 60 {
346 "just now".to_string()
347 } else if duration.num_minutes() < 60 {
348 let mins = duration.num_minutes();
349 format!("{}m ago", mins)
350 } else if duration.num_hours() < 24 {
351 let hours = duration.num_hours();
352 format!("{}h ago", hours)
353 } else if duration.num_days() < 30 {
354 let days = duration.num_days();
355 format!("{}d ago", days)
356 } else {
357 time.format("%Y-%m-%d").to_string()
358 }
359}
360
361pub fn browse_sessions(project_path: &Path) -> Option<SessionInfo> {
363 use colored::Colorize;
364
365 let selector = SessionSelector::new(project_path);
366 let sessions = selector.list_sessions();
367
368 if sessions.is_empty() {
369 println!(
370 "{}",
371 "No previous sessions found for this project.".yellow()
372 );
373 return None;
374 }
375
376 println!();
378 println!(
379 "{}",
380 format!("Recent Sessions ({})", sessions.len())
381 .cyan()
382 .bold()
383 );
384 println!();
385
386 for session in &sessions {
387 let time = format_relative_time(session.last_updated);
388 let msg_count = session.message_count;
389
390 println!(
391 " {} {} {}",
392 format!("[{}]", session.index).cyan(),
393 session.display_name.white(),
394 format!("({})", time).dimmed()
395 );
396 println!(" {} messages", msg_count.to_string().dimmed());
397 }
398
399 println!();
400 print!(
401 "{}",
402 "Enter number to resume, or press Enter to cancel: ".dimmed()
403 );
404 io::stdout().flush().ok()?;
405
406 let mut input = String::new();
408 io::stdin().lock().read_line(&mut input).ok()?;
409 let input = input.trim();
410
411 if input.is_empty() {
412 return None;
413 }
414
415 selector.find_session(input)
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use tempfile::tempdir;
422
423 #[test]
424 fn test_session_recorder() {
425 let temp_dir = tempdir().unwrap();
426 let project_path = temp_dir.path();
427
428 let mut recorder = SessionRecorder::new(project_path);
429 assert!(!recorder.has_messages());
430
431 recorder.record_user_message("Hello, world!");
432 assert!(recorder.has_messages());
433 assert_eq!(recorder.message_count(), 1);
434
435 recorder.record_assistant_message("Hello! How can I help?", None);
436 assert_eq!(recorder.message_count(), 2);
437
438 recorder.save().unwrap();
440 assert!(recorder.file_path.exists());
441 }
442
443 #[test]
444 fn test_project_hash() {
445 let hash1 = hash_project_path(Path::new("/tmp/project1"));
446 let hash2 = hash_project_path(Path::new("/tmp/project2"));
447 let hash3 = hash_project_path(Path::new("/tmp/project1"));
448
449 assert_eq!(hash1.len(), 8);
450 assert_ne!(hash1, hash2);
451 assert_eq!(hash1, hash3);
452 }
453
454 #[test]
455 fn test_truncate_message() {
456 assert_eq!(truncate_message("short", 10), "short");
457 assert_eq!(truncate_message("this is a long message", 10), "this is...");
458 assert_eq!(truncate_message("line1\nline2\nline3", 100), "line1");
459 }
460
461 #[test]
462 fn test_format_relative_time() {
463 let now = Utc::now();
464 assert_eq!(format_relative_time(now), "just now");
465
466 let hour_ago = now - chrono::Duration::hours(1);
467 assert_eq!(format_relative_time(hour_ago), "1h ago");
468
469 let day_ago = now - chrono::Duration::days(1);
470 assert_eq!(format_relative_time(day_ago), "1d ago");
471 }
472}