use std::io::Write as _;
use std::path::{Component, 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 session_dir_name(session_id: &str) -> Option<&str> {
if session_id.is_empty() || session_id == "." || session_id == ".." {
return None;
}
session_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
.then_some(session_id)
}
fn snapshot_path_in(sessions_dir: &Path, snapshot: &str, ext: &str) -> Option<PathBuf> {
let rel = Path::new(snapshot);
if !rel.components().all(|c| matches!(c, Component::Normal(_))) {
return None;
}
let name = rel.file_name()?.to_str()?;
if !(name.starts_with("session-") && name.ends_with(&format!(".{ext}"))) {
return None;
}
let path = sessions_dir.join(rel);
path.is_file().then_some(path)
}
pub fn resolve_session_snapshot(
sessions_dir: &Path,
session_id: &str,
ext: &str,
) -> Option<PathBuf> {
if !sessions_dir.is_dir() {
return None;
}
if let Some(name) = latest_snapshot_for_session(sessions_dir, session_id)
&& let Some(path) = snapshot_path_in(sessions_dir, &name, ext)
{
return Some(path);
}
let dir = sessions_dir.join(session_dir_name(session_id)?);
newest_session_file(&dir, ext)
}
pub fn snapshots_attributed_to(sessions_dir: &Path, session_id: &str, ext: &str) -> Vec<PathBuf> {
if !sessions_dir.is_dir() {
return Vec::new();
}
let mut out: Vec<PathBuf> = read_log(sessions_dir)
.into_iter()
.filter(|e| e.event == EVENT_PAUSE && e.session_id == session_id)
.filter_map(|e| snapshot_path_in(sessions_dir, &e.snapshot, ext))
.collect();
if let Some(name) = session_dir_name(session_id) {
out.extend(session_files_in(&sessions_dir.join(name), ext));
}
out.sort();
out.dedup();
out
}
fn session_files_in(dir: &Path, ext: &str) -> Vec<PathBuf> {
let suffix = format!(".{ext}");
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
rd.flatten()
.filter(|e| {
let name = e.file_name().into_string().unwrap_or_default();
name.starts_with("session-") && name.ends_with(&suffix)
})
.map(|e| e.path())
.filter(|p| p.is_file())
.collect()
}
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 mut line = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(sessions_dir.join(SESSION_LOG_FILENAME))?;
f.write_all(line.as_bytes())
}
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 append_leaves_other_sessions_bytes_untouched() {
let tmp = TempDir::new().unwrap();
append_entry(tmp.path(), &entry("B", "pause", "B/session-1.md", "t1")).unwrap();
append_entry(tmp.path(), &entry("C", "pause", "C/session-2.md", "t2")).unwrap();
let log = tmp.path().join(SESSION_LOG_FILENAME);
let before = fs::read(&log).unwrap();
let a = entry("A", "pause", "A/session-3.md", "t3");
append_entry(tmp.path(), &a).unwrap();
let after = fs::read(&log).unwrap();
assert_eq!(
&after[..before.len()],
&before[..],
"prior sessions' bytes must be untouched by a later pause"
);
assert_eq!(
String::from_utf8(after[before.len()..].to_vec()).unwrap(),
format!("{}\n", serde_json::to_string(&a).unwrap()),
"a pause appends exactly its own line and nothing else"
);
assert_eq!(read_log(tmp.path()).len(), 3);
}
#[test]
fn concurrent_appends_never_lose_a_line() {
const THREADS: usize = 8;
const PER_THREAD: usize = 128;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
std::thread::scope(|scope| {
for t in 0..THREADS {
let dir = dir.clone();
scope.spawn(move || {
for i in 0..PER_THREAD {
append_entry(
&dir,
&entry(
&format!("session-{t}"),
"pause",
&format!("session-{t}/session-2026090{}-0000{i:02}.md", t % 10),
"2026-09-03T20:03:20.307912+00:00",
),
)
.unwrap();
}
});
}
});
let raw = fs::read_to_string(dir.join(SESSION_LOG_FILENAME)).unwrap();
let bad: Vec<&str> = raw
.lines()
.filter(|l| serde_json::from_str::<SessionLogEntry>(l).is_err())
.collect();
assert!(
bad.is_empty(),
"{} of {} lines were interleaved or truncated; first: {:?}",
bad.len(),
raw.lines().count(),
bad.first()
);
assert_eq!(
read_log(&dir).len(),
THREADS * PER_THREAD,
"every concurrent append must survive"
);
}
#[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 session_dir_name_accepts_uuid() {
let id = "7bd5c27a-475b-41df-9e9f-a6f630801717";
assert_eq!(session_dir_name(id), Some(id));
assert_eq!(session_dir_name("tm_sess.01"), Some("tm_sess.01"));
}
#[test]
fn session_dir_name_rejects_traversal() {
for bad in ["", ".", "..", "../evil", "a/b", "sess:1", "sess id"] {
assert_eq!(session_dir_name(bad), None, "{bad:?} must not name a dir");
}
}
#[test]
fn snapshot_path_in_accepts_flat_and_nested() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("sid")).unwrap();
fs::write(tmp.path().join("session-flat.md"), b"x").unwrap();
fs::write(tmp.path().join("sid").join("session-nested.md"), b"x").unwrap();
assert!(snapshot_path_in(tmp.path(), "session-flat.md", "md").is_some());
assert!(snapshot_path_in(tmp.path(), "sid/session-nested.md", "md").is_some());
assert!(snapshot_path_in(tmp.path(), "session-flat.json", "md").is_none());
assert!(snapshot_path_in(tmp.path(), "notes.md", "md").is_none());
assert!(snapshot_path_in(tmp.path(), "session-gone.md", "md").is_none());
}
#[test]
fn snapshot_path_in_rejects_escapes() {
let tmp = TempDir::new().unwrap();
let store = tmp.path().join("sessions");
fs::create_dir_all(&store).unwrap();
let outside = tmp.path().join("session-outside.md");
fs::write(&outside, b"secret").unwrap();
assert!(snapshot_path_in(&store, "../session-outside.md", "md").is_none());
assert!(snapshot_path_in(&store, "a/../../session-outside.md", "md").is_none());
assert!(snapshot_path_in(&store, outside.to_str().unwrap(), "md").is_none());
}
#[test]
fn resolve_session_snapshot_refuses_another_sessions_snapshot() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-20260809-010155.md"), b"A").unwrap();
append_entry(
tmp.path(),
&entry("session-a", "pause", "session-20260809-010155.md", "t1"),
)
.unwrap();
assert_eq!(
resolve_session_snapshot(tmp.path(), "session-b", "md"),
None
);
assert!(resolve_session_snapshot(tmp.path(), "session-a", "md").is_some());
}
#[test]
fn snapshots_attributed_to_unions_log_and_directory() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-20260809-010155.md"), b"flat").unwrap();
append_entry(
tmp.path(),
&entry("s1", "pause", "session-20260809-010155.md", "t1"),
)
.unwrap();
fs::create_dir_all(tmp.path().join("s1")).unwrap();
fs::write(
tmp.path().join("s1").join("session-20260810-120000.md"),
b"nested",
)
.unwrap();
append_entry(
tmp.path(),
&entry("s1", "pause", "s1/session-20260810-120000.md", "t2"),
)
.unwrap();
let got = snapshots_attributed_to(tmp.path(), "s1", "md");
assert_eq!(got.len(), 2, "{got:?}");
assert!(
got.iter()
.any(|p| p.ends_with("session-20260809-010155.md"))
);
assert!(
got.iter()
.any(|p| p.ends_with("session-20260810-120000.md"))
);
}
#[test]
fn snapshots_attributed_to_excludes_other_sessions() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-20260809-010155.md"), b"A").unwrap();
append_entry(
tmp.path(),
&entry("s1", "pause", "session-20260809-010155.md", "t1"),
)
.unwrap();
fs::write(tmp.path().join("session-20260809-020000.md"), b"orphan").unwrap();
assert_eq!(snapshots_attributed_to(tmp.path(), "s1", "md").len(), 1);
assert!(snapshots_attributed_to(tmp.path(), "s2", "md").is_empty());
}
#[test]
fn resolve_session_snapshot_reads_legacy_flat_via_log() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-20260723-215536.md"), b"legacy").unwrap();
append_entry(
tmp.path(),
&entry("s1", "pause", "session-20260723-215536.md", "t1"),
)
.unwrap();
let got = resolve_session_snapshot(tmp.path(), "s1", "md").unwrap();
assert_eq!(got.file_name().unwrap(), "session-20260723-215536.md");
}
#[test]
fn resolve_session_snapshot_reads_per_session_dir() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("s1")).unwrap();
fs::write(tmp.path().join("s1").join("session-old.md"), b"old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(tmp.path().join("s1").join("session-new.md"), b"new").unwrap();
let got = resolve_session_snapshot(tmp.path(), "s1", "md").unwrap();
assert_eq!(got.file_name().unwrap(), "session-new.md");
assert_eq!(resolve_session_snapshot(tmp.path(), "s2", "md"), None);
}
#[test]
fn resolve_session_snapshot_ignores_unattributed_flat_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("session-orphan.md"), b"whose?").unwrap();
assert_eq!(resolve_session_snapshot(tmp.path(), "s1", "md"), None);
assert_eq!(resolve_session_snapshot(tmp.path(), "s2", "md"), None);
}
#[test]
fn resolve_session_snapshot_skips_a_log_entry_whose_file_is_gone() {
let tmp = TempDir::new().unwrap();
append_entry(tmp.path(), &entry("s1", "pause", "session-gone.md", "t1")).unwrap();
assert_eq!(
resolve_session_snapshot(tmp.path(), "s1", "md"),
None,
"a dangling log entry must not resolve to a phantom path"
);
}
#[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");
}
}