1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11
12pub const SCHEMA_VERSION: &str = "ops.v1";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct OpEvent {
22 pub schema_version: String,
24
25 pub op_id: String,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub parent_id: Option<String>,
31
32 pub session_id: String,
34
35 pub timestamp: DateTime<Utc>,
37
38 pub actor: Actor,
40
41 pub kind: OpKind,
43
44 pub payload: serde_json::Value,
46
47 pub canonical_payload_hash: String,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub preconditions: Option<Preconditions>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub dry_run_impact: Option<DryRunImpact>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub apply_result: Option<ApplyResult>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub prev_event_hash: Option<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub event_hash: Option<String>,
69
70 #[serde(flatten)]
72 pub extra: BTreeMap<String, serde_json::Value>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Actor {
82 pub id: String,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub run_id: Option<String>,
88
89 #[serde(default = "default_source")]
91 pub source: String,
92}
93
94fn default_source() -> String {
95 "cli".to_string()
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(transparent)]
108pub struct OpKind(pub String);
109
110impl OpKind {
111 pub fn new(namespace: &str, action: &str) -> Self {
112 Self(format!("{}.{}", namespace, action))
113 }
114
115 pub fn namespace(&self) -> &str {
116 self.0.split('.').next().unwrap_or(&self.0)
117 }
118
119 pub fn action(&self) -> &str {
120 self.0.split('.').nth(1).unwrap_or("")
121 }
122
123 pub fn structure_insert_rows() -> Self {
125 Self::new("structure", "insert_rows")
126 }
127 pub fn structure_delete_rows() -> Self {
128 Self::new("structure", "delete_rows")
129 }
130 pub fn structure_insert_cols() -> Self {
131 Self::new("structure", "insert_cols")
132 }
133 pub fn structure_delete_cols() -> Self {
134 Self::new("structure", "delete_cols")
135 }
136 pub fn structure_clone_row() -> Self {
137 Self::new("structure", "clone_row")
138 }
139 pub fn structure_merge_cells() -> Self {
140 Self::new("structure", "merge_cells")
141 }
142 pub fn structure_unmerge_cells() -> Self {
143 Self::new("structure", "unmerge_cells")
144 }
145 pub fn structure_rename_sheet() -> Self {
146 Self::new("structure", "rename_sheet")
147 }
148 pub fn structure_create_sheet() -> Self {
149 Self::new("structure", "create_sheet")
150 }
151 pub fn structure_delete_sheet() -> Self {
152 Self::new("structure", "delete_sheet")
153 }
154 pub fn structure_copy_range() -> Self {
155 Self::new("structure", "copy_range")
156 }
157 pub fn structure_move_range() -> Self {
158 Self::new("structure", "move_range")
159 }
160
161 pub fn transform_clear_range() -> Self {
163 Self::new("transform", "clear_range")
164 }
165 pub fn transform_fill_range() -> Self {
166 Self::new("transform", "fill_range")
167 }
168 pub fn transform_replace_in_range() -> Self {
169 Self::new("transform", "replace_in_range")
170 }
171 pub fn transform_write_matrix() -> Self {
172 Self::new("transform", "write_matrix")
173 }
174
175 pub fn style_apply() -> Self {
177 Self::new("style", "apply")
178 }
179
180 pub fn formula_apply_pattern() -> Self {
182 Self::new("formula", "apply_pattern")
183 }
184 pub fn formula_replace_in_formulas() -> Self {
185 Self::new("formula", "replace_in_formulas")
186 }
187
188 pub fn column_size() -> Self {
190 Self::new("column", "size")
191 }
192
193 pub fn layout_apply() -> Self {
195 Self::new("layout", "apply")
196 }
197
198 pub fn rules_apply() -> Self {
200 Self::new("rules", "apply")
201 }
202
203 pub fn name_define() -> Self {
205 Self::new("name", "define")
206 }
207 pub fn name_update() -> Self {
208 Self::new("name", "update")
209 }
210 pub fn name_delete() -> Self {
211 Self::new("name", "delete")
212 }
213
214 pub fn edit_batch() -> Self {
216 Self::new("edit", "batch")
217 }
218
219 pub fn import_range() -> Self {
221 Self::new("import", "range")
222 }
223 pub fn import_grid() -> Self {
224 Self::new("import", "grid")
225 }
226
227 pub fn session_materialize() -> Self {
229 Self::new("session", "materialize")
230 }
231}
232
233impl std::fmt::Display for OpKind {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.write_str(&self.0)
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Preconditions {
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub cell_matches: Vec<CellMatch>,
249
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub workbook_hash_before: Option<String>,
253
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub head_at_stage: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CellMatch {
262 pub address: String,
264 pub value: serde_json::Value,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct DryRunImpact {
275 pub cells_changed: u64,
276 pub formulas_rewritten: u64,
277
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub shifted_spans: Vec<ShiftedSpan>,
280
281 pub ref_errors_generated: u64,
282
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub warnings: Vec<String>,
285
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub boundary_warnings: Vec<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ShiftedSpan {
293 pub op_index: usize,
294 pub sheet_name: String,
295 pub axis: String,
297 pub at: u32,
298 pub count: u32,
299 pub direction: String,
301 pub description: String,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ApplyResult {
311 pub status: ApplyStatus,
313 pub duration_ms: u64,
314
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub warnings: Vec<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub workbook_hash_after: Option<String>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum ApplyStatus {
326 Applied,
327 Rejected,
328 Superseded,
329}
330
331pub fn canonical_payload_hash(payload: &serde_json::Value) -> String {
339 let canonical = canonical_json(payload);
340 let hash = Sha256::digest(canonical.as_bytes());
341 format!("sha256:{:x}", hash)
342}
343
344fn canonical_json(value: &serde_json::Value) -> String {
346 match value {
347 serde_json::Value::Object(map) => {
348 let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
349 sorted.sort_by_key(|(k, _)| *k);
350 let entries: Vec<String> = sorted
351 .into_iter()
352 .map(|(k, v)| {
353 format!(
354 "{}:{}",
355 serde_json::to_string(k).unwrap(),
356 canonical_json(v)
357 )
358 })
359 .collect();
360 format!("{{{}}}", entries.join(","))
361 }
362 serde_json::Value::Array(arr) => {
363 let entries: Vec<String> = arr.iter().map(canonical_json).collect();
364 format!("[{}]", entries.join(","))
365 }
366 _ => serde_json::to_string(value).unwrap_or_default(),
367 }
368}
369
370impl OpEvent {
375 pub fn new(
377 session_id: String,
378 parent_id: Option<String>,
379 actor: Actor,
380 kind: OpKind,
381 payload: serde_json::Value,
382 ) -> Self {
383 let hash = canonical_payload_hash(&payload);
384 let op_id = make_op_id();
385 Self {
386 schema_version: SCHEMA_VERSION.to_string(),
387 op_id,
388 parent_id,
389 session_id,
390 timestamp: Utc::now(),
391 actor,
392 kind,
393 payload,
394 canonical_payload_hash: hash,
395 preconditions: None,
396 dry_run_impact: None,
397 apply_result: None,
398 prev_event_hash: None,
399 event_hash: None,
400 extra: BTreeMap::new(),
401 }
402 }
403
404 pub fn with_preconditions(mut self, preconditions: Preconditions) -> Self {
406 self.preconditions = Some(preconditions);
407 self
408 }
409
410 pub fn with_dry_run_impact(mut self, impact: DryRunImpact) -> Self {
412 self.dry_run_impact = Some(impact);
413 self
414 }
415
416 pub fn with_apply_result(mut self, result: ApplyResult) -> Self {
418 self.apply_result = Some(result);
419 self
420 }
421
422 pub fn seal(&mut self) {
424 let json = serde_json::to_string(self).unwrap_or_default();
425 let hash = Sha256::digest(json.as_bytes());
426 self.event_hash = Some(format!("sha256:{:x}", hash));
427 }
428
429 pub fn validate_version(&self) -> Result<(), String> {
431 if self.schema_version != SCHEMA_VERSION {
432 return Err(format!(
433 "unsupported schema version '{}' (expected '{}')",
434 self.schema_version, SCHEMA_VERSION
435 ));
436 }
437 Ok(())
438 }
439}
440
441fn make_op_id() -> String {
443 use std::time::{SystemTime, UNIX_EPOCH};
444 let ts = SystemTime::now()
445 .duration_since(UNIX_EPOCH)
446 .unwrap_or_default()
447 .as_millis();
448 let rand_suffix: u32 = rand::random();
449 format!("op_{:013x}_{:08x}", ts, rand_suffix)
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459 use serde_json::json;
460
461 #[test]
462 fn op_event_roundtrip() {
463 let event = OpEvent::new(
464 "sess_test".to_string(),
465 None,
466 Actor {
467 id: "test:agent".to_string(),
468 run_id: None,
469 source: "cli".to_string(),
470 },
471 OpKind::structure_clone_row(),
472 json!({
473 "sheet_name": "Provider",
474 "source_row": 85,
475 "insert_at": 86
476 }),
477 );
478
479 let serialized = serde_json::to_string(&event).unwrap();
480 let deserialized: OpEvent = serde_json::from_str(&serialized).unwrap();
481
482 assert_eq!(deserialized.schema_version, SCHEMA_VERSION);
483 assert_eq!(deserialized.kind, OpKind::structure_clone_row());
484 assert_eq!(deserialized.session_id, "sess_test");
485 assert!(deserialized.canonical_payload_hash.starts_with("sha256:"));
486 }
487
488 #[test]
489 fn canonical_hash_is_deterministic() {
490 let payload_a = json!({"b": 2, "a": 1});
491 let payload_b = json!({"a": 1, "b": 2});
492 assert_eq!(
493 canonical_payload_hash(&payload_a),
494 canonical_payload_hash(&payload_b)
495 );
496 }
497
498 #[test]
499 fn op_kind_namespace_and_action() {
500 let kind = OpKind::structure_clone_row();
501 assert_eq!(kind.namespace(), "structure");
502 assert_eq!(kind.action(), "clone_row");
503 assert_eq!(kind.to_string(), "structure.clone_row");
504 }
505
506 #[test]
507 fn unknown_version_rejected() {
508 let mut event = OpEvent::new(
509 "sess_test".to_string(),
510 None,
511 Actor {
512 id: "test".to_string(),
513 run_id: None,
514 source: "cli".to_string(),
515 },
516 OpKind::edit_batch(),
517 json!({}),
518 );
519 event.schema_version = "ops.v999".to_string();
520 assert!(event.validate_version().is_err());
521 }
522
523 #[test]
524 fn unknown_fields_tolerated() {
525 let json_str = r#"{
526 "schema_version": "ops.v1",
527 "op_id": "op_test",
528 "session_id": "sess_test",
529 "timestamp": "2024-10-24T10:00:00Z",
530 "actor": {"id": "test", "source": "cli"},
531 "kind": "structure.clone_row",
532 "payload": {},
533 "canonical_payload_hash": "sha256:abc",
534 "future_field": "should be preserved"
535 }"#;
536
537 let event: OpEvent = serde_json::from_str(json_str).unwrap();
538 assert_eq!(
539 event.extra.get("future_field").unwrap(),
540 "should be preserved"
541 );
542 }
543
544 #[test]
545 fn seal_produces_event_hash() {
546 let mut event = OpEvent::new(
547 "sess_test".to_string(),
548 None,
549 Actor {
550 id: "test".to_string(),
551 run_id: None,
552 source: "cli".to_string(),
553 },
554 OpKind::edit_batch(),
555 json!({"cell": "A1", "value": 42}),
556 );
557 assert!(event.event_hash.is_none());
558 event.seal();
559 assert!(event.event_hash.is_some());
560 assert!(event.event_hash.as_ref().unwrap().starts_with("sha256:"));
561 }
562}