use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const SESSION_LOG_FILENAME: &str = "sessions-log.jsonl";
pub const LEGACY_POINTER_FILENAME: &str = "LATEST-SESSION.txt";
pub const EVENT_PAUSE: &str = "pause";
pub const EVENT_RESUME: &str = "resume";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionLogEntry {
pub session_id: String,
pub event: String,
pub snapshot: String,
pub timestamp: String,
}
pub fn read_log(sessions_dir: &Path) -> Vec<SessionLogEntry> {
let log_path = sessions_dir.join(SESSION_LOG_FILENAME);
let Ok(content) = std::fs::read_to_string(&log_path) else {
return Vec::new();
};
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<SessionLogEntry>(l).ok())
.collect()
}
pub fn latest_snapshot_for_session(sessions_dir: &Path, session_id: &str) -> Option<String> {
read_log(sessions_dir)
.into_iter()
.rev()
.find(|e| e.event == EVENT_PAUSE && e.session_id == session_id)
.map(|e| e.snapshot)
}
pub fn latest_snapshot_overall(sessions_dir: &Path) -> Option<String> {
read_log(sessions_dir)
.into_iter()
.rev()
.find(|e| e.event == EVENT_PAUSE)
.map(|e| e.snapshot)
}
pub fn resolve_latest_snapshot(sessions_dir: &Path, ext: &str) -> Option<PathBuf> {
if !sessions_dir.is_dir() {
return None;
}
if let Some(name) = latest_snapshot_overall(sessions_dir) {
let path = sessions_dir.join(&name);
if path.exists() {
return Some(path);
}
}
if let Some(name) = read_legacy_pointer(sessions_dir, ext) {
let path = sessions_dir.join(&name);
if path.exists() {
return Some(path);
}
}
newest_session_file(sessions_dir, ext)
}
pub fn append_entry(sessions_dir: &Path, entry: &SessionLogEntry) -> std::io::Result<()> {
std::fs::create_dir_all(sessions_dir)?;
let line = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(sessions_dir.join(SESSION_LOG_FILENAME))?;
writeln!(f, "{line}")
}
fn read_legacy_pointer(sessions_dir: &Path, ext: &str) -> Option<String> {
let content = std::fs::read_to_string(sessions_dir.join(LEGACY_POINTER_FILENAME)).ok()?;
let suffix = format!(".{ext}");
content
.lines()
.map(str::trim)
.find(|l| l.ends_with(&suffix))
.map(str::to_owned)
}
fn newest_session_file(sessions_dir: &Path, ext: &str) -> Option<PathBuf> {
let suffix = format!(".{ext}");
std::fs::read_dir(sessions_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().into_string().unwrap_or_default();
name.starts_with("session-") && name.ends_with(&suffix)
})
.max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok())
.map(|e| e.path())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn entry(session_id: &str, event: &str, snapshot: &str, ts: &str) -> SessionLogEntry {
SessionLogEntry {
session_id: session_id.to_string(),
event: event.to_string(),
snapshot: snapshot.to_string(),
timestamp: ts.to_string(),
}
}
#[test]
fn read_log_absent_is_empty() {
let tmp = TempDir::new().unwrap();
assert!(read_log(tmp.path()).is_empty());
}
#[test]
fn read_log_parses_valid_lines() {
let tmp = TempDir::new().unwrap();
let log = tmp.path().join(SESSION_LOG_FILENAME);
fs::write(
&log,
"{\"session_id\":\"a\",\"event\":\"pause\",\"snapshot\":\"session-1.md\",\"timestamp\":\"2026-07-15T10:00:00Z\"}\n\
{\"session_id\":\"a\",\"event\":\"resume\",\"snapshot\":\"session-1.md\",\"timestamp\":\"2026-07-15T11:00:00Z\"}\n",
)
.unwrap();
let entries = read_log(tmp.path());
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].event, "pause");
assert_eq!(entries[1].event, "resume");
}
#[test]
fn read_log_skips_malformed() {
let tmp = TempDir::new().unwrap();
let log = tmp.path().join(SESSION_LOG_FILENAME);
fs::write(
&log,
"{\"session_id\":\"a\",\"event\":\"pause\",\"snapshot\":\"session-1.md\",\"timestamp\":\"t\"}\n\
{\"session_id\":\n\
\n",
)
.unwrap();
let entries = read_log(tmp.path());
assert_eq!(entries.len(), 1, "malformed + blank lines skipped");
assert_eq!(entries[0].snapshot, "session-1.md");
}
#[test]
fn latest_snapshot_for_session_picks_own() {
let tmp = TempDir::new().unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-A.md", "t1")).unwrap();
append_entry(tmp.path(), &entry("s2", "pause", "session-B.md", "t2")).unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-C.md", "t3")).unwrap();
assert_eq!(
latest_snapshot_for_session(tmp.path(), "s1").as_deref(),
Some("session-C.md"),
"s1 gets its own newest pause, not s2's"
);
assert_eq!(
latest_snapshot_for_session(tmp.path(), "s2").as_deref(),
Some("session-B.md")
);
assert!(latest_snapshot_for_session(tmp.path(), "missing").is_none());
}
#[test]
fn latest_snapshot_overall_picks_last_pause() {
let tmp = TempDir::new().unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-A.md", "t1")).unwrap();
append_entry(tmp.path(), &entry("s2", "pause", "session-B.md", "t2")).unwrap();
append_entry(tmp.path(), &entry("s1", "resume", "session-A.md", "t3")).unwrap();
assert_eq!(
latest_snapshot_overall(tmp.path()).as_deref(),
Some("session-B.md")
);
}
#[test]
fn append_then_read_roundtrips() {
let tmp = TempDir::new().unwrap();
let e = entry("s1", "pause", "session-1.md", "2026-07-15T10:00:00Z");
append_entry(tmp.path(), &e).unwrap();
let entries = read_log(tmp.path());
assert_eq!(entries, vec![e]);
}
#[test]
fn append_is_additive() {
let tmp = TempDir::new().unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-1.md", "t1")).unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-2.md", "t2")).unwrap();
assert_eq!(
read_log(tmp.path()).len(),
2,
"second append does not truncate"
);
}
#[test]
fn resolve_prefers_log() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-log.md"), b"snap").unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-log.md", "t1")).unwrap();
fs::write(tmp.path().join(LEGACY_POINTER_FILENAME), "session-ptr.md").unwrap();
fs::write(tmp.path().join("session-ptr.md"), b"decoy").unwrap();
let got = resolve_latest_snapshot(tmp.path(), "md").unwrap();
assert_eq!(got.file_name().unwrap(), "session-log.md");
}
#[test]
fn resolve_falls_back_to_pointer() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-ptr.md"), b"snap").unwrap();
fs::write(
tmp.path().join(LEGACY_POINTER_FILENAME),
"Resume with this file:\nsession-ptr.md\n",
)
.unwrap();
let got = resolve_latest_snapshot(tmp.path(), "md").unwrap();
assert_eq!(got.file_name().unwrap(), "session-ptr.md");
}
#[test]
fn resolve_falls_back_to_mtime() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-old.md"), b"old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(tmp.path().join("session-new.md"), b"new").unwrap();
let got = resolve_latest_snapshot(tmp.path(), "md").unwrap();
assert_eq!(got.file_name().unwrap(), "session-new.md");
}
#[test]
fn resolve_none_when_empty() {
let tmp = TempDir::new().unwrap();
assert!(resolve_latest_snapshot(tmp.path(), "md").is_none());
append_entry(tmp.path(), &entry("s1", "pause", "gone.md", "t1")).unwrap();
assert!(resolve_latest_snapshot(tmp.path(), "md").is_none());
}
#[test]
fn resolve_supports_json_ext() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-1.json"), b"{}").unwrap();
fs::write(tmp.path().join(LEGACY_POINTER_FILENAME), "session-1.json").unwrap();
let got = resolve_latest_snapshot(tmp.path(), "json").unwrap();
assert_eq!(got.file_name().unwrap(), "session-1.json");
}
}