Skip to main content

spreadsheet_kit/core/
session_store.rs

1//! Persistent session storage backed by project-local `.asp/` directories.
2//!
3//! Layout:
4//! ```text
5//! .asp/
6//!   sessions/
7//!     <session_id>/
8//!       base.xlsx                 # Immutable base file
9//!       events.jsonl              # Append-only OpEvent log
10//!       HEAD                      # Active op_id
11//!       CURRENT_BRANCH            # Branch name pointer
12//!       branches.json             # Branch metadata
13//!       staged/
14//!         <staged_id>.json        # Staged op payloads + computed impact
15//!       snapshots/
16//!         manifest.json           # Snapshot index
17//!         <op_id>.xlsx            # Materialized snapshot files
18//!       locks/
19//!         session.lock            # Exclusive apply lock
20//! ```
21
22use 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
31/// Default snapshot interval (create a snapshot every N events).
32const SNAPSHOT_INTERVAL: usize = 10;
33
34/// Maximum number of concurrent sessions.
35const MAX_SESSIONS: usize = 50;
36
37// ---------------------------------------------------------------------------
38// SessionStore
39// ---------------------------------------------------------------------------
40
41/// Persistent session store managing `.asp/sessions/` directories.
42pub struct SessionStore {
43    root: PathBuf,
44}
45
46impl SessionStore {
47    /// Open or create a session store at the given workspace root.
48    /// The `.asp/sessions/` directory is created if it doesn't exist.
49    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    /// Create a new session from a base workbook file.
57    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        // Copy base file (immutable)
76        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        // Initialize empty binlog
86        let binlog_path = session_dir.join("events.jsonl");
87        fs::write(&binlog_path, "")?;
88
89        // Initialize HEAD (empty = at base, no events applied)
90        fs::write(session_dir.join("HEAD"), "")?;
91
92        // Initialize branch pointer
93        fs::write(session_dir.join("CURRENT_BRANCH"), "main")?;
94
95        // Initialize branches.json
96        let branches = BranchesFile::new();
97        branches.save(&session_dir.join("branches.json"))?;
98
99        // Initialize snapshot manifest
100        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        // Create staged and locks directories
106        fs::create_dir_all(session_dir.join("staged"))?;
107        fs::create_dir_all(session_dir.join("locks"))?;
108
109        // Write session metadata
110        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    /// Open an existing session by ID.
123    pub fn open_session(&self, session_id: &str) -> Result<SessionHandle> {
124        SessionHandle::open(&self.root, session_id)
125    }
126
127    /// List all session IDs.
128    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    /// Delete a session and all its artifacts.
146    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// ---------------------------------------------------------------------------
157// SessionMeta
158// ---------------------------------------------------------------------------
159
160#[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
168// ---------------------------------------------------------------------------
169// SessionHandle
170// ---------------------------------------------------------------------------
171
172/// Handle for interacting with a single persistent session.
173pub 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    // -- Paths --
191
192    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    // -- HEAD management --
233
234    /// Read the current HEAD op_id. Returns None if HEAD is empty (at base).
235    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    /// Set the HEAD op_id.
246    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    /// Clear HEAD back to base state.
251    pub fn clear_head(&self) -> Result<()> {
252        fs::write(self.head_path(), "").context("failed to clear HEAD")
253    }
254
255    // -- Branch management --
256
257    /// Read the current branch name.
258    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    /// Switch to a different branch.
265    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        // Update HEAD to the branch tip
275        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    /// Create a new branch forking from the given op_id.
285    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    /// List all branches.
308    pub fn list_branches(&self) -> Result<Vec<BranchInfo>> {
309        let branches = BranchesFile::load(&self.branches_path())?;
310        Ok(branches.branches)
311    }
312
313    // -- Event log --
314
315    /// Append an event to the binlog and advance HEAD + branch tip.
316    /// This is the atomic apply operation.
317    pub fn append_event(&self, mut event: OpEvent) -> Result<()> {
318        // Acquire exclusive lock
319        let _lock = self.acquire_lock()?;
320
321        // Validate HEAD matches expected parent
322        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        // Evaluate preconditions (cell_matches + workbook_hash_before)
332        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        // Seal the event hash
362        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        // Append to binlog
372        let writer = BinlogWriter::open(self.binlog_path())?;
373        writer.append(&event)?;
374
375        // Advance HEAD
376        self.write_head(&event.op_id)?;
377
378        // Update branch tip
379        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        // Check if we should create a snapshot
387        let reader = BinlogReader::open(self.binlog_path())?;
388        let event_count = reader.count()?;
389        if event_count > 0 && event_count % SNAPSHOT_INTERVAL == 0 {
390            // Snapshot creation is best-effort; don't fail the append
391            let _ = self.create_snapshot(&event.op_id, event_count);
392        }
393
394        Ok(())
395    }
396
397    /// Read all events in the binlog.
398    pub fn read_events(&self) -> Result<Vec<OpEvent>> {
399        let reader = BinlogReader::open(self.binlog_path())?;
400        reader.read_all()
401    }
402
403    /// Read events after a given op_id.
404    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    /// Get the event log for display (session log).
410    pub fn log(&self) -> Result<Vec<OpEvent>> {
411        self.read_events()
412    }
413
414    // -- Undo / Redo --
415
416    /// Move HEAD back one event (branch-local undo).
417    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    /// Move HEAD forward one event (branch-local redo).
442    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    /// Set HEAD to a specific op_id (checkout).
463    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    // -- Snapshots --
472
473    /// Create a snapshot at the given op_id.
474    fn create_snapshot(&self, op_id: &str, event_count: usize) -> Result<()> {
475        // Materialize the workbook at this point
476        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    // -- Materialization --
500
501    /// Materialize the workbook at the current HEAD by loading base + replaying events.
502    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                // At base: just return the base file
508                fs::read(self.base_path())
509                    .context("failed to read base workbook for materialization")
510            }
511        }
512    }
513
514    /// Materialize the workbook at a specific op_id.
515    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        // Check for nearest snapshot
520        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) // skip the snapshot event itself
532                    .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        // Open the workbook and replay events
571        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    // -- Locking --
582
583    fn acquire_lock(&self) -> Result<SessionLock> {
584        let lock_path = self.lock_path();
585        if lock_path.exists() {
586            // Check if lock is stale (>60 seconds old)
587            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                    // Stale lock, remove it
592                    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    /// Read session metadata.
611    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
618/// RAII lock guard that removes the lock file on drop.
619struct 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
629// ---------------------------------------------------------------------------
630// Event replay
631// ---------------------------------------------------------------------------
632
633/// Write session state to a temp file, apply a mutation via the provided
634/// closure, then reload the session from the mutated file.
635///
636/// This reuses the battle-tested file-based `apply_*_to_file()` functions
637/// and defers in-memory optimization to a future phase.
638fn 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
651/// Replay a single OpEvent on a WorkbookSession.
652///
653/// This is the core event-to-mutation mapping. Each OpKind is routed to the
654/// appropriate session method or file-based apply function (via temp-file
655/// round-trip).
656fn 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        // -- write_matrix / edit.batch (existing) --
676        "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        // -- Structure family (insert_rows, delete_rows, clone_row, etc.) --
707        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 family (clear_range, fill_range, replace_in_range) --
717        "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 family --
726        "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 pattern family --
735        "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        // -- Replace in formulas --
744        "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 sizing family --
755        "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        // -- Sheet layout family --
769        "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 family (data validation, conditional formatting) --
778        "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 family (direct session mutations, no temp file) --
788        "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 meta events --
813        "session.materialize" => {
814            // No-op — meta-event recorded for audit purposes.
815        }
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
829/// Deserialize an ops array from an event payload.
830///
831/// Tries `payload["ops"]` first (the standard `{"ops": [...]}` envelope used by
832/// batch commands). Falls back to wrapping the entire payload as a single-element
833/// vec for events that store a flat operation object.
834fn 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        // Single-op shorthand: wrap the entire payload into a one-element vec.
842        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
848// ---------------------------------------------------------------------------
849// Precondition evaluation
850// ---------------------------------------------------------------------------
851
852/// Evaluate `cell_matches` preconditions against current workbook state.
853///
854/// Returns a list of violation descriptions (empty = all passed).
855fn 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        // Compare with tolerance for numbers
900        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
921/// Compute a SHA-256 hash of raw workbook bytes.
922fn compute_workbook_hash(bytes: &[u8]) -> String {
923    format!("sha256:{:x}", Sha256::digest(bytes))
924}
925
926// ---------------------------------------------------------------------------
927// Helpers
928// ---------------------------------------------------------------------------
929
930fn 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// ---------------------------------------------------------------------------
941// Tests
942// ---------------------------------------------------------------------------
943
944#[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        // Append first event
991        let event1 = OpEvent::new(
992            handle.session_id.clone(),
993            None, // parent is None (first event, HEAD is empty)
994            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        // Append second event
1007        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        // Undo back to event1
1055        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        // Undo back to base
1063        let undone = handle.undo().unwrap();
1064        assert!(undone.is_none());
1065        assert!(handle.read_head().unwrap().is_none());
1066
1067        // Redo to event1
1068        let redone = handle.redo().unwrap();
1069        assert_eq!(redone.as_deref(), Some(op1_id.as_str()));
1070
1071        // Redo to event2
1072        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        // Try to append with wrong parent (CAS conflict)
1095        let bad_event = OpEvent::new(
1096            handle.session_id.clone(),
1097            None, // Wrong: should be Some(op1_id)
1098            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        // Create a branch
1126        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        // Switch to alt branch
1134        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        // Materialize with no events = base file
1147        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}