use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::query::types::Message;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub messages: Vec<Message>,
pub working_dir: String,
pub provider_name: String,
pub model_name: String,
pub title: String,
}
impl Session {
pub fn new(provider_name: &str, model_name: &str) -> Self {
let working_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
Self {
id: uuid::Uuid::new_v4().to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
messages: Vec::new(),
working_dir,
provider_name: provider_name.to_string(),
model_name: model_name.to_string(),
title: String::new(),
}
}
pub fn add_message(&mut self, message: Message) {
if self.title.is_empty() {
if let Some(text) = message.text() {
self.title = text.chars().take(80).collect();
if text.len() > 80 {
self.title.push_str("...");
}
}
}
self.messages.push(message);
self.updated_at = Utc::now();
}
fn session_dir(base_dir: &Path, session_id: &str) -> PathBuf {
base_dir.join(session_id)
}
fn session_file(base_dir: &Path, session_id: &str) -> PathBuf {
Self::session_dir(base_dir, session_id).join("session.json")
}
pub async fn save(&self, base_dir: &Path) -> Result<()> {
let dir = Self::session_dir(base_dir, &self.id);
tokio::fs::create_dir_all(&dir)
.await
.context("Failed to create session directory")?;
let file_path = Self::session_file(base_dir, &self.id);
let temp_path = dir.join("session.json.tmp");
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize session")?;
tokio::fs::write(&temp_path, &json)
.await
.context("Failed to write session temp file")?;
tokio::fs::rename(&temp_path, &file_path)
.await
.context("Failed to rename session file")?;
Ok(())
}
pub async fn load(base_dir: &Path, session_id: &str) -> Result<Self> {
let file_path = Self::session_file(base_dir, session_id);
let json = tokio::fs::read_to_string(&file_path)
.await
.with_context(|| format!("Failed to read session file: {}", file_path.display()))?;
let session: Self = serde_json::from_str(&json)
.context("Failed to deserialize session")?;
Ok(session)
}
pub async fn list_all(base_dir: &Path) -> Result<Vec<SessionSummary>> {
let mut summaries = Vec::new();
if !base_dir.exists() {
return Ok(summaries);
}
let mut entries = tokio::fs::read_dir(base_dir)
.await
.context("Failed to read session directory")?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let session_file = path.join("session.json");
if session_file.exists() {
match tokio::fs::read_to_string(&session_file).await {
Ok(json) => {
if let Ok(session) = serde_json::from_str::<Session>(&json) {
summaries.push(SessionSummary {
id: session.id,
title: session.title,
created_at: session.created_at,
updated_at: session.updated_at,
message_count: session.messages.len(),
provider: session.provider_name,
model: session.model_name,
});
}
}
Err(_) => continue,
}
}
}
}
summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(summaries)
}
}
#[derive(Debug)]
pub struct SessionSummary {
pub id: String,
pub title: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub message_count: usize,
pub provider: String,
pub model: String,
}
impl std::fmt::Display for SessionSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {} ({} messages, {} / {}, updated {})",
&self.id[..8],
if self.title.is_empty() {
"(untitled)"
} else {
&self.title
},
self.message_count,
self.provider,
self.model,
self.updated_at.format("%Y-%m-%d %H:%M")
)
}
}