use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::core::types::{ContentBlock, Message};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionMeta {
pub id: String,
pub provider: String,
pub model: String,
pub cwd: String,
pub created_at: u64,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct SessionSummary {
pub meta: SessionMeta,
pub updated: u64,
pub last_user: String,
pub messages: usize,
}
pub struct SessionStore {
dir: PathBuf,
}
impl SessionStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
SessionStore { dir: dir.into() }
}
pub fn new_id() -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("s{ms}")
}
fn path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.jsonl"))
}
pub fn create(&self, meta: &SessionMeta) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let line = serde_json::to_string(meta)?;
std::fs::write(self.path(&meta.id), format!("{line}\n"))?;
Ok(())
}
pub fn append(&self, id: &str, msg: &Message) -> anyhow::Result<()> {
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(self.path(id))?;
writeln!(f, "{}", serde_json::to_string(msg)?)?;
Ok(())
}
pub fn load(&self, id: &str) -> anyhow::Result<(SessionMeta, Vec<Message>)> {
let text = std::fs::read_to_string(self.path(id))?;
let mut lines = text.lines();
let meta: SessionMeta = match lines.next() {
Some(l) => serde_json::from_str(l)?,
None => anyhow::bail!("empty session file"),
};
let mut messages = Vec::new();
for l in lines {
if l.trim().is_empty() {
continue;
}
messages.push(serde_json::from_str::<Message>(l)?);
}
Ok((meta, messages))
}
pub fn list(&self) -> Vec<SessionMeta> {
let mut metas: Vec<SessionMeta> = Vec::new();
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return metas;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path)
&& let Some(first) = text.lines().next()
&& let Ok(meta) = serde_json::from_str::<SessionMeta>(first)
{
metas.push(meta);
}
}
metas.sort_by_key(|m| std::cmp::Reverse(m.created_at));
metas
}
pub fn latest(&self) -> Option<String> {
self.list().into_iter().next().map(|m| m.id)
}
pub fn list_summaries(&self) -> Vec<SessionSummary> {
let mut out: Vec<SessionSummary> = Vec::new();
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut lines = text.lines();
let Some(meta) = lines
.next()
.and_then(|l| serde_json::from_str::<SessionMeta>(l).ok())
else {
continue;
};
let mut last_user = String::new();
let mut messages = 0usize;
for l in lines {
if l.trim().is_empty() {
continue;
}
if let Ok(m) = serde_json::from_str::<Message>(l) {
messages += 1;
if matches!(m.role, crate::core::types::Role::User) {
let text: String = m
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
if !text.trim().is_empty() {
last_user = text.split_whitespace().collect::<Vec<_>>().join(" ");
}
}
}
}
let updated = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(meta.created_at);
out.push(SessionSummary {
meta,
updated,
last_user,
messages,
});
}
out.sort_by_key(|s| std::cmp::Reverse(s.updated));
out
}
pub fn rename(&self, id: &str, title: &str) -> anyhow::Result<()> {
let (mut meta, msgs) = self.load(id)?;
meta.title = title.to_string();
let mut out = serde_json::to_string(&meta)?;
out.push('\n');
for m in &msgs {
out.push_str(&serde_json::to_string(m)?);
out.push('\n');
}
std::fs::write(self.path(id), out)?;
Ok(())
}
pub fn delete(&self, id: &str) -> anyhow::Result<()> {
std::fs::remove_file(self.path(id))?;
Ok(())
}
pub fn fork(&self, id: &str) -> anyhow::Result<String> {
let (mut meta, msgs) = self.load(id)?;
let new_id = Self::new_id();
meta.id = new_id.clone();
meta.created_at = now_unix();
meta.title = if meta.title.is_empty() {
"fork".into()
} else {
format!("{} (fork)", meta.title)
};
self.create(&meta)?;
for m in &msgs {
self.append(&new_id, m)?;
}
Ok(new_id)
}
}
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::Message;
fn meta(id: &str) -> SessionMeta {
SessionMeta {
id: id.into(),
provider: "openai-chat".into(),
model: "gpt-4o".into(),
cwd: "/proj".into(),
created_at: 1000,
title: "test".into(),
}
}
#[test]
fn create_append_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path());
store.create(&meta("s1")).unwrap();
store.append("s1", &Message::user("hello")).unwrap();
store
.append(
"s1",
&Message::assistant(vec![crate::core::types::ContentBlock::text("hi")]),
)
.unwrap();
let (m, msgs) = store.load("s1").unwrap();
assert_eq!(m.model, "gpt-4o");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0], Message::user("hello"));
}
#[test]
fn rename_fork_delete() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path());
store.create(&meta("s1")).unwrap();
store.append("s1", &Message::user("hello")).unwrap();
store.rename("s1", "renamed").unwrap();
let (m, msgs) = store.load("s1").unwrap();
assert_eq!(m.title, "renamed");
assert_eq!(msgs.len(), 1);
let fork_id = store.fork("s1").unwrap();
assert_ne!(fork_id, "s1");
let (fm, fmsgs) = store.load(&fork_id).unwrap();
assert_eq!(fm.title, "renamed (fork)");
assert_eq!(fmsgs.len(), 1);
store.delete("s1").unwrap();
assert!(store.load("s1").is_err());
assert!(store.load(&fork_id).is_ok()); }
#[test]
fn list_sorts_newest_first() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path());
let mut older = meta("old");
older.created_at = 100;
let mut newer = meta("new");
newer.created_at = 200;
store.create(&older).unwrap();
store.create(&newer).unwrap();
let list = store.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, "new");
assert_eq!(store.latest().as_deref(), Some("new"));
}
}