use crate::llm::Message;
use anyhow::{bail, Context as _};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSession {
pub version: u32,
pub name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub context_config: SessionContextConfig,
pub llm_hint: String,
pub messages: Vec<SessionMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
pub role: String,
pub content: String,
pub ts: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionContextConfig {
pub context_days: u64,
pub since: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SessionSummary {
pub name: String,
pub updated_at: DateTime<Utc>,
pub message_count: usize,
}
pub fn validate_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
bail!("session name cannot be empty");
}
if name == ".." || name == "." {
bail!("'{}' is not a valid session name", name);
}
if name.contains('/') || name.contains('\\') {
bail!("session name cannot contain path separators: '{}'", name);
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
{
bail!(
"session name '{}' contains invalid characters (use letters, digits, -, _, .)",
name
);
}
Ok(())
}
impl ChatSession {
pub fn new(name: &str, context_config: SessionContextConfig) -> Self {
let now = Utc::now();
Self {
version: 1,
name: name.to_string(),
created_at: now,
updated_at: now,
context_config,
llm_hint: String::new(),
messages: Vec::new(),
}
}
pub fn load(sessions_dir: &Path, name: &str) -> anyhow::Result<Self> {
validate_name(name)?;
let path = sessions_dir.join(format!("{}.json", name));
let content = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read session file {}", path.display()))?;
let session: ChatSession = serde_json::from_str(&content)
.with_context(|| format!("corrupt session file {}", path.display()))?;
Ok(session)
}
pub fn save(&mut self, sessions_dir: &Path) -> anyhow::Result<()> {
validate_name(&self.name)?;
std::fs::create_dir_all(sessions_dir).context("cannot create sessions directory")?;
self.updated_at = Utc::now();
let path = sessions_dir.join(format!("{}.json", self.name));
let tmp = sessions_dir.join(format!("{}.json.tmp.{}", self.name, std::process::id()));
let json = serde_json::to_string_pretty(self).context("serialize session")?;
std::fs::write(&tmp, &json).context("write session tmp")?;
std::fs::rename(&tmp, &path).context("rename session tmp")?;
Ok(())
}
pub fn list(sessions_dir: &Path) -> anyhow::Result<Vec<SessionSummary>> {
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut summaries = Vec::new();
for entry in std::fs::read_dir(sessions_dir).context("read sessions dir")? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
summaries.push(SessionSummary {
name: session.name.clone(),
updated_at: session.updated_at,
message_count: session.messages.len(),
});
}
}
}
summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
Ok(summaries)
}
pub fn to_messages(&self) -> Vec<Message> {
self.messages
.iter()
.map(|m| Message {
role: m.role.clone(),
content: m.content.clone(),
})
.collect()
}
pub fn push_user(&mut self, content: String) {
self.messages.push(SessionMessage {
role: "user".into(),
content,
ts: Utc::now(),
});
}
pub fn push_assistant(&mut self, content: String) {
self.messages.push(SessionMessage {
role: "assistant".into(),
content,
ts: Utc::now(),
});
}
}