Skip to main content

spreadsheet_kit/core/
engine_bridge.rs

1//! Phase 4: Engine convergence bridge between OpEvent operations and
2//! Formualizer-native structural mutation APIs.
3//!
4//! This module provides the mapping layer for routing structural operations
5//! through `formualizer_eval::Engine` instead of the `umya` token-rewrite path.
6//! Currently, this is a stub/interface layer that documents the target API
7//! and provides the conversion types. Full integration requires the Formualizer
8//! engine to expose `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`
9//! APIs at the graph/store level.
10//!
11//! ## Migration Path
12//!
13//! 1. **Current state**: Structure ops mutate the workbook via `umya_spreadsheet`
14//!    and rewrite formula tokens via string manipulation in `apply_structure_ops_to_file`.
15//!
16//! 2. **Target state**: Structure ops mutate the Formualizer dependency graph first,
17//!    then serialize through `umya` only at materialization boundaries.
18//!
19//! 3. **Parity requirement**: `dry_run_impact` predictions must match applied
20//!    mutation results whether executing via `umya` or Formualizer paths.
21
22use crate::core::events::{DryRunImpact, OpEvent, ShiftedSpan};
23use serde::{Deserialize, Serialize};
24
25/// Describes a structural mutation to be applied through the engine.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub enum EngineStructureOp {
28    InsertRows {
29        sheet_name: String,
30        at_row: u32,
31        count: u32,
32    },
33    DeleteRows {
34        sheet_name: String,
35        start_row: u32,
36        count: u32,
37    },
38    InsertCols {
39        sheet_name: String,
40        at_col: u32,
41        count: u32,
42    },
43    DeleteCols {
44        sheet_name: String,
45        start_col: u32,
46        count: u32,
47    },
48}
49
50/// Result of applying a structural operation through the engine.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct EngineApplyResult {
53    pub formulas_rewritten: u64,
54    pub ref_errors_generated: u64,
55    pub shifted_spans: Vec<ShiftedSpan>,
56}
57
58/// Convert an OpEvent with a structural kind into an EngineStructureOp.
59///
60/// Returns `None` if the event kind is not a supported structural operation.
61pub fn op_event_to_engine_op(event: &OpEvent) -> Option<EngineStructureOp> {
62    let payload = &event.payload;
63    let kind = &event.kind.0;
64
65    match kind.as_str() {
66        "structure.insert_rows" => Some(EngineStructureOp::InsertRows {
67            sheet_name: payload.get("sheet_name")?.as_str()?.to_string(),
68            at_row: payload.get("at_row")?.as_u64()? as u32,
69            count: payload.get("count")?.as_u64().unwrap_or(1) as u32,
70        }),
71        "structure.delete_rows" => Some(EngineStructureOp::DeleteRows {
72            sheet_name: payload.get("sheet_name")?.as_str()?.to_string(),
73            start_row: payload.get("start_row")?.as_u64()? as u32,
74            count: payload.get("count")?.as_u64().unwrap_or(1) as u32,
75        }),
76        "structure.insert_cols" => Some(EngineStructureOp::InsertCols {
77            sheet_name: payload.get("sheet_name")?.as_str()?.to_string(),
78            at_col: payload.get("at_col")?.as_u64()? as u32,
79            count: payload.get("count")?.as_u64().unwrap_or(1) as u32,
80        }),
81        "structure.delete_cols" => Some(EngineStructureOp::DeleteCols {
82            sheet_name: payload.get("sheet_name")?.as_str()?.to_string(),
83            start_col: payload.get("start_col")?.as_u64()? as u32,
84            count: payload.get("count")?.as_u64().unwrap_or(1) as u32,
85        }),
86        _ => None,
87    }
88}
89
90/// Predict the impact of an engine structure operation.
91///
92/// This produces the same `DryRunImpact` structure used by the OpEvent staging
93/// system, but computed from the engine's dependency graph rather than from
94/// `umya` token analysis. This enables stage/apply parity enforcement.
95pub fn predict_engine_impact(op: &EngineStructureOp) -> DryRunImpact {
96    let shifted_span = match op {
97        EngineStructureOp::InsertRows {
98            sheet_name,
99            at_row,
100            count,
101        } => ShiftedSpan {
102            op_index: 0,
103            sheet_name: sheet_name.clone(),
104            axis: "row".to_string(),
105            at: *at_row,
106            count: *count,
107            direction: "insert".to_string(),
108            description: format!("rows {}..inf shift +{}", at_row, count),
109        },
110        EngineStructureOp::DeleteRows {
111            sheet_name,
112            start_row,
113            count,
114        } => ShiftedSpan {
115            op_index: 0,
116            sheet_name: sheet_name.clone(),
117            axis: "row".to_string(),
118            at: *start_row,
119            count: *count,
120            direction: "delete".to_string(),
121            description: format!("rows {}..inf shift -{}", start_row, count),
122        },
123        EngineStructureOp::InsertCols {
124            sheet_name,
125            at_col,
126            count,
127        } => ShiftedSpan {
128            op_index: 0,
129            sheet_name: sheet_name.clone(),
130            axis: "col".to_string(),
131            at: *at_col,
132            count: *count,
133            direction: "insert".to_string(),
134            description: format!("cols {}..inf shift +{}", at_col, count),
135        },
136        EngineStructureOp::DeleteCols {
137            sheet_name,
138            start_col,
139            count,
140        } => ShiftedSpan {
141            op_index: 0,
142            sheet_name: sheet_name.clone(),
143            axis: "col".to_string(),
144            at: *start_col,
145            count: *count,
146            direction: "delete".to_string(),
147            description: format!("cols {}..inf shift -{}", start_col, count),
148        },
149    };
150
151    DryRunImpact {
152        cells_changed: 0, // Will be computed by engine during actual apply
153        formulas_rewritten: 0,
154        shifted_spans: vec![shifted_span],
155        ref_errors_generated: 0,
156        warnings: Vec::new(),
157        boundary_warnings: Vec::new(),
158    }
159}
160
161// ---------------------------------------------------------------------------
162// ChangeEvent ↔ OpEvent mapping (Phase 4-B)
163// ---------------------------------------------------------------------------
164
165/// Maps a Formualizer `ChangeEvent` back to an OpEvent-compatible payload.
166///
167/// This is the reverse mapping: after the engine applies a structural change,
168/// it produces `ChangeEvent` journal entries. This function converts those
169/// back to `OpEvent` payload format for storage in the binlog.
170///
171/// Currently a placeholder — the actual mapping requires access to
172/// `formualizer_eval::engine::ChangeEvent` which varies by engine version.
173pub fn change_event_to_op_payload(
174    _engine_event_kind: &str,
175    _engine_event_data: &serde_json::Value,
176) -> serde_json::Value {
177    // Placeholder: in the target architecture, this maps engine-native change
178    // events back to OpEvent payload format for unified replay.
179    serde_json::json!({
180        "engine_native": true,
181        "mapping": "placeholder"
182    })
183}
184
185// ---------------------------------------------------------------------------
186// Tests
187// ---------------------------------------------------------------------------
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::core::events::{Actor, OpEvent, OpKind};
193    use serde_json::json;
194
195    #[test]
196    fn convert_insert_rows_event() {
197        let event = OpEvent::new(
198            "sess_test".to_string(),
199            None,
200            Actor {
201                id: "test".to_string(),
202                run_id: None,
203                source: "test".to_string(),
204            },
205            OpKind::structure_insert_rows(),
206            json!({
207                "sheet_name": "Sheet1",
208                "at_row": 5,
209                "count": 3
210            }),
211        );
212
213        let engine_op = op_event_to_engine_op(&event).unwrap();
214        match engine_op {
215            EngineStructureOp::InsertRows {
216                sheet_name,
217                at_row,
218                count,
219            } => {
220                assert_eq!(sheet_name, "Sheet1");
221                assert_eq!(at_row, 5);
222                assert_eq!(count, 3);
223            }
224            _ => panic!("expected InsertRows"),
225        }
226    }
227
228    #[test]
229    fn predict_insert_rows_impact() {
230        let op = EngineStructureOp::InsertRows {
231            sheet_name: "Sheet1".to_string(),
232            at_row: 10,
233            count: 2,
234        };
235        let impact = predict_engine_impact(&op);
236        assert_eq!(impact.shifted_spans.len(), 1);
237        assert_eq!(impact.shifted_spans[0].axis, "row");
238        assert_eq!(impact.shifted_spans[0].at, 10);
239        assert_eq!(impact.shifted_spans[0].count, 2);
240        assert_eq!(impact.shifted_spans[0].direction, "insert");
241    }
242
243    #[test]
244    fn non_structural_event_returns_none() {
245        let event = OpEvent::new(
246            "sess_test".to_string(),
247            None,
248            Actor {
249                id: "test".to_string(),
250                run_id: None,
251                source: "test".to_string(),
252            },
253            OpKind::edit_batch(),
254            json!({"cell": "A1"}),
255        );
256
257        assert!(op_event_to_engine_op(&event).is_none());
258    }
259}