1use crate::core::events::OpEvent;
7use anyhow::{Context, Result, anyhow, bail};
8use std::fs::{self, File, OpenOptions};
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12pub struct BinlogWriter {
18 path: PathBuf,
19}
20
21impl BinlogWriter {
22 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
24 let path = path.into();
25 if let Some(parent) = path.parent() {
26 fs::create_dir_all(parent).with_context(|| {
27 format!("failed to create binlog directory: {}", parent.display())
28 })?;
29 }
30 Ok(Self { path })
31 }
32
33 pub fn append(&self, event: &OpEvent) -> Result<()> {
35 let line = serde_json::to_string(event).context("failed to serialize OpEvent to JSON")?;
36
37 let mut file = OpenOptions::new()
38 .create(true)
39 .append(true)
40 .open(&self.path)
41 .with_context(|| format!("failed to open binlog: {}", self.path.display()))?;
42
43 writeln!(file, "{}", line).context("failed to write event to binlog")?;
44
45 file.sync_data().context("failed to fsync binlog")?;
46
47 Ok(())
48 }
49
50 pub fn path(&self) -> &Path {
52 &self.path
53 }
54}
55
56pub struct BinlogReader {
62 path: PathBuf,
63}
64
65impl BinlogReader {
66 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
68 let path = path.into();
69 if !path.exists() {
70 bail!("binlog not found: {}", path.display());
71 }
72 Ok(Self { path })
73 }
74
75 pub fn read_all(&self) -> Result<Vec<OpEvent>> {
77 let file = File::open(&self.path)
78 .with_context(|| format!("failed to open binlog: {}", self.path.display()))?;
79 let reader = BufReader::new(file);
80 let mut events = Vec::new();
81
82 for (line_number, line_result) in reader.lines().enumerate() {
83 let line = line_result
84 .with_context(|| format!("failed to read binlog line {}", line_number + 1))?;
85
86 let trimmed = line.trim();
87 if trimmed.is_empty() {
88 continue;
89 }
90
91 let event: OpEvent = serde_json::from_str(trimmed).with_context(|| {
92 format!("corrupted binlog at line {}: invalid JSON", line_number + 1)
93 })?;
94
95 events.push(event);
96 }
97
98 Ok(events)
99 }
100
101 pub fn read_after(&self, after_op_id: &str) -> Result<Vec<OpEvent>> {
103 let all = self.read_all()?;
104 let start_index = all
105 .iter()
106 .position(|e| e.op_id == after_op_id)
107 .map(|i| i + 1)
108 .unwrap_or(0);
109 Ok(all.into_iter().skip(start_index).collect())
110 }
111
112 pub fn read_until(&self, until_op_id: &str) -> Result<Vec<OpEvent>> {
114 let all = self.read_all()?;
115 let end_index = all
116 .iter()
117 .position(|e| e.op_id == until_op_id)
118 .ok_or_else(|| anyhow!("op_id '{}' not found in binlog", until_op_id))?;
119 Ok(all.into_iter().take(end_index + 1).collect())
120 }
121
122 pub fn validate_lineage(&self) -> Result<Vec<String>> {
125 let events = self.read_all()?;
126 let mut warnings = Vec::new();
127
128 for (i, event) in events.iter().enumerate() {
129 if i == 0 {
130 if event.parent_id.is_some() {
131 warnings.push(format!(
132 "first event '{}' has parent_id but is the first in the log",
133 event.op_id
134 ));
135 }
136 continue;
137 }
138
139 let expected_parent = &events[i - 1].op_id;
140 match &event.parent_id {
141 Some(parent) if parent != expected_parent => {
142 warnings.push(format!(
143 "event '{}' has parent_id '{}' but expected '{}' (branch point or corruption)",
144 event.op_id, parent, expected_parent
145 ));
146 }
147 None => {
148 warnings.push(format!(
149 "event '{}' at position {} has no parent_id",
150 event.op_id, i
151 ));
152 }
153 _ => {}
154 }
155 }
156
157 Ok(warnings)
158 }
159
160 pub fn validate_hash_chain(&self) -> Result<Vec<String>> {
162 let events = self.read_all()?;
163 let mut warnings = Vec::new();
164
165 for (i, event) in events.iter().enumerate() {
166 if i == 0 {
167 continue;
168 }
169
170 if let (Some(prev_hash), Some(expected_prev)) =
171 (&events[i - 1].event_hash, &event.prev_event_hash)
172 && prev_hash != expected_prev
173 {
174 warnings.push(format!(
175 "hash chain broken at event '{}': prev_event_hash '{}' != previous event_hash '{}'",
176 event.op_id, expected_prev, prev_hash
177 ));
178 }
179 }
180
181 Ok(warnings)
182 }
183
184 pub fn tip(&self) -> Result<Option<OpEvent>> {
186 let events = self.read_all()?;
187 Ok(events.into_iter().last())
188 }
189
190 pub fn count(&self) -> Result<usize> {
192 let file = File::open(&self.path)
193 .with_context(|| format!("failed to open binlog: {}", self.path.display()))?;
194 let reader = BufReader::new(file);
195 let count = reader
196 .lines()
197 .map_while(Result::ok)
198 .filter(|l| !l.trim().is_empty())
199 .count();
200 Ok(count)
201 }
202}
203
204#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
210pub struct SnapshotEntry {
211 pub op_id: String,
213 pub file_name: String,
215 pub file_hash: String,
217 pub created_at: chrono::DateTime<chrono::Utc>,
219 pub event_count: usize,
221}
222
223#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct SnapshotManifest {
226 pub session_id: String,
227 pub entries: Vec<SnapshotEntry>,
228}
229
230impl SnapshotManifest {
231 pub fn new(session_id: String) -> Self {
233 Self {
234 session_id,
235 entries: Vec::new(),
236 }
237 }
238
239 pub fn load(path: &Path) -> Result<Self> {
241 let content = fs::read_to_string(path)
242 .with_context(|| format!("failed to read snapshot manifest: {}", path.display()))?;
243 serde_json::from_str(&content).context("failed to parse snapshot manifest")
244 }
245
246 pub fn save(&self, path: &Path) -> Result<()> {
248 let content =
249 serde_json::to_string_pretty(self).context("failed to serialize snapshot manifest")?;
250 if let Some(parent) = path.parent() {
251 fs::create_dir_all(parent)?;
252 }
253 fs::write(path, content)
254 .with_context(|| format!("failed to write snapshot manifest: {}", path.display()))
255 }
256
257 pub fn add_entry(&mut self, entry: SnapshotEntry) {
259 self.entries.push(entry);
260 }
261
262 pub fn nearest_snapshot(
266 &self,
267 target_op_id: &str,
268 event_order: &[String],
269 ) -> Option<&SnapshotEntry> {
270 let target_pos = event_order.iter().position(|id| id == target_op_id)?;
271 self.entries
272 .iter()
273 .filter_map(|entry| {
274 event_order
275 .iter()
276 .position(|id| id == &entry.op_id)
277 .filter(|&pos| pos <= target_pos)
278 .map(|pos| (pos, entry))
279 })
280 .max_by_key(|(pos, _)| *pos)
281 .map(|(_, entry)| entry)
282 }
283}
284
285#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
291pub struct BranchInfo {
292 pub name: String,
294 pub tip_op_id: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub fork_point: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub label: Option<String>,
302 pub created_at: chrono::DateTime<chrono::Utc>,
304}
305
306#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
308pub struct BranchesFile {
309 pub branches: Vec<BranchInfo>,
310}
311
312impl BranchesFile {
313 pub fn new() -> Self {
314 Self {
315 branches: vec![BranchInfo {
316 name: "main".to_string(),
317 tip_op_id: None,
318 fork_point: None,
319 label: None,
320 created_at: chrono::Utc::now(),
321 }],
322 }
323 }
324
325 pub fn load(path: &Path) -> Result<Self> {
326 let content = fs::read_to_string(path)
327 .with_context(|| format!("failed to read branches file: {}", path.display()))?;
328 serde_json::from_str(&content).context("failed to parse branches file")
329 }
330
331 pub fn save(&self, path: &Path) -> Result<()> {
332 let content = serde_json::to_string_pretty(self)?;
333 fs::write(path, content)
334 .with_context(|| format!("failed to write branches file: {}", path.display()))
335 }
336
337 pub fn get_branch(&self, name: &str) -> Option<&BranchInfo> {
338 self.branches.iter().find(|b| b.name == name)
339 }
340
341 pub fn get_branch_mut(&mut self, name: &str) -> Option<&mut BranchInfo> {
342 self.branches.iter_mut().find(|b| b.name == name)
343 }
344
345 pub fn add_branch(&mut self, info: BranchInfo) {
346 self.branches.push(info);
347 }
348}
349
350impl Default for BranchesFile {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::core::events::{Actor, OpEvent, OpKind};
364 use serde_json::json;
365 use tempfile::TempDir;
366
367 fn test_event(session_id: &str, parent_id: Option<&str>) -> OpEvent {
368 OpEvent::new(
369 session_id.to_string(),
370 parent_id.map(|s| s.to_string()),
371 Actor {
372 id: "test".to_string(),
373 run_id: None,
374 source: "test".to_string(),
375 },
376 OpKind::edit_batch(),
377 json!({"cell": "A1", "value": 42}),
378 )
379 }
380
381 #[test]
382 fn binlog_append_and_read() {
383 let tmp = TempDir::new().unwrap();
384 let log_path = tmp.path().join("events.jsonl");
385
386 let writer = BinlogWriter::open(&log_path).unwrap();
387 let event1 = test_event("sess1", None);
388 let event2 = test_event("sess1", Some(&event1.op_id));
389
390 writer.append(&event1).unwrap();
391 writer.append(&event2).unwrap();
392
393 let reader = BinlogReader::open(&log_path).unwrap();
394 let events = reader.read_all().unwrap();
395 assert_eq!(events.len(), 2);
396 assert_eq!(events[0].op_id, event1.op_id);
397 assert_eq!(events[1].op_id, event2.op_id);
398 }
399
400 #[test]
401 fn binlog_read_after() {
402 let tmp = TempDir::new().unwrap();
403 let log_path = tmp.path().join("events.jsonl");
404
405 let writer = BinlogWriter::open(&log_path).unwrap();
406 let e1 = test_event("sess1", None);
407 let e2 = test_event("sess1", Some(&e1.op_id));
408 let e3 = test_event("sess1", Some(&e2.op_id));
409
410 writer.append(&e1).unwrap();
411 writer.append(&e2).unwrap();
412 writer.append(&e3).unwrap();
413
414 let reader = BinlogReader::open(&log_path).unwrap();
415 let after = reader.read_after(&e1.op_id).unwrap();
416 assert_eq!(after.len(), 2);
417 assert_eq!(after[0].op_id, e2.op_id);
418 }
419
420 #[test]
421 fn binlog_lineage_validation() {
422 let tmp = TempDir::new().unwrap();
423 let log_path = tmp.path().join("events.jsonl");
424
425 let writer = BinlogWriter::open(&log_path).unwrap();
426 let e1 = test_event("sess1", None);
427 let e2 = test_event("sess1", Some(&e1.op_id));
428 let e3 = test_event("sess1", Some("wrong_parent"));
430
431 writer.append(&e1).unwrap();
432 writer.append(&e2).unwrap();
433 writer.append(&e3).unwrap();
434
435 let reader = BinlogReader::open(&log_path).unwrap();
436 let warnings = reader.validate_lineage().unwrap();
437 assert_eq!(warnings.len(), 1);
438 assert!(warnings[0].contains("wrong_parent"));
439 }
440
441 #[test]
442 fn snapshot_manifest_nearest() {
443 let mut manifest = SnapshotManifest::new("sess1".to_string());
444 manifest.add_entry(SnapshotEntry {
445 op_id: "op_001".to_string(),
446 file_name: "snap_001.xlsx".to_string(),
447 file_hash: "sha256:aaa".to_string(),
448 created_at: chrono::Utc::now(),
449 event_count: 1,
450 });
451 manifest.add_entry(SnapshotEntry {
452 op_id: "op_005".to_string(),
453 file_name: "snap_005.xlsx".to_string(),
454 file_hash: "sha256:bbb".to_string(),
455 created_at: chrono::Utc::now(),
456 event_count: 5,
457 });
458
459 let order: Vec<String> = (1..=10).map(|i| format!("op_{:03}", i)).collect();
460
461 let nearest = manifest.nearest_snapshot("op_007", &order);
463 assert_eq!(nearest.unwrap().op_id, "op_005");
464
465 let nearest = manifest.nearest_snapshot("op_003", &order);
467 assert_eq!(nearest.unwrap().op_id, "op_001");
468
469 let nearest = manifest.nearest_snapshot("op_001", &order);
471 assert_eq!(nearest.unwrap().op_id, "op_001");
472 }
473
474 #[test]
475 fn branches_file_roundtrip() {
476 let tmp = TempDir::new().unwrap();
477 let path = tmp.path().join("branches.json");
478
479 let mut bf = BranchesFile::new();
480 bf.add_branch(BranchInfo {
481 name: "alt-scenario".to_string(),
482 tip_op_id: Some("op_abc".to_string()),
483 fork_point: Some("op_005".to_string()),
484 label: Some("Alternative Scenario".to_string()),
485 created_at: chrono::Utc::now(),
486 });
487
488 bf.save(&path).unwrap();
489 let loaded = BranchesFile::load(&path).unwrap();
490 assert_eq!(loaded.branches.len(), 2);
491 assert_eq!(
492 loaded
493 .get_branch("alt-scenario")
494 .unwrap()
495 .tip_op_id
496 .as_deref(),
497 Some("op_abc")
498 );
499 }
500}