use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::wire::ChatMessage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
pub created_at: u64,
pub updated_at: u64,
}
pub(crate) fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub(crate) fn new_session_id() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("ses_{nanos:x}_{n:x}")
}
pub(crate) fn title_from_prompt(prompt: &str) -> String {
let first = prompt.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let title: String = first.chars().take(60).collect();
if title.is_empty() {
"New session".to_owned()
} else {
title
}
}
#[derive(Debug, Clone)]
pub(crate) struct FileStore {
root: PathBuf,
}
impl FileStore {
pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn log_path(&self, id: &str) -> PathBuf {
self.root.join("sessions").join(format!("{id}.jsonl"))
}
fn legacy_record_path(&self, id: &str) -> PathBuf {
self.root.join("sessions").join(format!("{id}.json"))
}
fn legacy_messages_paths(&self, id: &str) -> [PathBuf; 2] {
let dir = self.root.join("messages");
[dir.join(format!("{id}.jsonl")), dir.join(format!("{id}.json"))]
}
pub(crate) fn put_record(&self, record: &SessionRecord) -> Result<(), String> {
let messages = self.load_messages(&record.id)?;
self.write_log(&record.id, record, &messages)
}
pub(crate) fn get_record(&self, id: &str) -> Result<Option<SessionRecord>, String> {
match read_header(&self.log_path(id)) {
Some(record) => Ok(Some(record)),
None => read_json_opt(&self.legacy_record_path(id)),
}
}
pub(crate) fn list_records(&self) -> Result<Vec<SessionRecord>, String> {
let dir = self.root.join("sessions");
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("listing sessions in {}: {e}", dir.display())),
};
let mut out: Vec<SessionRecord> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
match path.extension().and_then(|x| x.to_str()) {
Some("jsonl") => read_header(&path),
Some("json") => read_json_opt::<SessionRecord>(&path).ok().flatten(),
_ => None,
}
})
.collect();
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
Ok(out)
}
pub(crate) fn touch(&self, id: &str, updated_at: u64) -> Result<(), String> {
if let Some(mut record) = self.get_record(id)? {
record.updated_at = updated_at;
self.put_record(&record)?;
}
Ok(())
}
pub(crate) fn load_messages(&self, id: &str) -> Result<Vec<ChatMessage>, String> {
if let Some(text) = read_to_string_opt(&self.log_path(id))? {
return Ok(text.lines().filter_map(|line| parse_line(line).message()).collect());
}
let [jsonl, json] = self.legacy_messages_paths(id);
if let Some(text) = read_to_string_opt(&jsonl)? {
return Ok(text.lines().filter_map(|line| serde_json::from_str(line).ok()).collect());
}
Ok(read_json_opt(&json)?.unwrap_or_default())
}
pub(crate) fn append_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
if messages.is_empty() {
return Ok(());
}
let path = self.log_path(id);
ensure_parent(&path)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("opening {}: {e}", path.display()))?;
std::io::Write::write_all(&mut file, encode_messages(messages)?.as_bytes())
.map_err(|e| format!("appending to {}: {e}", path.display()))
}
pub(crate) fn replace_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
let header = self.get_record(id)?;
match header {
Some(record) => self.write_log(id, &record, messages),
None => {
let path = self.log_path(id);
ensure_parent(&path)?;
write_atomic(&path, &encode_messages(messages)?)
}
}
}
fn write_log(&self, id: &str, record: &SessionRecord, messages: &[ChatMessage]) -> Result<(), String> {
let path = self.log_path(id);
ensure_parent(&path)?;
let header = serde_json::to_string(&LogLine::Session(record.clone()))
.map_err(|e| format!("serializing the header for {}: {e}", path.display()))?;
write_atomic(&path, &format!("{header}\n{}", encode_messages(messages)?))
}
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum LogLine {
Session(SessionRecord),
Message(ChatMessage),
}
impl LogLine {
fn message(self) -> Option<ChatMessage> {
match self {
Self::Message(message) => Some(message),
Self::Session(_) => None,
}
}
}
fn parse_line(line: &str) -> LogLine {
serde_json::from_str(line).unwrap_or(LogLine::Session(SessionRecord {
id: String::new(),
title: None,
model: None,
cwd: None,
parent_id: None,
created_at: 0,
updated_at: 0,
}))
}
fn read_header(path: &Path) -> Option<SessionRecord> {
let file = std::fs::File::open(path).ok()?;
let mut first = String::new();
std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first).ok()?;
match serde_json::from_str(&first).ok()? {
LogLine::Session(record) if !record.id.is_empty() => Some(record),
_ => None,
}
}
fn encode_messages(messages: &[ChatMessage]) -> Result<String, String> {
let mut out = String::new();
for message in messages {
let line = serde_json::to_string(&LogLine::Message(message.clone()))
.map_err(|e| format!("serializing a message: {e}"))?;
out.push_str(&line);
out.push('\n');
}
Ok(out)
}
fn ensure_parent(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("creating {}: {e}", parent.display()))?;
}
Ok(())
}
fn read_to_string_opt(path: &Path) -> Result<Option<String>, String> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("reading {}: {e}", path.display())),
}
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let temp = path.with_extension(format!("{}.tmp", std::process::id()));
std::fs::write(&temp, contents).map_err(|e| format!("writing {}: {e}", temp.display()))?;
std::fs::rename(&temp, path).map_err(|e| {
let _ = std::fs::remove_file(&temp);
format!("replacing {}: {e}", path.display())
})
}
fn read_json_opt<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, String> {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).map(Some).map_err(|e| format!("parsing {}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("reading {}: {e}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("hl-session-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn record_roundtrip_and_list_is_newest_first() {
let dir = scratch("rec");
let store = FileStore::new(&dir);
assert!(store.get_record("nope").unwrap().is_none());
assert!(store.list_records().unwrap().is_empty());
let a = SessionRecord { id: "a".into(), title: Some("first".into()), model: Some("m".into()), cwd: None, parent_id: None, created_at: 100, updated_at: 100 };
let b = SessionRecord { id: "b".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 200, updated_at: 200 };
store.put_record(&a).unwrap();
store.put_record(&b).unwrap();
assert_eq!(store.get_record("a").unwrap().unwrap().title.as_deref(), Some("first"));
let ids: Vec<String> = store.list_records().unwrap().into_iter().map(|r| r.id).collect();
assert_eq!(ids, ["b", "a"], "newest updated_at first");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_header_and_the_transcript_live_in_one_file() {
let dir = scratch("onefile");
let store = FileStore::new(&dir);
let record = SessionRecord {
id: "s1".to_owned(),
title: Some("a chat".to_owned()),
model: Some("m".to_owned()),
cwd: None,
parent_id: None,
created_at: 1,
updated_at: 2,
};
store.put_record(&record).unwrap();
store.append_messages("s1", &[ChatMessage::user("hello")]).unwrap();
assert!(dir.join("sessions").join("s1.jsonl").is_file());
assert!(!dir.join("messages").exists(), "no second file to fall out of step");
let listed = store.list_records().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].title.as_deref(), Some("a chat"));
assert_eq!(store.load_messages("s1").unwrap().len(), 1, "the header is not a message");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_truncated_last_line_costs_only_that_turn() {
let dir = scratch("torn");
let store = FileStore::new(&dir);
store
.append_messages("s1", &[ChatMessage::user("first"), ChatMessage::user("second")])
.unwrap();
let path = dir.join("sessions").join("s1.jsonl");
let mut raw = std::fs::read_to_string(&path).unwrap();
raw.push_str("{\"role\":\"user\",\"cont"); std::fs::write(&path, raw).unwrap();
let loaded = store.load_messages("s1").unwrap();
assert_eq!(loaded.len(), 2, "the intact turns survive a torn tail");
assert_eq!(loaded[0].content.as_deref(), Some("first"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_session_written_by_an_older_build_still_loads() {
let dir = scratch("legacy");
let store = FileStore::new(&dir);
let legacy = dir.join("messages").join("s1.json");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, serde_json::to_string(&vec![ChatMessage::user("from before")]).unwrap())
.unwrap();
let loaded = store.load_messages("s1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].content.as_deref(), Some("from before"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn appending_extends_rather_than_replacing() {
let dir = scratch("append");
let store = FileStore::new(&dir);
store.append_messages("s1", &[ChatMessage::user("one")]).unwrap();
store.append_messages("s1", &[ChatMessage::user("two")]).unwrap();
assert_eq!(store.load_messages("s1").unwrap().len(), 2);
store.replace_messages("s1", &[ChatMessage::user("summary")]).unwrap();
let loaded = store.load_messages("s1").unwrap();
assert_eq!(loaded.len(), 1, "replace truncates, it does not extend");
assert_eq!(loaded[0].content.as_deref(), Some("summary"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn messages_roundtrip_and_touch() {
let dir = scratch("msg");
let store = FileStore::new(&dir);
assert!(store.load_messages("s1").unwrap().is_empty());
let msgs = vec![ChatMessage::user("hi"), ChatMessage::tool_result("c1", "done")];
store.append_messages("s1", &msgs).unwrap();
let loaded = store.load_messages("s1").unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].content.as_deref(), Some("hi"));
store.touch("s1", 999).unwrap();
assert!(store.get_record("s1").unwrap().is_none());
store.put_record(&SessionRecord { id: "s1".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 1, updated_at: 1 }).unwrap();
store.touch("s1", 999).unwrap();
assert_eq!(store.get_record("s1").unwrap().unwrap().updated_at, 999);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreadable_session_reads_as_an_error_and_not_as_an_absent_one() {
let dir = scratch("unreadable");
let store = FileStore::new(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("sessions"), "not a directory").unwrap();
assert!(store.list_records().is_err(), "an unlistable directory is not an empty one");
std::fs::remove_file(dir.join("sessions")).unwrap();
std::fs::create_dir_all(dir.join("sessions").join("s1.jsonl")).unwrap();
assert!(store.load_messages("s1").is_err(), "an unreadable transcript is not an empty one");
std::fs::create_dir_all(dir.join("sessions").join("s2.json")).unwrap();
assert!(store.get_record("s2").is_err(), "an unreadable record is not an absent one");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_record_written_by_an_older_build_still_loads_and_lists() {
let dir = scratch("legacyrec");
let store = FileStore::new(&dir);
let path = dir.join("sessions").join("s1.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let record = SessionRecord {
id: "s1".to_owned(),
title: Some("an older chat".to_owned()),
model: Some("m".to_owned()),
cwd: None,
parent_id: None,
created_at: 5,
updated_at: 5,
};
std::fs::write(&path, serde_json::to_string(&record).unwrap()).unwrap();
assert_eq!(store.get_record("s1").unwrap().unwrap().title.as_deref(), Some("an older chat"));
let listed = store.list_records().unwrap();
assert_eq!(listed.len(), 1, "a legacy record lists alongside current ones");
assert_eq!(listed[0].id, "s1");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_header_without_an_id_is_not_a_session() {
let dir = scratch("noid");
let store = FileStore::new(&dir);
let path = dir.join("sessions").join("s1.jsonl");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{\"type\":\"session\",\"id\":\"\",\"created_at\":0,\"updated_at\":0}\n").unwrap();
assert!(store.get_record("s1").unwrap().is_none());
assert!(store.list_records().unwrap().is_empty(), "an id-less header is skipped, not listed");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_timestamps_come_from_a_real_clock() {
let now = now_millis();
assert!(now > 1_700_000_000_000, "epoch milliseconds, not seconds and not a constant: {now}");
}
#[test]
fn new_session_id_is_unique_and_prefixed() {
let a = new_session_id();
let b = new_session_id();
assert_ne!(a, b);
assert!(a.starts_with("ses_"));
}
#[test]
fn title_is_first_line_capped() {
assert_eq!(title_from_prompt("Fix the parser\nand tests"), "Fix the parser");
assert_eq!(title_from_prompt(" \n hello "), "hello");
assert_eq!(title_from_prompt(""), "New session");
assert_eq!(title_from_prompt(&"x".repeat(100)).chars().count(), 60);
}
}