use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use crate::adapter::{Clock, Fs, Rng};
use crate::error::SessionError;
use crate::layout::StorePaths;
use crate::manifest::{HistoryRecord, append_record, read_records};
use crate::store::{get_object, object_hash, put_object_with_hash};
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SessionState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redo: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RecordOutcome {
Unchanged,
Recorded { id: String },
}
fn state_path(paths: &StorePaths, doc_id: &str) -> PathBuf {
paths.session_dir(doc_id).join("state.json")
}
pub(crate) fn journal_path(paths: &StorePaths, doc_id: &str) -> PathBuf {
paths.session_dir(doc_id).join("journal.jsonl")
}
pub(crate) fn load_state(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<SessionState, SessionError> {
let p = state_path(paths, doc_id);
if !fs.exists(&p) {
return Ok(SessionState::default());
}
let bytes = fs.read(&p)?;
serde_json::from_slice(&bytes)
.map_err(|e| SessionError::new(format!("parse session state: {e}")))
}
pub(crate) fn save_state(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
state: &SessionState,
) -> Result<(), SessionError> {
let p = state_path(paths, doc_id);
fs.create_dir_all(&paths.session_dir(doc_id))?;
let bytes = serde_json::to_vec_pretty(state)
.map_err(|e| SessionError::new(format!("serialize session state: {e}")))?;
fs.write(&p, &bytes)
}
pub(crate) fn find_record<'a>(records: &'a [HistoryRecord], id: &str) -> Option<&'a HistoryRecord> {
records.iter().find(|r| r.id == id)
}
pub fn record_state(
fs: &impl Fs,
paths: &StorePaths,
clock: &impl Clock,
_rng: &impl Rng,
doc_id: &str,
content: &[u8],
op_kind: Option<&str>,
) -> Result<RecordOutcome, SessionError> {
let mut state = load_state(fs, paths, doc_id)?;
let jpath = journal_path(paths, doc_id);
let records = read_records(fs, &jpath)?;
let new_hash = object_hash(content);
if let Some(head_id) = &state.head
&& let Some(head_rec) = find_record(&records, head_id)
&& head_rec.snapshot == new_hash
{
return Ok(RecordOutcome::Unchanged);
}
put_object_with_hash(fs, paths, doc_id, content, &new_hash)?;
let seq = u64::try_from(records.len())
.map_err(|_| SessionError::new("session record count exceeds u64"))?;
let id = format!("r{seq}");
let mut rec = HistoryRecord::new(id.clone(), seq, state.head.clone(), new_hash);
rec.op_kind = op_kind.map(str::to_owned);
rec.timestamp_ms = clock
.now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_millis());
append_record(fs, &jpath, &rec)?;
state.head = Some(id.clone());
state.redo.clear();
save_state(fs, paths, doc_id, &state)?;
Ok(RecordOutcome::Recorded { id })
}
pub fn current_content(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Option<Vec<u8>>, SessionError> {
let state = load_state(fs, paths, doc_id)?;
let head_id = match state.head {
Some(h) => h,
None => return Ok(None),
};
let records = read_records(fs, &journal_path(paths, doc_id))?;
let rec = find_record(&records, &head_id).ok_or_else(|| {
SessionError::new(format!("session HEAD points to unknown record: {head_id}"))
})?;
let content = get_object(fs, paths, doc_id, &rec.snapshot)?;
Ok(Some(content))
}
pub fn undo(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Option<Vec<u8>>, SessionError> {
let mut state = load_state(fs, paths, doc_id)?;
let head_id = match state.head.as_deref() {
Some(h) => h,
None => return Ok(None),
};
let records = read_records(fs, &journal_path(paths, doc_id))?;
let rec = find_record(&records, head_id).ok_or_else(|| {
SessionError::new(format!("session HEAD points to unknown record: {head_id}"))
})?;
let parent = match rec.parent.clone() {
Some(p) => p,
None => return Ok(None), };
state.redo.push(head_id.to_owned());
state.head = Some(parent);
save_state(fs, paths, doc_id, &state)?;
current_content(fs, paths, doc_id)
}
pub fn redo(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Option<Vec<u8>>, SessionError> {
let mut state = load_state(fs, paths, doc_id)?;
let target = match state.redo.pop() {
Some(t) => t,
None => return Ok(None),
};
state.head = Some(target);
save_state(fs, paths, doc_id, &state)?;
current_content(fs, paths, doc_id)
}
pub fn clear_session(fs: &impl Fs, paths: &StorePaths, doc_id: &str) -> Result<(), SessionError> {
let dir = paths.session_dir(doc_id);
if fs.exists(&dir) {
fs.remove(&dir)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::{FakeClock, FakeRng, MemFs};
fn setup() -> (MemFs, StorePaths, FakeClock, FakeRng) {
(
MemFs::new(),
StorePaths::new("/data"),
FakeClock(std::time::SystemTime::UNIX_EPOCH),
FakeRng(0),
)
}
#[test]
fn first_record_sets_head() {
let (fs, paths, clock, rng) = setup();
let outcome = record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
assert_eq!(
outcome,
RecordOutcome::Recorded {
id: "r0".to_string()
}
);
let content = current_content(&fs, &paths, "doc1").unwrap();
assert_eq!(content, Some(b"v1".to_vec()));
let state = load_state(&fs, &paths, "doc1").unwrap();
assert_eq!(state.head, Some("r0".to_string()));
assert!(state.redo.is_empty());
}
#[test]
fn dedup_identical_head() {
let (fs, paths, clock, rng) = setup();
let first = record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
assert_eq!(
first,
RecordOutcome::Recorded {
id: "r0".to_string()
}
);
let second = record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
assert_eq!(second, RecordOutcome::Unchanged);
let jpath = journal_path(&paths, "doc1");
let records = read_records(&fs, &jpath).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn second_distinct_record_advances_head_and_chains_parent() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
let outcome = record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap();
assert_eq!(
outcome,
RecordOutcome::Recorded {
id: "r1".to_string()
}
);
let content = current_content(&fs, &paths, "doc1").unwrap();
assert_eq!(content, Some(b"v2".to_vec()));
let jpath = journal_path(&paths, "doc1");
let records = read_records(&fs, &jpath).unwrap();
assert_eq!(records.len(), 2);
let r1 = find_record(&records, "r1").unwrap();
assert_eq!(r1.parent, Some("r0".to_string()));
}
#[test]
fn op_kind_is_stored() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"data", Some("external")).unwrap();
let jpath = journal_path(&paths, "doc1");
let records = read_records(&fs, &jpath).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].op_kind, Some("external".to_string()));
}
#[test]
fn current_content_empty_session() {
let (fs, paths, _clock, _rng) = setup();
let result = current_content(&fs, &paths, "doc1").unwrap();
assert_eq!(result, None);
}
#[test]
fn new_record_clears_redo() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
let mut state = load_state(&fs, &paths, "doc1").unwrap();
state.redo = vec!["rX".to_string()];
save_state(&fs, &paths, "doc1", &state).unwrap();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap();
let reloaded = load_state(&fs, &paths, "doc1").unwrap();
assert!(reloaded.redo.is_empty());
}
#[test]
fn recording_returns_same_object_for_identical_content_across_branches() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"A", None).unwrap();
record_state(&fs, &paths, &clock, &rng, "doc1", b"B", None).unwrap();
let outcome = record_state(&fs, &paths, &clock, &rng, "doc1", b"A", None).unwrap();
assert_eq!(
outcome,
RecordOutcome::Recorded {
id: "r2".to_string()
}
);
let jpath = journal_path(&paths, "doc1");
let records = read_records(&fs, &jpath).unwrap();
assert_eq!(records.len(), 3);
let r0 = find_record(&records, "r0").unwrap();
let r2 = find_record(&records, "r2").unwrap();
assert_eq!(r0.snapshot, r2.snapshot);
assert_ne!(r0.id, r2.id);
}
#[test]
fn undo_moves_to_parent() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); let content = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(content, Some(b"v1".to_vec()));
let state = load_state(&fs, &paths, "doc1").unwrap();
assert_eq!(state.head, Some("r0".to_string()));
assert_eq!(state.redo, vec!["r1".to_string()]);
assert_eq!(
current_content(&fs, &paths, "doc1").unwrap(),
Some(b"v1".to_vec())
);
}
#[test]
fn undo_at_root_returns_none_and_keeps_head() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); let result = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(result, None);
let state = load_state(&fs, &paths, "doc1").unwrap();
assert_eq!(state.head, Some("r0".to_string()));
assert!(state.redo.is_empty());
}
#[test]
fn undo_empty_session_is_none() {
let (fs, paths, _clock, _rng) = setup();
let result = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(result, None);
}
#[test]
fn redo_returns_to_undone_state() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); undo(&fs, &paths, "doc1").unwrap(); let content = redo(&fs, &paths, "doc1").unwrap();
assert_eq!(content, Some(b"v2".to_vec()));
let state = load_state(&fs, &paths, "doc1").unwrap();
assert_eq!(state.head, Some("r1".to_string()));
assert!(state.redo.is_empty());
}
#[test]
fn redo_without_undo_is_none() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); let result = redo(&fs, &paths, "doc1").unwrap();
assert_eq!(result, None);
}
#[test]
fn undo_undo_undo_redo_undo_sequence() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v3", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v4", None).unwrap(); undo(&fs, &paths, "doc1").unwrap(); undo(&fs, &paths, "doc1").unwrap(); let after_third_undo = undo(&fs, &paths, "doc1").unwrap(); assert_eq!(after_third_undo, Some(b"v1".to_vec()));
let after_redo = redo(&fs, &paths, "doc1").unwrap();
assert_eq!(after_redo, Some(b"v2".to_vec()));
let after_final_undo = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(after_final_undo, Some(b"v1".to_vec()));
}
#[test]
fn new_edit_after_undo_clears_redo_and_branches() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); undo(&fs, &paths, "doc1").unwrap(); let outcome = record_state(&fs, &paths, &clock, &rng, "doc1", b"v3", None).unwrap(); assert_eq!(
outcome,
RecordOutcome::Recorded {
id: "r2".to_string()
}
);
assert_eq!(
current_content(&fs, &paths, "doc1").unwrap(),
Some(b"v3".to_vec())
);
let redo_result = redo(&fs, &paths, "doc1").unwrap();
assert_eq!(redo_result, None);
let state = load_state(&fs, &paths, "doc1").unwrap();
assert!(state.redo.is_empty());
}
#[test]
fn round_trip_external_change_is_undoable() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); let outcome =
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", Some("external")).unwrap(); let r2_id = match outcome {
RecordOutcome::Recorded { ref id } => id.clone(),
RecordOutcome::Unchanged => panic!("expected Recorded"),
};
let jpath = journal_path(&paths, "doc1");
let records = read_records(&fs, &jpath).unwrap();
let r0 = find_record(&records, "r0").unwrap();
let r2 = find_record(&records, &r2_id).unwrap();
assert_eq!(r2.op_kind, Some("external".to_string()));
assert_eq!(r2.snapshot, r0.snapshot); assert_eq!(
current_content(&fs, &paths, "doc1").unwrap(),
Some(b"v1".to_vec())
);
let after_first_undo = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(after_first_undo, Some(b"v2".to_vec()));
let after_second_undo = undo(&fs, &paths, "doc1").unwrap();
assert_eq!(after_second_undo, Some(b"v1".to_vec()));
}
#[test]
fn clear_session_removes_all_state() {
let (fs, paths, clock, rng) = setup();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap();
record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap();
clear_session(&fs, &paths, "doc1").unwrap();
let state = load_state(&fs, &paths, "doc1").unwrap();
assert_eq!(state, SessionState::default());
assert_eq!(current_content(&fs, &paths, "doc1").unwrap(), None);
clear_session(&fs, &paths, "doc1").unwrap();
}
}