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 if index > 0 && index <= sessions.len() {
256 return sessions.into_iter().nth(index - 1);
257 }
258 }
259
260 sessions
262 .into_iter()
263 .find(|s| s.id == identifier || s.id.starts_with(identifier))
264 }
265
266 pub fn resolve_session(&self, arg: &str) -> Option<SessionInfo> {
268 if arg == "latest" {
269 self.list_sessions().into_iter().next()
270 } else {
271 self.find_session(arg)
272 }
273 }
274
275 pub fn load_conversation(&self, session_info: &SessionInfo) -> io::Result<ConversationRecord> {
277 let content = fs::read_to_string(&session_info.file_path)?;
278 serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
279 }
280
281 fn load_session_info(&self, file_path: &Path) -> Option<SessionInfo> {
283 let content = fs::read_to_string(file_path).ok()?;
284 let record: ConversationRecord = serde_json::from_str(&content).ok()?;
285
286 let display_name = record.summary.clone().unwrap_or_else(|| {
288 record
289 .messages
290 .iter()
291 .find(|m| m.role == MessageRole::User)
292 .map(|m| truncate_message(&m.content, 60))
293 .unwrap_or_else(|| "Empty session".to_string())
294 });
295
296 Some(SessionInfo {
297 id: record.session_id,
298 file_path: file_path.to_path_buf(),
299 start_time: record.start_time,
300 last_updated: record.last_updated,
301 message_count: record.messages.len(),
302 display_name,
303 index: 0, })
305 }
306}
307
308fn get_sessions_dir(project_hash: &str) -> PathBuf {
310 dirs::home_dir()
311 .unwrap_or_else(|| PathBuf::from("."))
312 .join(".syncable")
313 .join("sessions")
314 .join(project_hash)
315}
316
317fn hash_project_path(project_path: &Path) -> String {
319 let canonical = project_path
320 .canonicalize()
321 .unwrap_or_else(|_| project_path.to_path_buf());
322 let mut hasher = DefaultHasher::new();
323 canonical.hash(&mut hasher);
324 format!("{:016x}", hasher.finish())[..8].to_string()
325}
326
327fn truncate_message(msg: &str, max_len: usize) -> String {
329 let clean = msg.lines().next().unwrap_or(msg).trim();
331
332 if clean.len() <= max_len {
333 clean.to_string()
334 } else {
335 format!("{}...", &clean[..max_len.saturating_sub(3)])
336 }
337}
338
339pub fn format_relative_time(time: DateTime<Utc>) -> String {
341 let now = Utc::now();
342 let duration = now.signed_duration_since(time);
343
344 if duration.num_seconds() < 60 {
345 "just now".to_string()
346 } else if duration.num_minutes() < 60 {
347 let mins = duration.num_minutes();
348 format!("{}m ago", mins)
349 } else if duration.num_hours() < 24 {
350 let hours = duration.num_hours();
351 format!("{}h ago", hours)
352 } else if duration.num_days() < 30 {
353 let days = duration.num_days();
354 format!("{}d ago", days)
355 } else {
356 time.format("%Y-%m-%d").to_string()
357 }
358}
359
360pub fn browse_sessions(project_path: &Path) -> Option<SessionInfo> {
362 use colored::Colorize;
363
364 let selector = SessionSelector::new(project_path);
365 let sessions = selector.list_sessions();
366
367 if sessions.is_empty() {
368 println!(
369 "{}",
370 "No previous sessions found for this project.".yellow()
371 );
372 return None;
373 }
374
375 println!();
377 println!(
378 "{}",
379 format!("Recent Sessions ({})", sessions.len())
380 .cyan()
381 .bold()
382 );
383 println!();
384
385 for session in &sessions {
386 let time = format_relative_time(session.last_updated);
387 let msg_count = session.message_count;
388
389 println!(
390 " {} {} {}",
391 format!("[{}]", session.index).cyan(),
392 session.display_name.white(),
393 format!("({})", time).dimmed()
394 );
395 println!(" {} messages", msg_count.to_string().dimmed());
396 }
397
398 println!();
399 print!(
400 "{}",
401 "Enter number to resume, or press Enter to cancel: ".dimmed()
402 );
403 io::stdout().flush().ok()?;
404
405 let mut input = String::new();
407 io::stdin().lock().read_line(&mut input).ok()?;
408 let input = input.trim();
409
410 if input.is_empty() {
411 return None;
412 }
413
414 selector.find_session(input)
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use tempfile::tempdir;
421
422 #[test]
423 fn test_session_recorder() {
424 let temp_dir = tempdir().unwrap();
425 let project_path = temp_dir.path();
426
427 let mut recorder = SessionRecorder::new(project_path);
428 assert!(!recorder.has_messages());
429
430 recorder.record_user_message("Hello, world!");
431 assert!(recorder.has_messages());
432 assert_eq!(recorder.message_count(), 1);
433
434 recorder.record_assistant_message("Hello! How can I help?", None);
435 assert_eq!(recorder.message_count(), 2);
436
437 recorder.save().unwrap();
439 assert!(recorder.file_path.exists());
440 }
441
442 #[test]
443 fn test_project_hash() {
444 let hash1 = hash_project_path(Path::new("/tmp/project1"));
445 let hash2 = hash_project_path(Path::new("/tmp/project2"));
446 let hash3 = hash_project_path(Path::new("/tmp/project1"));
447
448 assert_eq!(hash1.len(), 8);
449 assert_ne!(hash1, hash2);
450 assert_eq!(hash1, hash3);
451 }
452
453 #[test]
454 fn test_truncate_message() {
455 assert_eq!(truncate_message("short", 10), "short");
456 assert_eq!(truncate_message("this is a long message", 10), "this is...");
457 assert_eq!(truncate_message("line1\nline2\nline3", 100), "line1");
458 }
459
460 #[test]
461 fn test_format_relative_time() {
462 let now = Utc::now();
463 assert_eq!(format_relative_time(now), "just now");
464
465 let hour_ago = now - chrono::Duration::hours(1);
466 assert_eq!(format_relative_time(hour_ago), "1h ago");
467
468 let day_ago = now - chrono::Duration::days(1);
469 assert_eq!(format_relative_time(day_ago), "1d ago");
470 }
471}