use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JournalEntry {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub kind: String,
#[serde(default)]
pub payload: Value,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Journal {
pub entries: Vec<JournalEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
}
impl Journal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn append(&mut self, kind: impl Into<String>, payload: Value) -> String {
let id = format!("entry-{}", uuid::Uuid::new_v4());
let parent_id = self.leaf_id.clone();
let entry = JournalEntry {
id: id.clone(),
parent_id,
kind: kind.into(),
payload,
created_at: chrono::Utc::now().timestamp(),
};
self.entries.push(entry);
self.leaf_id = Some(id.clone());
id
}
pub fn branch_to(&mut self, target: &str) -> bool {
if self.entries.iter().any(|e| e.id == target) {
self.leaf_id = Some(target.to_string());
true
} else {
false
}
}
#[must_use]
pub fn active_path(&self) -> Vec<&JournalEntry> {
let Some(leaf) = self.leaf_id.as_deref() else {
return Vec::new();
};
let mut by_id = std::collections::HashMap::new();
for e in &self.entries {
by_id.insert(e.id.as_str(), e);
}
let mut path = Vec::new();
let mut cur: Option<&str> = Some(leaf);
while let Some(id) = cur {
if let Some(entry) = by_id.get(id) {
path.push(*entry);
cur = entry.parent_id.as_deref();
} else {
break;
}
}
path.reverse();
path
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&JournalEntry> {
self.entries.iter().find(|e| e.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn append_sets_parent_and_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
assert_eq!(j.leaf_id.as_deref(), Some(a.as_str()));
let b = j.append("user", json!("hi"));
let entry = j.get(&b).unwrap();
assert_eq!(entry.parent_id.as_deref(), Some(a.as_str()));
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
}
#[test]
fn branching_only_moves_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("b"));
let c = j.append("assistant", json!("c"));
assert_eq!(j.entries.len(), 3);
assert!(j.branch_to(&b));
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
assert_eq!(j.entries.len(), 3);
let path = j.active_path();
assert_eq!(path.len(), 2);
assert_eq!(path[0].id, a);
assert_eq!(path[1].id, b);
let d = j.append("user", json!("d after branch"));
let ent = j.get(&d).unwrap();
assert_eq!(ent.parent_id.as_deref(), Some(b.as_str()));
assert!(j.get(&c).is_some());
let path2 = j.active_path();
assert_eq!(path2.len(), 3);
assert_eq!(path2[2].id, d);
}
#[test]
fn journal_is_serializable_and_preserves_shape() {
let mut j = Journal::new();
j.append("header", json!({}));
j.append("user", json!("hello"));
let s = serde_json::to_string(&j).unwrap();
let back: Journal = serde_json::from_str(&s).unwrap();
assert_eq!(back, j);
}
}