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