use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use super::history::ToolCallRecord;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationRecord {
pub session_id: String,
pub project_hash: String,
pub start_time: DateTime<Utc>,
pub last_updated: DateTime<Utc>,
pub messages: Vec<MessageRecord>,
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub history_snapshot: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageRecord {
pub id: String,
pub timestamp: DateTime<Utc>,
pub role: MessageRole,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<SerializableToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableToolCall {
pub name: String,
pub args_summary: String,
pub result_summary: String,
}
impl From<&ToolCallRecord> for SerializableToolCall {
fn from(tc: &ToolCallRecord) -> Self {
Self {
name: tc.tool_name.clone(),
args_summary: tc.args_summary.clone(),
result_summary: tc.result_summary.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub id: String,
pub file_path: PathBuf,
pub start_time: DateTime<Utc>,
pub last_updated: DateTime<Utc>,
pub message_count: usize,
pub display_name: String,
pub index: usize,
}
pub struct SessionRecorder {
session_id: String,
file_path: PathBuf,
record: ConversationRecord,
}
impl SessionRecorder {
pub fn new(project_path: &Path) -> Self {
let session_id = Uuid::new_v4().to_string();
let project_hash = hash_project_path(project_path);
let start_time = Utc::now();
let timestamp = start_time.format("%Y%m%d-%H%M%S").to_string();
let uuid_short = &session_id[..8];
let filename = format!("session-{}-{}.json", timestamp, uuid_short);
let sessions_dir = get_sessions_dir(&project_hash);
let file_path = sessions_dir.join(filename);
let record = ConversationRecord {
session_id: session_id.clone(),
project_hash,
start_time,
last_updated: start_time,
messages: Vec::new(),
summary: None,
history_snapshot: None,
};
Self {
session_id,
file_path,
record,
}
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn record_user_message(&mut self, content: &str) {
let message = MessageRecord {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
role: MessageRole::User,
content: content.to_string(),
tool_calls: None,
};
self.record.messages.push(message);
self.record.last_updated = Utc::now();
}
pub fn record_assistant_message(
&mut self,
content: &str,
tool_calls: Option<&[ToolCallRecord]>,
) {
let serializable_tools =
tool_calls.map(|calls| calls.iter().map(SerializableToolCall::from).collect());
let message = MessageRecord {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: content.to_string(),
tool_calls: serializable_tools,
};
self.record.messages.push(message);
self.record.last_updated = Utc::now();
}
pub fn save(&self) -> io::Result<()> {
if let Some(parent) = self.file_path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&self.record)?;
fs::write(&self.file_path, json)?;
Ok(())
}
pub fn save_with_history(
&mut self,
history: &super::history::ConversationHistory,
) -> io::Result<()> {
match history.to_json() {
Ok(history_json) => {
self.record.history_snapshot = Some(history_json);
}
Err(e) => {
eprintln!("Warning: Failed to serialize history: {}", e);
}
}
self.save()
}
pub fn has_messages(&self) -> bool {
!self.record.messages.is_empty()
}
pub fn message_count(&self) -> usize {
self.record.messages.len()
}
}
pub struct SessionSelector {
#[allow(dead_code)]
project_path: PathBuf,
project_hash: String,
}
impl SessionSelector {
pub fn new(project_path: &Path) -> Self {
let project_hash = hash_project_path(project_path);
Self {
project_path: project_path.to_path_buf(),
project_hash,
}
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions_dir = get_sessions_dir(&self.project_hash);
if !sessions_dir.exists() {
return Vec::new();
}
let mut sessions: Vec<SessionInfo> = fs::read_dir(&sessions_dir)
.ok()
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|entry| self.load_session_info(&entry.path()))
.collect();
sessions.sort_by(|a, b| b.last_updated.cmp(&a.last_updated));
for (i, session) in sessions.iter_mut().enumerate() {
session.index = i + 1;
}
sessions
}
pub fn find_session(&self, identifier: &str) -> Option<SessionInfo> {
let sessions = self.list_sessions();
if let Ok(index) = identifier.parse::<usize>()
&& index > 0
&& index <= sessions.len()
{
return sessions.into_iter().nth(index - 1);
}
sessions
.into_iter()
.find(|s| s.id == identifier || s.id.starts_with(identifier))
}
pub fn resolve_session(&self, arg: &str) -> Option<SessionInfo> {
if arg == "latest" {
self.list_sessions().into_iter().next()
} else {
self.find_session(arg)
}
}
pub fn load_conversation(&self, session_info: &SessionInfo) -> io::Result<ConversationRecord> {
let content = fs::read_to_string(&session_info.file_path)?;
serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn load_session_info(&self, file_path: &Path) -> Option<SessionInfo> {
let content = fs::read_to_string(file_path).ok()?;
let record: ConversationRecord = serde_json::from_str(&content).ok()?;
let display_name = record.summary.clone().unwrap_or_else(|| {
record
.messages
.iter()
.find(|m| m.role == MessageRole::User)
.map(|m| truncate_message(&m.content, 60))
.unwrap_or_else(|| "Empty session".to_string())
});
Some(SessionInfo {
id: record.session_id,
file_path: file_path.to_path_buf(),
start_time: record.start_time,
last_updated: record.last_updated,
message_count: record.messages.len(),
display_name,
index: 0, })
}
}
fn get_sessions_dir(project_hash: &str) -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".syncable")
.join("sessions")
.join(project_hash)
}
fn hash_project_path(project_path: &Path) -> String {
let canonical = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let mut hasher = DefaultHasher::new();
canonical.hash(&mut hasher);
format!("{:016x}", hasher.finish())[..8].to_string()
}
fn truncate_message(msg: &str, max_len: usize) -> String {
let clean = msg.lines().next().unwrap_or(msg).trim();
if clean.len() <= max_len {
clean.to_string()
} else {
format!("{}...", &clean[..max_len.saturating_sub(3)])
}
}
pub fn format_relative_time(time: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(time);
if duration.num_seconds() < 60 {
"just now".to_string()
} else if duration.num_minutes() < 60 {
let mins = duration.num_minutes();
format!("{}m ago", mins)
} else if duration.num_hours() < 24 {
let hours = duration.num_hours();
format!("{}h ago", hours)
} else if duration.num_days() < 30 {
let days = duration.num_days();
format!("{}d ago", days)
} else {
time.format("%Y-%m-%d").to_string()
}
}
pub fn browse_sessions(project_path: &Path) -> Option<SessionInfo> {
use colored::Colorize;
let selector = SessionSelector::new(project_path);
let sessions = selector.list_sessions();
if sessions.is_empty() {
println!(
"{}",
"No previous sessions found for this project.".yellow()
);
return None;
}
println!();
println!(
"{}",
format!("Recent Sessions ({})", sessions.len())
.cyan()
.bold()
);
println!();
for session in &sessions {
let time = format_relative_time(session.last_updated);
let msg_count = session.message_count;
println!(
" {} {} {}",
format!("[{}]", session.index).cyan(),
session.display_name.white(),
format!("({})", time).dimmed()
);
println!(" {} messages", msg_count.to_string().dimmed());
}
println!();
print!(
"{}",
"Enter number to resume, or press Enter to cancel: ".dimmed()
);
io::stdout().flush().ok()?;
let mut input = String::new();
io::stdin().lock().read_line(&mut input).ok()?;
let input = input.trim();
if input.is_empty() {
return None;
}
selector.find_session(input)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_session_recorder() {
let temp_dir = tempdir().unwrap();
let project_path = temp_dir.path();
let mut recorder = SessionRecorder::new(project_path);
assert!(!recorder.has_messages());
recorder.record_user_message("Hello, world!");
assert!(recorder.has_messages());
assert_eq!(recorder.message_count(), 1);
recorder.record_assistant_message("Hello! How can I help?", None);
assert_eq!(recorder.message_count(), 2);
recorder.save().unwrap();
assert!(recorder.file_path.exists());
}
#[test]
fn test_project_hash() {
let hash1 = hash_project_path(Path::new("/tmp/project1"));
let hash2 = hash_project_path(Path::new("/tmp/project2"));
let hash3 = hash_project_path(Path::new("/tmp/project1"));
assert_eq!(hash1.len(), 8);
assert_ne!(hash1, hash2);
assert_eq!(hash1, hash3);
}
#[test]
fn test_truncate_message() {
assert_eq!(truncate_message("short", 10), "short");
assert_eq!(truncate_message("this is a long message", 10), "this is...");
assert_eq!(truncate_message("line1\nline2\nline3", 100), "line1");
}
#[test]
fn test_format_relative_time() {
let now = Utc::now();
assert_eq!(format_relative_time(now), "just now");
let hour_ago = now - chrono::Duration::hours(1);
assert_eq!(format_relative_time(hour_ago), "1h ago");
let day_ago = now - chrono::Duration::days(1);
assert_eq!(format_relative_time(day_ago), "1d ago");
}
}