1use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::adapter::Fs;
14use crate::error::SessionError;
15
16#[derive(Debug, Clone, Default, PartialEq)]
20pub struct CheckpointMeta {
21 pub action_id: Option<String>,
23 pub action_version: Option<String>,
25 pub preview_hash: Option<String>,
27 pub replay_eligible: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct HistoryRecord {
40 pub id: String,
42 pub seq: u64,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub parent: Option<String>,
47 pub snapshot: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub op_kind: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub label: Option<String>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub affected: Vec<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub timestamp_ms: Option<u128>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub author: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub action_id: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub action_version: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub preview_hash: Option<String>,
73 #[serde(default, skip_serializing_if = "is_false")]
75 pub replay_eligible: bool,
76}
77
78impl HistoryRecord {
79 pub fn new(
82 id: impl Into<String>,
83 seq: u64,
84 parent: Option<String>,
85 snapshot: impl Into<String>,
86 ) -> Self {
87 Self {
88 id: id.into(),
89 seq,
90 parent,
91 snapshot: snapshot.into(),
92 op_kind: None,
93 label: None,
94 affected: Vec::new(),
95 timestamp_ms: None,
96 author: None,
97 action_id: None,
98 action_version: None,
99 preview_hash: None,
100 replay_eligible: false,
101 }
102 }
103}
104
105fn is_false(b: &bool) -> bool {
106 !*b
107}
108
109pub(crate) fn append_jsonl_record<T: serde::Serialize>(
114 fs: &impl Fs,
115 path: &Path,
116 record: &T,
117) -> Result<(), SessionError> {
118 if let Some(parent) = path.parent() {
119 fs.create_dir_all(parent)?;
120 }
121 let mut line = serde_json::to_vec(record)
122 .map_err(|e| SessionError::new(format!("serialize record: {e}")))?;
123 line.push(b'\n');
124 fs.append(path, &line)
125}
126
127pub(crate) fn read_jsonl_records<T: serde::de::DeserializeOwned>(
130 fs: &impl Fs,
131 path: &Path,
132) -> Result<Vec<T>, SessionError> {
133 if !fs.exists(path) {
134 return Ok(Vec::new());
135 }
136 let bytes = fs.read(path)?;
137 let text = std::str::from_utf8(&bytes)
138 .map_err(|e| SessionError::new(format!("manifest is not utf-8: {e}")))?;
139 let mut out = Vec::new();
140 for line in text.lines() {
141 if line.trim().is_empty() {
142 continue;
143 }
144 let rec = serde_json::from_str(line)
145 .map_err(|e| SessionError::new(format!("parse record: {e}")))?;
146 out.push(rec);
147 }
148 Ok(out)
149}
150
151pub fn append_record(
154 fs: &impl Fs,
155 path: &Path,
156 record: &HistoryRecord,
157) -> Result<(), SessionError> {
158 append_jsonl_record(fs, path, record)
159}
160
161pub fn read_records(fs: &impl Fs, path: &Path) -> Result<Vec<HistoryRecord>, SessionError> {
165 read_jsonl_records::<HistoryRecord>(fs, path)
166}
167
168#[cfg(test)]
171mod tests {
172 use std::path::PathBuf;
173
174 use super::*;
175 use crate::adapter::{Fs, MemFs};
176
177 fn manifest_path() -> PathBuf {
178 PathBuf::from("/data/m.jsonl")
179 }
180
181 fn make_fs() -> MemFs {
182 MemFs::new()
183 }
184
185 #[test]
186 fn append_then_read_one() {
187 let fs = make_fs();
188 let path = manifest_path();
189 let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
190 append_record(&fs, &path, &rec).unwrap();
191 let records = read_records(&fs, &path).unwrap();
192 assert_eq!(records.len(), 1);
193 assert_eq!(records[0], rec);
194 }
195
196 #[test]
197 fn append_multiple_preserves_order() {
198 let fs = make_fs();
199 let path = manifest_path();
200 let r0 = HistoryRecord::new("r0", 0, None, "aaa");
201 let r1 = HistoryRecord::new("r1", 1, Some("r0".to_string()), "bbb");
202 let r2 = HistoryRecord::new("r2", 2, Some("r1".to_string()), "ccc");
203 append_record(&fs, &path, &r0).unwrap();
204 append_record(&fs, &path, &r1).unwrap();
205 append_record(&fs, &path, &r2).unwrap();
206 let records = read_records(&fs, &path).unwrap();
207 assert_eq!(records.len(), 3);
208 assert_eq!(records[0], r0);
209 assert_eq!(records[1], r1);
210 assert_eq!(records[2], r2);
211 }
212
213 #[test]
214 fn read_missing_is_empty() {
215 let fs = make_fs();
216 let path = PathBuf::from("/nonexistent/m.jsonl");
217 let records = read_records(&fs, &path).unwrap();
218 assert!(records.is_empty());
219 }
220
221 #[test]
222 fn lean_record_omits_optionals() {
223 let fs = make_fs();
224 let path = manifest_path();
225 let rec = HistoryRecord::new("r0", 0, None, "cafebabe");
226 append_record(&fs, &path, &rec).unwrap();
227 let raw = fs.read(&path).unwrap();
228 let line = std::str::from_utf8(&raw).unwrap();
229 assert!(
230 !line.contains("op_kind"),
231 "op_kind must be absent in lean form"
232 );
233 assert!(!line.contains("label"), "label must be absent in lean form");
234 assert!(
235 !line.contains("affected"),
236 "affected must be absent in lean form"
237 );
238 assert!(
239 !line.contains("timestamp_ms"),
240 "timestamp_ms must be absent in lean form"
241 );
242 assert!(
243 !line.contains("author"),
244 "author must be absent in lean form"
245 );
246 assert!(
247 !line.contains("action_id"),
248 "action_id must be absent in lean form"
249 );
250 assert!(
251 !line.contains("action_version"),
252 "action_version must be absent in lean form"
253 );
254 assert!(
255 !line.contains("preview_hash"),
256 "preview_hash must be absent in lean form"
257 );
258 assert!(
259 !line.contains("replay_eligible"),
260 "replay_eligible must be absent in lean form"
261 );
262 assert!(line.contains("\"snapshot\""), "snapshot must be present");
263 assert!(line.contains("\"seq\""), "seq must be present");
264 }
265
266 #[test]
267 fn checkpoint_record_roundtrips() {
268 let fs = make_fs();
269 let path = manifest_path();
270 let mut rec = HistoryRecord::new("cp0", 0, None, "cafef00d");
271 rec.action_id = Some("act-1".to_string());
272 rec.action_version = Some("rev-3".to_string());
273 rec.preview_hash = Some("preview123".to_string());
274 rec.replay_eligible = true;
275 append_record(&fs, &path, &rec).unwrap();
276 let raw = fs.read(&path).unwrap();
277 let line = std::str::from_utf8(&raw).unwrap();
278 assert!(line.contains("action_id"), "action_id must appear when set");
279 assert!(
280 line.contains("action_version"),
281 "action_version must appear when set"
282 );
283 assert!(
284 line.contains("preview_hash"),
285 "preview_hash must appear when set"
286 );
287 assert!(
288 line.contains("replay_eligible"),
289 "replay_eligible must appear when true"
290 );
291 let records = read_records(&fs, &path).unwrap();
292 assert_eq!(records.len(), 1);
293 assert_eq!(records[0], rec);
294 }
295
296 #[test]
297 fn old_manifest_without_checkpoint_fields_deserializes() {
298 let fs = make_fs();
299 let path = manifest_path();
300 let old_line = b"{\"id\":\"r0\",\"seq\":0,\"snapshot\":\"oldhash\",\"op_kind\":\"edit\"}\n";
302 fs.create_dir_all(path.parent().unwrap()).unwrap();
303 fs.write(&path, old_line).unwrap();
304 let records = read_records(&fs, &path).unwrap();
305 assert_eq!(records.len(), 1);
306 assert_eq!(records[0].action_id, None);
307 assert_eq!(records[0].action_version, None);
308 assert_eq!(records[0].preview_hash, None);
309 assert!(!records[0].replay_eligible);
310 }
311
312 #[test]
313 fn full_record_roundtrips() {
314 let fs = make_fs();
315 let path = manifest_path();
316 let rec = HistoryRecord {
317 id: "full".to_string(),
318 seq: 7,
319 parent: Some("prev".to_string()),
320 snapshot: "abc123".to_string(),
321 op_kind: Some("move".to_string()),
322 label: Some("v2".to_string()),
323 affected: vec!["node-a".to_string(), "node-b".to_string()],
324 timestamp_ms: Some(1_700_000_000_000),
325 author: Some("alice".to_string()),
326 action_id: Some("act-42".to_string()),
327 action_version: Some("rev-7".to_string()),
328 preview_hash: Some("deadbeef".to_string()),
329 replay_eligible: true,
330 };
331 append_record(&fs, &path, &rec).unwrap();
332 let records = read_records(&fs, &path).unwrap();
333 assert_eq!(records.len(), 1);
334 assert_eq!(records[0], rec);
335 }
336
337 #[test]
338 fn blank_lines_skipped() {
339 let fs = make_fs();
340 let path = manifest_path();
341 let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
342 append_record(&fs, &path, &rec).unwrap();
343 fs.append(&path, b"\n \n").unwrap();
345 let records = read_records(&fs, &path).unwrap();
346 assert_eq!(records.len(), 1);
347 assert_eq!(records[0], rec);
348 }
349
350 #[test]
351 fn malformed_line_errors() {
352 let fs = make_fs();
353 let path = manifest_path();
354 fs.create_dir_all(path.parent().unwrap()).unwrap();
356 fs.write(&path, b"{not json}\n").unwrap();
357 let result = read_records(&fs, &path);
358 assert!(result.is_err(), "expected error on malformed JSON line");
359 }
360}