use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::adapter::Fs;
use crate::error::SessionError;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CheckpointMeta {
pub action_id: Option<String>,
pub action_version: Option<String>,
pub preview_hash: Option<String>,
pub replay_eligible: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryRecord {
pub id: String,
pub seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub snapshot: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub op_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub affected: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u128>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_hash: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub replay_eligible: bool,
}
impl HistoryRecord {
pub fn new(
id: impl Into<String>,
seq: u64,
parent: Option<String>,
snapshot: impl Into<String>,
) -> Self {
Self {
id: id.into(),
seq,
parent,
snapshot: snapshot.into(),
op_kind: None,
label: None,
affected: Vec::new(),
timestamp_ms: None,
author: None,
action_id: None,
action_version: None,
preview_hash: None,
replay_eligible: false,
}
}
}
fn is_false(b: &bool) -> bool {
!*b
}
pub(crate) fn append_jsonl_record<T: serde::Serialize>(
fs: &impl Fs,
path: &Path,
record: &T,
) -> Result<(), SessionError> {
if let Some(parent) = path.parent() {
fs.create_dir_all(parent)?;
}
let mut line = serde_json::to_vec(record)
.map_err(|e| SessionError::new(format!("serialize record: {e}")))?;
line.push(b'\n');
fs.append(path, &line)
}
pub(crate) fn read_jsonl_records<T: serde::de::DeserializeOwned>(
fs: &impl Fs,
path: &Path,
) -> Result<Vec<T>, SessionError> {
if !fs.exists(path) {
return Ok(Vec::new());
}
let bytes = fs.read(path)?;
let text = std::str::from_utf8(&bytes)
.map_err(|e| SessionError::new(format!("manifest is not utf-8: {e}")))?;
let mut out = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let rec = serde_json::from_str(line)
.map_err(|e| SessionError::new(format!("parse record: {e}")))?;
out.push(rec);
}
Ok(out)
}
pub fn append_record(
fs: &impl Fs,
path: &Path,
record: &HistoryRecord,
) -> Result<(), SessionError> {
append_jsonl_record(fs, path, record)
}
pub fn read_records(fs: &impl Fs, path: &Path) -> Result<Vec<HistoryRecord>, SessionError> {
read_jsonl_records::<HistoryRecord>(fs, path)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::adapter::{Fs, MemFs};
fn manifest_path() -> PathBuf {
PathBuf::from("/data/m.jsonl")
}
fn make_fs() -> MemFs {
MemFs::new()
}
#[test]
fn append_then_read_one() {
let fs = make_fs();
let path = manifest_path();
let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
append_record(&fs, &path, &rec).unwrap();
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0], rec);
}
#[test]
fn append_multiple_preserves_order() {
let fs = make_fs();
let path = manifest_path();
let r0 = HistoryRecord::new("r0", 0, None, "aaa");
let r1 = HistoryRecord::new("r1", 1, Some("r0".to_string()), "bbb");
let r2 = HistoryRecord::new("r2", 2, Some("r1".to_string()), "ccc");
append_record(&fs, &path, &r0).unwrap();
append_record(&fs, &path, &r1).unwrap();
append_record(&fs, &path, &r2).unwrap();
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 3);
assert_eq!(records[0], r0);
assert_eq!(records[1], r1);
assert_eq!(records[2], r2);
}
#[test]
fn read_missing_is_empty() {
let fs = make_fs();
let path = PathBuf::from("/nonexistent/m.jsonl");
let records = read_records(&fs, &path).unwrap();
assert!(records.is_empty());
}
#[test]
fn lean_record_omits_optionals() {
let fs = make_fs();
let path = manifest_path();
let rec = HistoryRecord::new("r0", 0, None, "cafebabe");
append_record(&fs, &path, &rec).unwrap();
let raw = fs.read(&path).unwrap();
let line = std::str::from_utf8(&raw).unwrap();
assert!(
!line.contains("op_kind"),
"op_kind must be absent in lean form"
);
assert!(!line.contains("label"), "label must be absent in lean form");
assert!(
!line.contains("affected"),
"affected must be absent in lean form"
);
assert!(
!line.contains("timestamp_ms"),
"timestamp_ms must be absent in lean form"
);
assert!(
!line.contains("author"),
"author must be absent in lean form"
);
assert!(
!line.contains("action_id"),
"action_id must be absent in lean form"
);
assert!(
!line.contains("action_version"),
"action_version must be absent in lean form"
);
assert!(
!line.contains("preview_hash"),
"preview_hash must be absent in lean form"
);
assert!(
!line.contains("replay_eligible"),
"replay_eligible must be absent in lean form"
);
assert!(line.contains("\"snapshot\""), "snapshot must be present");
assert!(line.contains("\"seq\""), "seq must be present");
}
#[test]
fn checkpoint_record_roundtrips() {
let fs = make_fs();
let path = manifest_path();
let mut rec = HistoryRecord::new("cp0", 0, None, "cafef00d");
rec.action_id = Some("act-1".to_string());
rec.action_version = Some("rev-3".to_string());
rec.preview_hash = Some("preview123".to_string());
rec.replay_eligible = true;
append_record(&fs, &path, &rec).unwrap();
let raw = fs.read(&path).unwrap();
let line = std::str::from_utf8(&raw).unwrap();
assert!(line.contains("action_id"), "action_id must appear when set");
assert!(
line.contains("action_version"),
"action_version must appear when set"
);
assert!(
line.contains("preview_hash"),
"preview_hash must appear when set"
);
assert!(
line.contains("replay_eligible"),
"replay_eligible must appear when true"
);
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0], rec);
}
#[test]
fn old_manifest_without_checkpoint_fields_deserializes() {
let fs = make_fs();
let path = manifest_path();
let old_line = b"{\"id\":\"r0\",\"seq\":0,\"snapshot\":\"oldhash\",\"op_kind\":\"edit\"}\n";
fs.create_dir_all(path.parent().unwrap()).unwrap();
fs.write(&path, old_line).unwrap();
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].action_id, None);
assert_eq!(records[0].action_version, None);
assert_eq!(records[0].preview_hash, None);
assert!(!records[0].replay_eligible);
}
#[test]
fn full_record_roundtrips() {
let fs = make_fs();
let path = manifest_path();
let rec = HistoryRecord {
id: "full".to_string(),
seq: 7,
parent: Some("prev".to_string()),
snapshot: "abc123".to_string(),
op_kind: Some("move".to_string()),
label: Some("v2".to_string()),
affected: vec!["node-a".to_string(), "node-b".to_string()],
timestamp_ms: Some(1_700_000_000_000),
author: Some("alice".to_string()),
action_id: Some("act-42".to_string()),
action_version: Some("rev-7".to_string()),
preview_hash: Some("deadbeef".to_string()),
replay_eligible: true,
};
append_record(&fs, &path, &rec).unwrap();
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0], rec);
}
#[test]
fn blank_lines_skipped() {
let fs = make_fs();
let path = manifest_path();
let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
append_record(&fs, &path, &rec).unwrap();
fs.append(&path, b"\n \n").unwrap();
let records = read_records(&fs, &path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0], rec);
}
#[test]
fn malformed_line_errors() {
let fs = make_fs();
let path = manifest_path();
fs.create_dir_all(path.parent().unwrap()).unwrap();
fs.write(&path, b"{not json}\n").unwrap();
let result = read_records(&fs, &path);
assert!(result.is_err(), "expected error on malformed JSON line");
}
}