use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SessionIndexEventKind {
Start,
End,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionIndexRecord {
pub schema_version: u8,
pub session_id: String,
pub event: SessionIndexEventKind,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turns: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl SessionIndexRecord {
pub fn start(session_id: impl Into<String>) -> Self {
Self::new(session_id, SessionIndexEventKind::Start)
}
pub fn end(session_id: impl Into<String>) -> Self {
Self::new(session_id, SessionIndexEventKind::End)
}
fn new(session_id: impl Into<String>, event: SessionIndexEventKind) -> Self {
Self {
schema_version: 1,
session_id: session_id.into(),
event,
timestamp: Utc::now(),
cwd: None,
profile: None,
model: None,
duration_ms: None,
turns: None,
tags: Vec::new(),
note: None,
}
}
}
pub fn index_path() -> PathBuf {
crate::core::config::base_dir().join("sessions").join("index.jsonl")
}
pub fn append_record(record: &SessionIndexRecord) -> crate::Result<()> {
append_record_to_path(&index_path(), record)
}
pub fn read_recent(limit: usize) -> crate::Result<Vec<SessionIndexRecord>> {
read_recent_from_path(&index_path(), limit)
}
fn append_record_to_path(path: &std::path::Path, record: &SessionIndexRecord) -> crate::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| crate::core::error::RuntimeError::Session(format!("create session index directory: {err}")))?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|err| crate::core::error::RuntimeError::Session(format!("open session index: {err}")))?;
let mut line = serde_json::to_string(record)
.map_err(|err| crate::core::error::RuntimeError::Session(format!("serialize session index record: {err}")))?;
line.push('\n');
use std::io::Write;
file.write_all(line.as_bytes())
.map_err(|err| crate::core::error::RuntimeError::Session(format!("write session index record: {err}")))?;
Ok(())
}
fn read_recent_from_path(path: &std::path::Path, limit: usize) -> crate::Result<Vec<SessionIndexRecord>> {
if limit == 0 || !path.exists() {
return Ok(Vec::new());
}
let contents = std::fs::read_to_string(path)
.map_err(|err| crate::core::error::RuntimeError::Session(format!("read session index: {err}")))?;
let mut records = Vec::new();
for line in contents.lines().rev().take(limit) {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SessionIndexRecord>(line) {
Ok(record) => records.push(record),
Err(err) => {
tracing::warn!("skipping malformed session index line: {err}");
continue;
}
}
}
records.reverse();
Ok(records)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use serde_json::Value;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
old_base_dir: Option<String>,
}
impl EnvGuard {
fn set_base_dir(path: &std::path::Path) -> Self {
let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
std::env::set_var("SYNAPS_BASE_DIR", path);
Self { old_base_dir }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = self.old_base_dir.take() {
std::env::set_var("SYNAPS_BASE_DIR", value);
} else {
std::env::remove_var("SYNAPS_BASE_DIR");
}
}
}
fn temp_base_dir(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"synaps-session-index-{test_name}-{}",
uuid::Uuid::new_v4()
))
}
#[test]
#[serial]
fn append_record_creates_jsonl_under_base_dir() {
let _lock = ENV_LOCK.lock().unwrap();
let base = temp_base_dir("creates-jsonl");
let _guard = EnvGuard::set_base_dir(&base);
let record = SessionIndexRecord::start("sess-1");
append_record(&record).unwrap();
let path = base.join("sessions").join("index.jsonl");
assert!(path.exists());
let contents = std::fs::read_to_string(path).unwrap();
let line: Value = serde_json::from_str(contents.trim()).unwrap();
assert_eq!(line["schema_version"], 1);
assert_eq!(line["session_id"], "sess-1");
assert_eq!(line["event"], "start");
assert!(line.get("timestamp").is_some());
assert!(line.get("cwd").is_none());
}
#[test]
#[serial]
fn append_start_and_end_are_valid_json_lines() {
let _lock = ENV_LOCK.lock().unwrap();
let base = temp_base_dir("start-end-lines");
let _guard = EnvGuard::set_base_dir(&base);
append_record(&SessionIndexRecord::start("sess-1")).unwrap();
append_record(&SessionIndexRecord::end("sess-1")).unwrap();
let contents = std::fs::read_to_string(index_path()).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(serde_json::from_str::<Value>(lines[0]).unwrap()["event"], "start");
assert_eq!(serde_json::from_str::<Value>(lines[1]).unwrap()["event"], "end");
}
#[test]
#[serial]
fn read_recent_returns_newest_records_in_chronological_order() {
let _lock = ENV_LOCK.lock().unwrap();
let base = temp_base_dir("read-recent");
let _guard = EnvGuard::set_base_dir(&base);
append_record(&SessionIndexRecord::start("sess-1")).unwrap();
append_record(&SessionIndexRecord::start("sess-2")).unwrap();
append_record(&SessionIndexRecord::end("sess-2")).unwrap();
let records = read_recent(2).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].session_id, "sess-2");
assert_eq!(records[0].event, SessionIndexEventKind::Start);
assert_eq!(records[1].session_id, "sess-2");
assert_eq!(records[1].event, SessionIndexEventKind::End);
}
#[test]
#[serial]
fn read_recent_missing_index_returns_empty() {
let _lock = ENV_LOCK.lock().unwrap();
let base = temp_base_dir("missing-index");
let _guard = EnvGuard::set_base_dir(&base);
assert!(read_recent(10).unwrap().is_empty());
}
#[test]
#[serial]
fn read_recent_limit_zero_returns_empty() {
let _lock = ENV_LOCK.lock().unwrap();
let base = temp_base_dir("limit-zero");
let _guard = EnvGuard::set_base_dir(&base);
append_record(&SessionIndexRecord::start("sess-1")).unwrap();
assert!(read_recent(0).unwrap().is_empty());
}
}