use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalEntry {
pub seq: u64,
pub session_id: String,
pub transition: StateTransition,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StateTransition {
Created {
quorum_id: String,
scheme: String,
threshold: u32,
},
CommitmentReceived { signer_id: String },
ShareReceived { signer_id: String },
Completed,
Expired,
Aborted { reason: String },
}
pub struct SessionWal {
path: PathBuf,
next_seq: Mutex<u64>,
}
impl SessionWal {
pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
let next_seq = if path.exists() {
let max_seq = Self::read_all_from_path(&path)?
.into_iter()
.map(|e| e.seq)
.max()
.unwrap_or(0);
max_seq + 1
} else {
1
};
Ok(Self {
path,
next_seq: Mutex::new(next_seq),
})
}
pub fn append(&self, session_id: &str, transition: StateTransition) -> std::io::Result<u64> {
let seq = {
let mut next = self.next_seq.lock().unwrap();
let current = *next;
*next += 1;
current
};
let entry = WalEntry {
seq,
session_id: session_id.into(),
transition,
timestamp: chrono::Utc::now(),
};
let json = serde_json::to_string(&entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{json}")?;
file.sync_data()?;
Ok(seq)
}
pub fn read_all(&self) -> std::io::Result<Vec<WalEntry>> {
Self::read_all_from_path(&self.path)
}
fn read_all_from_path(path: &Path) -> std::io::Result<Vec<WalEntry>> {
if !path.exists() {
return Ok(Vec::new());
}
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<WalEntry>(&line) {
Ok(entry) => entries.push(entry),
Err(_) => continue,
}
}
Ok(entries)
}
pub fn truncate(&self) -> std::io::Result<()> {
OpenOptions::new()
.write(true)
.truncate(true)
.open(&self.path)?;
let mut next = self.next_seq.lock().unwrap();
*next = 1;
Ok(())
}
pub fn next_seq(&self) -> u64 {
*self.next_seq.lock().unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_and_read() {
let tmp = tempfile::tempdir().unwrap();
let wal_path = tmp.path().join("wal.jsonl");
let wal = SessionWal::open(&wal_path).unwrap();
wal.append(
"s1",
StateTransition::Created {
quorum_id: "q".into(),
scheme: "CMP20".into(),
threshold: 2,
},
)
.unwrap();
wal.append(
"s1",
StateTransition::CommitmentReceived {
signer_id: "alice".into(),
},
)
.unwrap();
wal.append("s1", StateTransition::Completed).unwrap();
let entries = wal.read_all().unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].seq, 1);
assert_eq!(entries[1].seq, 2);
assert_eq!(entries[2].seq, 3);
}
#[test]
fn sequence_numbers_monotonic() {
let tmp = tempfile::tempdir().unwrap();
let wal = SessionWal::open(tmp.path().join("wal.jsonl")).unwrap();
let s1 = wal.append("s1", StateTransition::Completed).unwrap();
let s2 = wal.append("s1", StateTransition::Expired).unwrap();
let s3 = wal.append("s2", StateTransition::Completed).unwrap();
assert!(s1 < s2);
assert!(s2 < s3);
}
#[test]
fn reopen_continues_sequence() {
let tmp = tempfile::tempdir().unwrap();
let wal_path = tmp.path().join("wal.jsonl");
{
let wal = SessionWal::open(&wal_path).unwrap();
wal.append("s1", StateTransition::Completed).unwrap();
wal.append("s1", StateTransition::Expired).unwrap();
}
{
let wal = SessionWal::open(&wal_path).unwrap();
assert_eq!(wal.next_seq(), 3);
let seq = wal.append("s2", StateTransition::Completed).unwrap();
assert_eq!(seq, 3);
}
}
#[test]
fn truncate_clears_entries() {
let tmp = tempfile::tempdir().unwrap();
let wal = SessionWal::open(tmp.path().join("wal.jsonl")).unwrap();
wal.append("s1", StateTransition::Completed).unwrap();
wal.truncate().unwrap();
assert_eq!(wal.read_all().unwrap().len(), 0);
assert_eq!(wal.next_seq(), 1);
}
#[test]
fn empty_wal_returns_empty_vec() {
let tmp = tempfile::tempdir().unwrap();
let wal = SessionWal::open(tmp.path().join("nonexistent.jsonl")).unwrap();
assert!(wal.read_all().unwrap().is_empty());
}
#[test]
fn all_transition_types_serialize() {
let transitions = vec![
StateTransition::Created {
quorum_id: "q".into(),
scheme: "CMP20".into(),
threshold: 2,
},
StateTransition::CommitmentReceived {
signer_id: "a".into(),
},
StateTransition::ShareReceived {
signer_id: "a".into(),
},
StateTransition::Completed,
StateTransition::Expired,
StateTransition::Aborted {
reason: "test".into(),
},
];
for t in &transitions {
let json = serde_json::to_string(t).unwrap();
let recovered: StateTransition = serde_json::from_str(&json).unwrap();
assert_eq!(t, &recovered);
}
}
#[test]
fn corrupt_lines_skipped() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("wal.jsonl");
std::fs::write(&path, "not json\n{\"seq\":1,\"session_id\":\"s\",\"transition\":{\"created\":{\"quorum_id\":\"q\",\"scheme\":\"CMP20\",\"threshold\":2}},\"timestamp\":\"2026-01-01T00:00:00Z\"}\n").unwrap();
let wal = SessionWal::open(&path).unwrap();
let entries = wal.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].session_id, "s");
}
}