codewhale_protocol/
journal.rs1use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct JournalEntry {
18 pub id: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub parent_id: Option<String>,
23 pub kind: String,
25 #[serde(default)]
27 pub payload: Value,
28 pub created_at: i64,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
35pub struct Journal {
36 pub entries: Vec<JournalEntry>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub leaf_id: Option<String>,
40}
41
42impl Journal {
43 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
49 pub fn len(&self) -> usize {
50 self.entries.len()
51 }
52
53 #[must_use]
54 pub fn is_empty(&self) -> bool {
55 self.entries.is_empty()
56 }
57
58 pub fn append(&mut self, kind: impl Into<String>, payload: Value) -> String {
60 let id = format!("entry-{}", uuid::Uuid::new_v4());
61 let parent_id = self.leaf_id.clone();
62 let entry = JournalEntry {
63 id: id.clone(),
64 parent_id,
65 kind: kind.into(),
66 payload,
67 created_at: chrono::Utc::now().timestamp(),
68 };
69 self.entries.push(entry);
70 self.leaf_id = Some(id.clone());
71 id
72 }
73
74 pub fn branch_to(&mut self, target: &str) -> bool {
77 if self.entries.iter().any(|e| e.id == target) {
78 self.leaf_id = Some(target.to_string());
79 true
80 } else {
81 false
82 }
83 }
84
85 #[must_use]
87 pub fn active_path(&self) -> Vec<&JournalEntry> {
88 let Some(leaf) = self.leaf_id.as_deref() else {
89 return Vec::new();
90 };
91 let mut by_id = std::collections::HashMap::new();
93 for e in &self.entries {
94 by_id.insert(e.id.as_str(), e);
95 }
96 let mut path = Vec::new();
97 let mut cur: Option<&str> = Some(leaf);
98 while let Some(id) = cur {
99 if let Some(entry) = by_id.get(id) {
100 path.push(*entry);
101 cur = entry.parent_id.as_deref();
102 } else {
103 break;
104 }
105 }
106 path.reverse();
107 path
108 }
109
110 #[must_use]
112 pub fn get(&self, id: &str) -> Option<&JournalEntry> {
113 self.entries.iter().find(|e| e.id == id)
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use serde_json::json;
121
122 #[test]
123 fn append_sets_parent_and_leaf() {
124 let mut j = Journal::new();
125 let a = j.append("header", json!({}));
126 assert_eq!(j.leaf_id.as_deref(), Some(a.as_str()));
127 let b = j.append("user", json!("hi"));
128 let entry = j.get(&b).unwrap();
129 assert_eq!(entry.parent_id.as_deref(), Some(a.as_str()));
130 assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
131 }
132
133 #[test]
134 fn branching_only_moves_leaf() {
135 let mut j = Journal::new();
136 let a = j.append("header", json!({}));
137 let b = j.append("user", json!("b"));
138 let c = j.append("assistant", json!("c"));
139 assert_eq!(j.entries.len(), 3);
140 assert!(j.branch_to(&b));
141 assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
142 assert_eq!(j.entries.len(), 3);
144 let path = j.active_path();
146 assert_eq!(path.len(), 2);
147 assert_eq!(path[0].id, a);
148 assert_eq!(path[1].id, b);
149 let d = j.append("user", json!("d after branch"));
150 let ent = j.get(&d).unwrap();
151 assert_eq!(ent.parent_id.as_deref(), Some(b.as_str()));
152 assert!(j.get(&c).is_some());
154 let path2 = j.active_path();
155 assert_eq!(path2.len(), 3);
156 assert_eq!(path2[2].id, d);
157 }
158
159 #[test]
160 fn journal_is_serializable_and_preserves_shape() {
161 let mut j = Journal::new();
162 j.append("header", json!({}));
163 j.append("user", json!("hello"));
164 let s = serde_json::to_string(&j).unwrap();
165 let back: Journal = serde_json::from_str(&s).unwrap();
166 assert_eq!(back, j);
167 }
168}