use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const SCHEMA_VERSION: &str = "ops.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpEvent {
pub schema_version: String,
pub op_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub session_id: String,
pub timestamp: DateTime<Utc>,
pub actor: Actor,
pub kind: OpKind,
pub payload: serde_json::Value,
pub canonical_payload_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub preconditions: Option<Preconditions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dry_run_impact: Option<DryRunImpact>,
#[serde(skip_serializing_if = "Option::is_none")]
pub apply_result: Option<ApplyResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev_event_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_hash: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Actor {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default = "default_source")]
pub source: String,
}
fn default_source() -> String {
"cli".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OpKind(pub String);
impl OpKind {
pub fn new(namespace: &str, action: &str) -> Self {
Self(format!("{}.{}", namespace, action))
}
pub fn namespace(&self) -> &str {
self.0.split('.').next().unwrap_or(&self.0)
}
pub fn action(&self) -> &str {
self.0.split('.').nth(1).unwrap_or("")
}
pub fn structure_insert_rows() -> Self {
Self::new("structure", "insert_rows")
}
pub fn structure_delete_rows() -> Self {
Self::new("structure", "delete_rows")
}
pub fn structure_insert_cols() -> Self {
Self::new("structure", "insert_cols")
}
pub fn structure_delete_cols() -> Self {
Self::new("structure", "delete_cols")
}
pub fn structure_clone_row() -> Self {
Self::new("structure", "clone_row")
}
pub fn structure_merge_cells() -> Self {
Self::new("structure", "merge_cells")
}
pub fn structure_unmerge_cells() -> Self {
Self::new("structure", "unmerge_cells")
}
pub fn structure_rename_sheet() -> Self {
Self::new("structure", "rename_sheet")
}
pub fn structure_create_sheet() -> Self {
Self::new("structure", "create_sheet")
}
pub fn structure_delete_sheet() -> Self {
Self::new("structure", "delete_sheet")
}
pub fn structure_copy_range() -> Self {
Self::new("structure", "copy_range")
}
pub fn structure_move_range() -> Self {
Self::new("structure", "move_range")
}
pub fn transform_clear_range() -> Self {
Self::new("transform", "clear_range")
}
pub fn transform_fill_range() -> Self {
Self::new("transform", "fill_range")
}
pub fn transform_replace_in_range() -> Self {
Self::new("transform", "replace_in_range")
}
pub fn transform_write_matrix() -> Self {
Self::new("transform", "write_matrix")
}
pub fn style_apply() -> Self {
Self::new("style", "apply")
}
pub fn formula_apply_pattern() -> Self {
Self::new("formula", "apply_pattern")
}
pub fn formula_replace_in_formulas() -> Self {
Self::new("formula", "replace_in_formulas")
}
pub fn column_size() -> Self {
Self::new("column", "size")
}
pub fn layout_apply() -> Self {
Self::new("layout", "apply")
}
pub fn rules_apply() -> Self {
Self::new("rules", "apply")
}
pub fn name_define() -> Self {
Self::new("name", "define")
}
pub fn name_update() -> Self {
Self::new("name", "update")
}
pub fn name_delete() -> Self {
Self::new("name", "delete")
}
pub fn edit_batch() -> Self {
Self::new("edit", "batch")
}
pub fn import_range() -> Self {
Self::new("import", "range")
}
pub fn import_grid() -> Self {
Self::new("import", "grid")
}
pub fn session_materialize() -> Self {
Self::new("session", "materialize")
}
}
impl std::fmt::Display for OpKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preconditions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cell_matches: Vec<CellMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workbook_hash_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head_at_stage: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CellMatch {
pub address: String,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DryRunImpact {
pub cells_changed: u64,
pub formulas_rewritten: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shifted_spans: Vec<ShiftedSpan>,
pub ref_errors_generated: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boundary_warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShiftedSpan {
pub op_index: usize,
pub sheet_name: String,
pub axis: String,
pub at: u32,
pub count: u32,
pub direction: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyResult {
pub status: ApplyStatus,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workbook_hash_after: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplyStatus {
Applied,
Rejected,
Superseded,
}
pub fn canonical_payload_hash(payload: &serde_json::Value) -> String {
let canonical = canonical_json(payload);
let hash = Sha256::digest(canonical.as_bytes());
format!("sha256:{:x}", hash)
}
fn canonical_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
let entries: Vec<String> = sorted
.into_iter()
.map(|(k, v)| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap(),
canonical_json(v)
)
})
.collect();
format!("{{{}}}", entries.join(","))
}
serde_json::Value::Array(arr) => {
let entries: Vec<String> = arr.iter().map(canonical_json).collect();
format!("[{}]", entries.join(","))
}
_ => serde_json::to_string(value).unwrap_or_default(),
}
}
impl OpEvent {
pub fn new(
session_id: String,
parent_id: Option<String>,
actor: Actor,
kind: OpKind,
payload: serde_json::Value,
) -> Self {
let hash = canonical_payload_hash(&payload);
let op_id = make_op_id();
Self {
schema_version: SCHEMA_VERSION.to_string(),
op_id,
parent_id,
session_id,
timestamp: Utc::now(),
actor,
kind,
payload,
canonical_payload_hash: hash,
preconditions: None,
dry_run_impact: None,
apply_result: None,
prev_event_hash: None,
event_hash: None,
extra: BTreeMap::new(),
}
}
pub fn with_preconditions(mut self, preconditions: Preconditions) -> Self {
self.preconditions = Some(preconditions);
self
}
pub fn with_dry_run_impact(mut self, impact: DryRunImpact) -> Self {
self.dry_run_impact = Some(impact);
self
}
pub fn with_apply_result(mut self, result: ApplyResult) -> Self {
self.apply_result = Some(result);
self
}
pub fn seal(&mut self) {
let json = serde_json::to_string(self).unwrap_or_default();
let hash = Sha256::digest(json.as_bytes());
self.event_hash = Some(format!("sha256:{:x}", hash));
}
pub fn validate_version(&self) -> Result<(), String> {
if self.schema_version != SCHEMA_VERSION {
return Err(format!(
"unsupported schema version '{}' (expected '{}')",
self.schema_version, SCHEMA_VERSION
));
}
Ok(())
}
}
fn make_op_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let rand_suffix: u32 = rand::random();
format!("op_{:013x}_{:08x}", ts, rand_suffix)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn op_event_roundtrip() {
let event = OpEvent::new(
"sess_test".to_string(),
None,
Actor {
id: "test:agent".to_string(),
run_id: None,
source: "cli".to_string(),
},
OpKind::structure_clone_row(),
json!({
"sheet_name": "Provider",
"source_row": 85,
"insert_at": 86
}),
);
let serialized = serde_json::to_string(&event).unwrap();
let deserialized: OpEvent = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.schema_version, SCHEMA_VERSION);
assert_eq!(deserialized.kind, OpKind::structure_clone_row());
assert_eq!(deserialized.session_id, "sess_test");
assert!(deserialized.canonical_payload_hash.starts_with("sha256:"));
}
#[test]
fn canonical_hash_is_deterministic() {
let payload_a = json!({"b": 2, "a": 1});
let payload_b = json!({"a": 1, "b": 2});
assert_eq!(
canonical_payload_hash(&payload_a),
canonical_payload_hash(&payload_b)
);
}
#[test]
fn op_kind_namespace_and_action() {
let kind = OpKind::structure_clone_row();
assert_eq!(kind.namespace(), "structure");
assert_eq!(kind.action(), "clone_row");
assert_eq!(kind.to_string(), "structure.clone_row");
}
#[test]
fn unknown_version_rejected() {
let mut event = OpEvent::new(
"sess_test".to_string(),
None,
Actor {
id: "test".to_string(),
run_id: None,
source: "cli".to_string(),
},
OpKind::edit_batch(),
json!({}),
);
event.schema_version = "ops.v999".to_string();
assert!(event.validate_version().is_err());
}
#[test]
fn unknown_fields_tolerated() {
let json_str = r#"{
"schema_version": "ops.v1",
"op_id": "op_test",
"session_id": "sess_test",
"timestamp": "2024-10-24T10:00:00Z",
"actor": {"id": "test", "source": "cli"},
"kind": "structure.clone_row",
"payload": {},
"canonical_payload_hash": "sha256:abc",
"future_field": "should be preserved"
}"#;
let event: OpEvent = serde_json::from_str(json_str).unwrap();
assert_eq!(
event.extra.get("future_field").unwrap(),
"should be preserved"
);
}
#[test]
fn seal_produces_event_hash() {
let mut event = OpEvent::new(
"sess_test".to_string(),
None,
Actor {
id: "test".to_string(),
run_id: None,
source: "cli".to_string(),
},
OpKind::edit_batch(),
json!({"cell": "A1", "value": 42}),
);
assert!(event.event_hash.is_none());
event.seal();
assert!(event.event_hash.is_some());
assert!(event.event_hash.as_ref().unwrap().starts_with("sha256:"));
}
}