1use crate::core::binlog::{
23 BinlogReader, BinlogWriter, BranchInfo, BranchesFile, SnapshotEntry, SnapshotManifest,
24};
25use crate::core::events::OpEvent;
26use anyhow::{Context, Result, anyhow, bail};
27use sha2::{Digest, Sha256};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31const SNAPSHOT_INTERVAL: usize = 10;
33
34const MAX_SESSIONS: usize = 50;
36
37pub struct SessionStore {
43 root: PathBuf,
44}
45
46impl SessionStore {
47 pub fn open(workspace_root: impl Into<PathBuf>) -> Result<Self> {
50 let root = workspace_root.into().join(".asp").join("sessions");
51 fs::create_dir_all(&root)
52 .with_context(|| format!("failed to create session store: {}", root.display()))?;
53 Ok(Self { root })
54 }
55
56 pub fn create_session(&self, base_path: &Path, label: Option<&str>) -> Result<SessionHandle> {
58 let session_count = self.list_sessions()?.len();
59 if session_count >= MAX_SESSIONS {
60 bail!(
61 "session limit reached: {} sessions exist (max {})",
62 session_count,
63 MAX_SESSIONS
64 );
65 }
66
67 if !base_path.exists() {
68 bail!("base file not found: {}", base_path.display());
69 }
70
71 let session_id = make_session_id();
72 let session_dir = self.root.join(&session_id);
73 fs::create_dir_all(&session_dir)?;
74
75 let base_dest = session_dir.join("base.xlsx");
77 fs::copy(base_path, &base_dest).with_context(|| {
78 format!(
79 "failed to copy base file from '{}' to '{}'",
80 base_path.display(),
81 base_dest.display()
82 )
83 })?;
84
85 let binlog_path = session_dir.join("events.jsonl");
87 fs::write(&binlog_path, "")?;
88
89 fs::write(session_dir.join("HEAD"), "")?;
91
92 fs::write(session_dir.join("CURRENT_BRANCH"), "main")?;
94
95 let branches = BranchesFile::new();
97 branches.save(&session_dir.join("branches.json"))?;
98
99 let manifest = SnapshotManifest::new(session_id.clone());
101 let snapshots_dir = session_dir.join("snapshots");
102 fs::create_dir_all(&snapshots_dir)?;
103 manifest.save(&snapshots_dir.join("manifest.json"))?;
104
105 fs::create_dir_all(session_dir.join("staged"))?;
107 fs::create_dir_all(session_dir.join("locks"))?;
108
109 let meta = SessionMeta {
111 session_id: session_id.clone(),
112 label: label.map(|s| s.to_string()),
113 base_path: base_path.to_path_buf(),
114 created_at: chrono::Utc::now(),
115 };
116 let meta_json = serde_json::to_string_pretty(&meta)?;
117 fs::write(session_dir.join("session.json"), meta_json)?;
118
119 SessionHandle::open(&self.root, &session_id)
120 }
121
122 pub fn open_session(&self, session_id: &str) -> Result<SessionHandle> {
124 SessionHandle::open(&self.root, session_id)
125 }
126
127 pub fn list_sessions(&self) -> Result<Vec<String>> {
129 let mut sessions = Vec::new();
130 if self.root.exists() {
131 for entry in fs::read_dir(&self.root)? {
132 let entry = entry?;
133 if entry.file_type()?.is_dir()
134 && let Some(name) = entry.file_name().to_str()
135 && name.starts_with("sess_")
136 {
137 sessions.push(name.to_string());
138 }
139 }
140 }
141 sessions.sort();
142 Ok(sessions)
143 }
144
145 pub fn delete_session(&self, session_id: &str) -> Result<()> {
147 let session_dir = self.root.join(session_id);
148 if !session_dir.exists() {
149 bail!("session not found: {}", session_id);
150 }
151 fs::remove_dir_all(&session_dir)
152 .with_context(|| format!("failed to delete session: {}", session_id))
153 }
154}
155
156#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct SessionMeta {
162 pub session_id: String,
163 pub label: Option<String>,
164 pub base_path: PathBuf,
165 pub created_at: chrono::DateTime<chrono::Utc>,
166}
167
168pub struct SessionHandle {
174 pub session_id: String,
175 dir: PathBuf,
176}
177
178impl SessionHandle {
179 fn open(store_root: &Path, session_id: &str) -> Result<Self> {
180 let dir = store_root.join(session_id);
181 if !dir.exists() {
182 bail!("session not found: {}", session_id);
183 }
184 Ok(Self {
185 session_id: session_id.to_string(),
186 dir,
187 })
188 }
189
190 pub fn dir(&self) -> &Path {
193 &self.dir
194 }
195
196 pub fn base_path(&self) -> PathBuf {
197 self.dir.join("base.xlsx")
198 }
199
200 pub fn binlog_path(&self) -> PathBuf {
201 self.dir.join("events.jsonl")
202 }
203
204 pub fn head_path(&self) -> PathBuf {
205 self.dir.join("HEAD")
206 }
207
208 pub fn current_branch_path(&self) -> PathBuf {
209 self.dir.join("CURRENT_BRANCH")
210 }
211
212 pub fn branches_path(&self) -> PathBuf {
213 self.dir.join("branches.json")
214 }
215
216 pub fn snapshot_manifest_path(&self) -> PathBuf {
217 self.dir.join("snapshots").join("manifest.json")
218 }
219
220 pub fn snapshot_file_path(&self, op_id: &str) -> PathBuf {
221 self.dir.join("snapshots").join(format!("{}.xlsx", op_id))
222 }
223
224 pub fn staged_dir(&self) -> PathBuf {
225 self.dir.join("staged")
226 }
227
228 pub fn lock_path(&self) -> PathBuf {
229 self.dir.join("locks").join("session.lock")
230 }
231
232 pub fn read_head(&self) -> Result<Option<String>> {
236 let content = fs::read_to_string(self.head_path()).context("failed to read HEAD")?;
237 let trimmed = content.trim();
238 if trimmed.is_empty() {
239 Ok(None)
240 } else {
241 Ok(Some(trimmed.to_string()))
242 }
243 }
244
245 pub fn write_head(&self, op_id: &str) -> Result<()> {
247 fs::write(self.head_path(), op_id).context("failed to write HEAD")
248 }
249
250 pub fn clear_head(&self) -> Result<()> {
252 fs::write(self.head_path(), "").context("failed to clear HEAD")
253 }
254
255 pub fn current_branch(&self) -> Result<String> {
259 let content = fs::read_to_string(self.current_branch_path())
260 .context("failed to read CURRENT_BRANCH")?;
261 Ok(content.trim().to_string())
262 }
263
264 pub fn switch_branch(&self, branch_name: &str) -> Result<()> {
266 let branches = BranchesFile::load(&self.branches_path())?;
267 if branches.get_branch(branch_name).is_none() {
268 bail!("branch not found: {}", branch_name);
269 }
270
271 fs::write(self.current_branch_path(), branch_name)
272 .context("failed to write CURRENT_BRANCH")?;
273
274 let tip = branches
276 .get_branch(branch_name)
277 .and_then(|b| b.tip_op_id.clone());
278 match tip {
279 Some(op_id) => self.write_head(&op_id),
280 None => self.clear_head(),
281 }
282 }
283
284 pub fn create_branch(
286 &self,
287 name: &str,
288 fork_point: Option<&str>,
289 label: Option<&str>,
290 ) -> Result<()> {
291 let mut branches = BranchesFile::load(&self.branches_path())?;
292 if branches.get_branch(name).is_some() {
293 bail!("branch already exists: {}", name);
294 }
295
296 branches.add_branch(BranchInfo {
297 name: name.to_string(),
298 tip_op_id: fork_point.map(|s| s.to_string()),
299 fork_point: fork_point.map(|s| s.to_string()),
300 label: label.map(|s| s.to_string()),
301 created_at: chrono::Utc::now(),
302 });
303
304 branches.save(&self.branches_path())
305 }
306
307 pub fn list_branches(&self) -> Result<Vec<BranchInfo>> {
309 let branches = BranchesFile::load(&self.branches_path())?;
310 Ok(branches.branches)
311 }
312
313 pub fn append_event(&self, mut event: OpEvent) -> Result<()> {
318 let _lock = self.acquire_lock()?;
320
321 let current_head = self.read_head()?;
323 if event.parent_id != current_head {
324 bail!(
325 "CAS conflict: event parent_id is {:?} but HEAD is {:?}",
326 event.parent_id,
327 current_head
328 );
329 }
330
331 if let Some(ref preconditions) = event.preconditions {
333 let has_cell_matches = !preconditions.cell_matches.is_empty();
334 let has_hash_before = preconditions.workbook_hash_before.is_some();
335
336 if has_cell_matches || has_hash_before {
337 let wb_bytes = self.materialize()?;
338
339 if has_hash_before {
340 let actual = compute_workbook_hash(&wb_bytes);
341 let expected = preconditions.workbook_hash_before.as_ref().unwrap();
342 if &actual != expected {
343 bail!(
344 "precondition failed: workbook_hash_before mismatch (expected {}, got {})",
345 expected,
346 actual
347 );
348 }
349 }
350
351 if has_cell_matches {
352 let ws = crate::core::session::WorkbookSession::from_bytes(&wb_bytes)?;
353 let violations = evaluate_cell_matches(&ws, &preconditions.cell_matches)?;
354 if !violations.is_empty() {
355 bail!("precondition failed: {}", violations.join("; "));
356 }
357 }
358 }
359 }
360
361 let prev_hash = if current_head.is_some() {
363 let reader = BinlogReader::open(self.binlog_path())?;
364 reader.tip()?.and_then(|e| e.event_hash)
365 } else {
366 None
367 };
368 event.prev_event_hash = prev_hash;
369 event.seal();
370
371 let writer = BinlogWriter::open(self.binlog_path())?;
373 writer.append(&event)?;
374
375 self.write_head(&event.op_id)?;
377
378 let branch_name = self.current_branch()?;
380 let mut branches = BranchesFile::load(&self.branches_path())?;
381 if let Some(branch) = branches.get_branch_mut(&branch_name) {
382 branch.tip_op_id = Some(event.op_id.clone());
383 }
384 branches.save(&self.branches_path())?;
385
386 let reader = BinlogReader::open(self.binlog_path())?;
388 let event_count = reader.count()?;
389 if event_count > 0 && event_count % SNAPSHOT_INTERVAL == 0 {
390 let _ = self.create_snapshot(&event.op_id, event_count);
392 }
393
394 Ok(())
395 }
396
397 pub fn read_events(&self) -> Result<Vec<OpEvent>> {
399 let reader = BinlogReader::open(self.binlog_path())?;
400 reader.read_all()
401 }
402
403 pub fn read_events_after(&self, after_op_id: &str) -> Result<Vec<OpEvent>> {
405 let reader = BinlogReader::open(self.binlog_path())?;
406 reader.read_after(after_op_id)
407 }
408
409 pub fn log(&self) -> Result<Vec<OpEvent>> {
411 self.read_events()
412 }
413
414 pub fn undo(&self) -> Result<Option<String>> {
418 let head = self.read_head()?;
419 let Some(head_id) = head else {
420 bail!("already at base: nothing to undo");
421 };
422
423 let events = self.read_events()?;
424 let head_event = events
425 .iter()
426 .find(|e| e.op_id == head_id)
427 .ok_or_else(|| anyhow!("HEAD event '{}' not found in binlog", head_id))?;
428
429 match &head_event.parent_id {
430 Some(parent) => {
431 self.write_head(parent)?;
432 Ok(Some(parent.clone()))
433 }
434 None => {
435 self.clear_head()?;
436 Ok(None)
437 }
438 }
439 }
440
441 pub fn redo(&self) -> Result<Option<String>> {
443 let head = self.read_head()?;
444 let events = self.read_events()?;
445
446 let next = match &head {
447 Some(head_id) => events
448 .iter()
449 .find(|e| e.parent_id.as_deref() == Some(head_id)),
450 None => events.first(),
451 };
452
453 match next {
454 Some(event) => {
455 self.write_head(&event.op_id)?;
456 Ok(Some(event.op_id.clone()))
457 }
458 None => bail!("nothing to redo"),
459 }
460 }
461
462 pub fn checkout(&self, op_id: &str) -> Result<()> {
464 let events = self.read_events()?;
465 if !events.iter().any(|e| e.op_id == op_id) {
466 bail!("op_id '{}' not found in event log", op_id);
467 }
468 self.write_head(op_id)
469 }
470
471 fn create_snapshot(&self, op_id: &str, event_count: usize) -> Result<()> {
475 let materialized_bytes = self.materialize_at(op_id)?;
477 let snapshot_path = self.snapshot_file_path(op_id);
478 fs::write(&snapshot_path, &materialized_bytes)?;
479
480 let hash = {
481 let digest = Sha256::digest(&materialized_bytes);
482 format!("sha256:{:x}", digest)
483 };
484
485 let mut manifest = SnapshotManifest::load(&self.snapshot_manifest_path())
486 .unwrap_or_else(|_| SnapshotManifest::new(self.session_id.clone()));
487
488 manifest.add_entry(SnapshotEntry {
489 op_id: op_id.to_string(),
490 file_name: format!("{}.xlsx", op_id),
491 file_hash: hash,
492 created_at: chrono::Utc::now(),
493 event_count,
494 });
495
496 manifest.save(&self.snapshot_manifest_path())
497 }
498
499 pub fn materialize(&self) -> Result<Vec<u8>> {
503 let head = self.read_head()?;
504 match head {
505 Some(op_id) => self.materialize_at(&op_id),
506 None => {
507 fs::read(self.base_path())
509 .context("failed to read base workbook for materialization")
510 }
511 }
512 }
513
514 pub fn materialize_at(&self, target_op_id: &str) -> Result<Vec<u8>> {
516 let events = self.read_events()?;
517 let event_ids: Vec<String> = events.iter().map(|e| e.op_id.clone()).collect();
518
519 let manifest = SnapshotManifest::load(&self.snapshot_manifest_path())
521 .unwrap_or_else(|_| SnapshotManifest::new(self.session_id.clone()));
522
523 let (start_bytes, replay_events) =
524 if let Some(snap) = manifest.nearest_snapshot(target_op_id, &event_ids) {
525 let snap_path = self.dir.join("snapshots").join(&snap.file_name);
526 let bytes = fs::read(&snap_path)
527 .with_context(|| format!("failed to read snapshot: {}", snap_path.display()))?;
528 let after = events
529 .iter()
530 .skip_while(|e| e.op_id != snap.op_id)
531 .skip(1) .take_while(|e| {
533 let dominated = event_ids
534 .iter()
535 .position(|id| id == &e.op_id)
536 .unwrap_or(usize::MAX);
537 let target_pos = event_ids
538 .iter()
539 .position(|id| id == target_op_id)
540 .unwrap_or(0);
541 dominated <= target_pos
542 })
543 .cloned()
544 .collect::<Vec<_>>();
545 (bytes, after)
546 } else {
547 let bytes = fs::read(self.base_path()).context("failed to read base workbook")?;
548 let up_to = events
549 .iter()
550 .take_while(|e| {
551 let pos = event_ids
552 .iter()
553 .position(|id| id == &e.op_id)
554 .unwrap_or(usize::MAX);
555 let target_pos = event_ids
556 .iter()
557 .position(|id| id == target_op_id)
558 .unwrap_or(0);
559 pos <= target_pos
560 })
561 .cloned()
562 .collect::<Vec<_>>();
563 (bytes, up_to)
564 };
565
566 if replay_events.is_empty() {
567 return Ok(start_bytes);
568 }
569
570 use crate::core::session::WorkbookSession;
572 let mut session = WorkbookSession::from_bytes(&start_bytes)?;
573
574 for event in &replay_events {
575 replay_event_on_session(&mut session, event)?;
576 }
577
578 session.to_bytes()
579 }
580
581 fn acquire_lock(&self) -> Result<SessionLock> {
584 let lock_path = self.lock_path();
585 if lock_path.exists() {
586 if let Ok(metadata) = fs::metadata(&lock_path)
588 && let Ok(modified) = metadata.modified()
589 {
590 if modified.elapsed().unwrap_or_default() > std::time::Duration::from_secs(60) {
591 let _ = fs::remove_file(&lock_path);
593 } else {
594 bail!(
595 "session is locked by another writer (lock file: {})",
596 lock_path.display()
597 );
598 }
599 }
600 }
601
602 let lock_content = serde_json::json!({
603 "pid": std::process::id(),
604 "acquired_at": chrono::Utc::now().to_rfc3339(),
605 });
606 fs::write(&lock_path, lock_content.to_string())?;
607 Ok(SessionLock { path: lock_path })
608 }
609
610 pub fn meta(&self) -> Result<SessionMeta> {
612 let content = fs::read_to_string(self.dir.join("session.json"))
613 .context("failed to read session.json")?;
614 serde_json::from_str(&content).context("failed to parse session.json")
615 }
616}
617
618struct SessionLock {
620 path: PathBuf,
621}
622
623impl Drop for SessionLock {
624 fn drop(&mut self) {
625 let _ = fs::remove_file(&self.path);
626 }
627}
628
629fn replay_via_temp_file<F>(
639 session: &mut crate::core::session::WorkbookSession,
640 apply_fn: F,
641) -> Result<()>
642where
643 F: FnOnce(&Path) -> Result<()>,
644{
645 let tmp = session.to_temp_file()?;
646 apply_fn(tmp.path())?;
647 session.reload_from_path(tmp.path())?;
648 Ok(())
649}
650
651fn replay_event_on_session(
657 session: &mut crate::core::session::WorkbookSession,
658 event: &OpEvent,
659) -> Result<()> {
660 use crate::core::session::SessionTransformOp;
661 use crate::model::diagnostics::FormulaParsePolicy;
662 use crate::tools::fork::{
663 ApplyFormulaPatternOpInput, ColumnSizeOp, ReplaceInFormulasOp, StructureOp, StyleOp,
664 TransformOp, apply_column_size_ops_to_file, apply_formula_pattern_ops_to_file,
665 apply_replace_in_formulas_to_file, apply_structure_ops_to_file, apply_style_ops_to_file,
666 apply_transform_ops_to_file,
667 };
668 use crate::tools::rules_batch::{RulesOp, apply_rules_ops_to_file};
669 use crate::tools::sheet_layout::{SheetLayoutOp, apply_sheet_layout_ops_to_file};
670
671 let kind_str = &event.kind.0;
672 let payload = &event.payload;
673
674 match kind_str.as_str() {
675 "transform.write_matrix" | "edit.batch" => {
677 let sheet_name = payload
678 .get("sheet_name")
679 .and_then(|v| v.as_str())
680 .unwrap_or("")
681 .to_string();
682 let anchor = payload
683 .get("anchor")
684 .and_then(|v| v.as_str())
685 .unwrap_or("A1")
686 .to_string();
687 let overwrite_formulas = payload
688 .get("overwrite_formulas")
689 .and_then(|v| v.as_bool())
690 .unwrap_or(false);
691
692 if let Some(rows_val) = payload.get("rows") {
693 let rows: Vec<Vec<Option<crate::core::session::SessionMatrixCell>>> =
694 serde_json::from_value(rows_val.clone()).unwrap_or_default();
695
696 let ops = vec![SessionTransformOp::WriteMatrix {
697 sheet_name,
698 anchor,
699 rows,
700 overwrite_formulas,
701 }];
702 session.apply_ops(&ops)?;
703 }
704 }
705
706 k if k.starts_with("structure.") => {
708 let ops: Vec<StructureOp> = deserialize_ops_array(payload)?;
709 let policy = FormulaParsePolicy::default();
710 replay_via_temp_file(session, |path| {
711 apply_structure_ops_to_file(path, &ops, policy)?;
712 Ok(())
713 })?;
714 }
715
716 "transform.clear_range" | "transform.fill_range" | "transform.replace_in_range" => {
718 let ops: Vec<TransformOp> = deserialize_ops_array(payload)?;
719 replay_via_temp_file(session, |path| {
720 apply_transform_ops_to_file(path, &ops)?;
721 Ok(())
722 })?;
723 }
724
725 "style.apply" => {
727 let ops: Vec<StyleOp> = deserialize_ops_array(payload)?;
728 replay_via_temp_file(session, |path| {
729 apply_style_ops_to_file(path, &ops)?;
730 Ok(())
731 })?;
732 }
733
734 "formula.apply_pattern" => {
736 let ops: Vec<ApplyFormulaPatternOpInput> = deserialize_ops_array(payload)?;
737 replay_via_temp_file(session, |path| {
738 apply_formula_pattern_ops_to_file(path, &ops)?;
739 Ok(())
740 })?;
741 }
742
743 "formula.replace_in_formulas" => {
745 let op: ReplaceInFormulasOp = serde_json::from_value(payload.clone())
746 .context("failed to deserialize replace_in_formulas payload")?;
747 let policy = FormulaParsePolicy::default();
748 replay_via_temp_file(session, |path| {
749 apply_replace_in_formulas_to_file(path, &op, policy)?;
750 Ok(())
751 })?;
752 }
753
754 "column.size" => {
756 let sheet_name = payload
757 .get("sheet_name")
758 .and_then(|v| v.as_str())
759 .unwrap_or("")
760 .to_string();
761 let ops: Vec<ColumnSizeOp> = deserialize_ops_array(payload)?;
762 replay_via_temp_file(session, |path| {
763 apply_column_size_ops_to_file(path, &sheet_name, &ops)?;
764 Ok(())
765 })?;
766 }
767
768 "layout.apply" => {
770 let ops: Vec<SheetLayoutOp> = deserialize_ops_array(payload)?;
771 replay_via_temp_file(session, |path| {
772 apply_sheet_layout_ops_to_file(path, &ops)?;
773 Ok(())
774 })?;
775 }
776
777 "rules.apply" => {
779 let ops: Vec<RulesOp> = deserialize_ops_array(payload)?;
780 let policy = FormulaParsePolicy::default();
781 replay_via_temp_file(session, |path| {
782 apply_rules_ops_to_file(path, &ops, policy)?;
783 Ok(())
784 })?;
785 }
786
787 "name.define" => {
789 let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
790 let refers_to = payload
791 .get("refers_to")
792 .and_then(|v| v.as_str())
793 .unwrap_or("");
794 let scope = payload.get("scope").and_then(|v| v.as_str());
795 let scope_sheet = payload.get("scope_sheet_name").and_then(|v| v.as_str());
796 session.define_name(name, refers_to, scope, scope_sheet)?;
797 }
798 "name.update" => {
799 let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
800 let refers_to = payload.get("refers_to").and_then(|v| v.as_str());
801 let scope = payload.get("scope").and_then(|v| v.as_str());
802 let scope_sheet = payload.get("scope_sheet_name").and_then(|v| v.as_str());
803 session.update_name(name, refers_to, scope, scope_sheet)?;
804 }
805 "name.delete" => {
806 let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
807 let scope = payload.get("scope").and_then(|v| v.as_str());
808 let scope_sheet = payload.get("scope_sheet_name").and_then(|v| v.as_str());
809 session.delete_name(name, scope, scope_sheet)?;
810 }
811
812 "session.materialize" => {
814 }
816
817 _ => {
818 tracing::warn!(
819 "replay: unsupported event kind '{}' (op_id: {}), skipping",
820 kind_str,
821 event.op_id
822 );
823 }
824 }
825
826 Ok(())
827}
828
829fn deserialize_ops_array<T: serde::de::DeserializeOwned>(
835 payload: &serde_json::Value,
836) -> Result<Vec<T>> {
837 if let Some(ops_val) = payload.get("ops") {
838 serde_json::from_value(ops_val.clone())
839 .context("failed to deserialize ops array from payload")
840 } else {
841 let single: T = serde_json::from_value(payload.clone())
843 .context("failed to deserialize single op from payload")?;
844 Ok(vec![single])
845 }
846}
847
848fn evaluate_cell_matches(
856 session: &crate::core::session::WorkbookSession,
857 cell_matches: &[crate::core::events::CellMatch],
858) -> Result<Vec<String>> {
859 let mut violations = Vec::new();
860
861 for cm in cell_matches {
862 let (sheet_name, cell_ref) = if let Some(pos) = cm.address.rfind('!') {
863 (&cm.address[..pos], &cm.address[pos + 1..])
864 } else {
865 return Err(anyhow!(
866 "cell_matches address '{}' missing Sheet!Cell notation",
867 cm.address
868 ));
869 };
870
871 let sheet = match session.sheet_by_name(sheet_name) {
872 Some(s) => s,
873 None => {
874 violations.push(format!("{}: sheet '{}' not found", cm.address, sheet_name));
875 continue;
876 }
877 };
878
879 let actual_value = sheet
880 .get_cell(cell_ref)
881 .map(|c| {
882 let val = c.get_value();
883 if val.is_empty() {
884 serde_json::Value::Null
885 } else if let Ok(n) = val.parse::<f64>() {
886 serde_json::json!(n)
887 } else if val == "TRUE" || val == "true" {
888 serde_json::Value::Bool(true)
889 } else if val == "FALSE" || val == "false" {
890 serde_json::Value::Bool(false)
891 } else {
892 serde_json::Value::String(val.to_string())
893 }
894 })
895 .unwrap_or(serde_json::Value::Null);
896
897 let expected = &cm.value;
898
899 let matches = match (expected, &actual_value) {
901 (serde_json::Value::Number(e), serde_json::Value::Number(a)) => {
902 let ef = e.as_f64().unwrap_or(f64::NAN);
903 let af = a.as_f64().unwrap_or(f64::NAN);
904 (ef - af).abs() < 1e-9
905 }
906 (serde_json::Value::Null, serde_json::Value::Null) => true,
907 _ => expected == &actual_value,
908 };
909
910 if !matches {
911 violations.push(format!(
912 "{}: expected {}, got {}",
913 cm.address, expected, actual_value
914 ));
915 }
916 }
917
918 Ok(violations)
919}
920
921fn compute_workbook_hash(bytes: &[u8]) -> String {
923 format!("sha256:{:x}", Sha256::digest(bytes))
924}
925
926fn make_session_id() -> String {
931 use std::time::{SystemTime, UNIX_EPOCH};
932 let ts = SystemTime::now()
933 .duration_since(UNIX_EPOCH)
934 .unwrap_or_default()
935 .as_millis();
936 let rand_suffix: u32 = rand::random();
937 format!("sess_{:010x}_{:06x}", ts, rand_suffix & 0xFFFFFF)
938}
939
940#[cfg(test)]
945mod tests {
946 use super::*;
947 use crate::core::events::{Actor, OpEvent, OpKind};
948 use serde_json::json;
949 use tempfile::TempDir;
950
951 fn test_actor() -> Actor {
952 Actor {
953 id: "test:agent".to_string(),
954 run_id: None,
955 source: "test".to_string(),
956 }
957 }
958
959 fn create_test_base(dir: &Path) -> PathBuf {
960 let base_path = dir.join("base.xlsx");
961 let workbook = umya_spreadsheet::new_file();
962 umya_spreadsheet::writer::xlsx::write(&workbook, &base_path).unwrap();
963 base_path
964 }
965
966 #[test]
967 fn session_lifecycle_create_and_list() {
968 let tmp = TempDir::new().unwrap();
969 let base = create_test_base(tmp.path());
970
971 let store = SessionStore::open(tmp.path()).unwrap();
972 let handle = store.create_session(&base, Some("Test Session")).unwrap();
973
974 let sessions = store.list_sessions().unwrap();
975 assert_eq!(sessions.len(), 1);
976 assert_eq!(sessions[0], handle.session_id);
977
978 let meta = handle.meta().unwrap();
979 assert_eq!(meta.label.as_deref(), Some("Test Session"));
980 }
981
982 #[test]
983 fn session_append_and_read_events() {
984 let tmp = TempDir::new().unwrap();
985 let base = create_test_base(tmp.path());
986
987 let store = SessionStore::open(tmp.path()).unwrap();
988 let handle = store.create_session(&base, None).unwrap();
989
990 let event1 = OpEvent::new(
992 handle.session_id.clone(),
993 None, test_actor(),
995 OpKind::edit_batch(),
996 json!({"cell": "A1", "value": 42}),
997 );
998 let op1_id = event1.op_id.clone();
999 handle.append_event(event1).unwrap();
1000
1001 assert_eq!(
1002 handle.read_head().unwrap().as_deref(),
1003 Some(op1_id.as_str())
1004 );
1005
1006 let event2 = OpEvent::new(
1008 handle.session_id.clone(),
1009 Some(op1_id.clone()),
1010 test_actor(),
1011 OpKind::edit_batch(),
1012 json!({"cell": "B2", "value": 100}),
1013 );
1014 let op2_id = event2.op_id.clone();
1015 handle.append_event(event2).unwrap();
1016
1017 assert_eq!(
1018 handle.read_head().unwrap().as_deref(),
1019 Some(op2_id.as_str())
1020 );
1021
1022 let events = handle.read_events().unwrap();
1023 assert_eq!(events.len(), 2);
1024 }
1025
1026 #[test]
1027 fn session_undo_redo() {
1028 let tmp = TempDir::new().unwrap();
1029 let base = create_test_base(tmp.path());
1030
1031 let store = SessionStore::open(tmp.path()).unwrap();
1032 let handle = store.create_session(&base, None).unwrap();
1033
1034 let event1 = OpEvent::new(
1035 handle.session_id.clone(),
1036 None,
1037 test_actor(),
1038 OpKind::edit_batch(),
1039 json!({"cell": "A1"}),
1040 );
1041 let op1_id = event1.op_id.clone();
1042 handle.append_event(event1).unwrap();
1043
1044 let event2 = OpEvent::new(
1045 handle.session_id.clone(),
1046 Some(op1_id.clone()),
1047 test_actor(),
1048 OpKind::edit_batch(),
1049 json!({"cell": "B2"}),
1050 );
1051 let op2_id = event2.op_id.clone();
1052 handle.append_event(event2).unwrap();
1053
1054 let undone = handle.undo().unwrap();
1056 assert_eq!(undone.as_deref(), Some(op1_id.as_str()));
1057 assert_eq!(
1058 handle.read_head().unwrap().as_deref(),
1059 Some(op1_id.as_str())
1060 );
1061
1062 let undone = handle.undo().unwrap();
1064 assert!(undone.is_none());
1065 assert!(handle.read_head().unwrap().is_none());
1066
1067 let redone = handle.redo().unwrap();
1069 assert_eq!(redone.as_deref(), Some(op1_id.as_str()));
1070
1071 let redone = handle.redo().unwrap();
1073 assert_eq!(redone.as_deref(), Some(op2_id.as_str()));
1074 }
1075
1076 #[test]
1077 fn session_cas_conflict() {
1078 let tmp = TempDir::new().unwrap();
1079 let base = create_test_base(tmp.path());
1080
1081 let store = SessionStore::open(tmp.path()).unwrap();
1082 let handle = store.create_session(&base, None).unwrap();
1083
1084 let event1 = OpEvent::new(
1085 handle.session_id.clone(),
1086 None,
1087 test_actor(),
1088 OpKind::edit_batch(),
1089 json!({}),
1090 );
1091 let _op1_id = event1.op_id.clone();
1092 handle.append_event(event1).unwrap();
1093
1094 let bad_event = OpEvent::new(
1096 handle.session_id.clone(),
1097 None, test_actor(),
1099 OpKind::edit_batch(),
1100 json!({}),
1101 );
1102 let result = handle.append_event(bad_event);
1103 assert!(result.is_err());
1104 assert!(result.unwrap_err().to_string().contains("CAS conflict"));
1105 }
1106
1107 #[test]
1108 fn session_branching() {
1109 let tmp = TempDir::new().unwrap();
1110 let base = create_test_base(tmp.path());
1111
1112 let store = SessionStore::open(tmp.path()).unwrap();
1113 let handle = store.create_session(&base, None).unwrap();
1114
1115 let event1 = OpEvent::new(
1116 handle.session_id.clone(),
1117 None,
1118 test_actor(),
1119 OpKind::edit_batch(),
1120 json!({}),
1121 );
1122 let op1_id = event1.op_id.clone();
1123 handle.append_event(event1).unwrap();
1124
1125 handle
1127 .create_branch("alt-scenario", Some(&op1_id), Some("Alternative"))
1128 .unwrap();
1129
1130 let branches = handle.list_branches().unwrap();
1131 assert_eq!(branches.len(), 2);
1132
1133 handle.switch_branch("alt-scenario").unwrap();
1135 assert_eq!(handle.current_branch().unwrap(), "alt-scenario");
1136 }
1137
1138 #[test]
1139 fn session_materialize_at_base() {
1140 let tmp = TempDir::new().unwrap();
1141 let base = create_test_base(tmp.path());
1142
1143 let store = SessionStore::open(tmp.path()).unwrap();
1144 let handle = store.create_session(&base, None).unwrap();
1145
1146 let bytes = handle.materialize().unwrap();
1148 assert!(!bytes.is_empty());
1149 }
1150
1151 #[test]
1152 fn session_delete() {
1153 let tmp = TempDir::new().unwrap();
1154 let base = create_test_base(tmp.path());
1155
1156 let store = SessionStore::open(tmp.path()).unwrap();
1157 let handle = store.create_session(&base, None).unwrap();
1158 let sid = handle.session_id.clone();
1159
1160 store.delete_session(&sid).unwrap();
1161 assert!(store.list_sessions().unwrap().is_empty());
1162 }
1163}