use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpActor {
Agent,
User,
ForeignSupervisor,
LiveBuffer,
}
impl OpActor {
pub fn as_str(self) -> &'static str {
match self {
OpActor::Agent => "agent",
OpActor::User => "user",
OpActor::ForeignSupervisor => "foreign_supervisor",
OpActor::LiveBuffer => "live_buffer",
}
}
pub fn from_str_lenient(value: &str) -> Option<Self> {
match value {
"agent" => Some(OpActor::Agent),
"user" => Some(OpActor::User),
"foreign_supervisor" => Some(OpActor::ForeignSupervisor),
"live_buffer" => Some(OpActor::LiveBuffer),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpSource {
SnapshotDiff,
LiveBufferDrift,
ForeignSupervisorWrite,
AgentWrite,
}
pub fn classify_actor(source: OpSource) -> OpActor {
match source {
OpSource::SnapshotDiff => OpActor::User,
OpSource::LiveBufferDrift => OpActor::LiveBuffer,
OpSource::ForeignSupervisorWrite => OpActor::ForeignSupervisor,
OpSource::AgentWrite => OpActor::Agent,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CausalClock {
pub lamport: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_session: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentOp {
pub document_path: String,
pub component: String,
pub node_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_index: Option<usize>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub item_id: String,
pub op_kind: String,
pub actor: OpActor,
pub clock: CausalClock,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recorded_at: Option<String>,
}
impl DocumentOp {
pub fn same_mutation(&self, other: &DocumentOp) -> bool {
self.document_path == other.document_path
&& self.component == other.component
&& self.node_key == other.node_key
&& self.op_kind == other.op_kind
&& self.before_preview == other.before_preview
&& self.after_preview == other.after_preview
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_actor_is_source_driven() {
assert_eq!(classify_actor(OpSource::SnapshotDiff), OpActor::User);
assert_eq!(
classify_actor(OpSource::LiveBufferDrift),
OpActor::LiveBuffer
);
assert_eq!(
classify_actor(OpSource::ForeignSupervisorWrite),
OpActor::ForeignSupervisor
);
assert_eq!(classify_actor(OpSource::AgentWrite), OpActor::Agent);
}
#[test]
fn op_actor_string_round_trips() {
for actor in [
OpActor::Agent,
OpActor::User,
OpActor::ForeignSupervisor,
OpActor::LiveBuffer,
] {
assert_eq!(OpActor::from_str_lenient(actor.as_str()), Some(actor));
}
assert_eq!(OpActor::from_str_lenient("nonsense"), None);
}
#[test]
fn document_op_serde_round_trips() {
let op = DocumentOp {
document_path: "plan.md".to_string(),
component: "queue".to_string(),
node_key: "queue:0:beta:0".to_string(),
node_index: Some(1),
item_id: "beta".to_string(),
op_kind: "insert".to_string(),
actor: OpActor::User,
clock: CausalClock {
lamport: 0,
origin_session: Some("sess-1".to_string()),
},
before_preview: None,
after_preview: Some("- do [#beta]".to_string()),
recorded_at: Some("123".to_string()),
};
let json = serde_json::to_string(&op).unwrap();
let parsed: DocumentOp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, op);
assert!(json.contains("\"actor\":\"user\""));
}
#[test]
fn same_mutation_ignores_clock_and_time() {
let base = DocumentOp {
document_path: "plan.md".to_string(),
component: "queue".to_string(),
node_key: "queue:0:beta:0".to_string(),
node_index: None,
item_id: "beta".to_string(),
op_kind: "insert".to_string(),
actor: OpActor::User,
clock: CausalClock {
lamport: 5,
origin_session: Some("sess-1".to_string()),
},
before_preview: None,
after_preview: Some("- do [#beta]".to_string()),
recorded_at: Some("100".to_string()),
};
let mut later = base.clone();
later.clock.lamport = 99;
later.recorded_at = Some("200".to_string());
assert!(base.same_mutation(&later));
let mut different = base.clone();
different.after_preview = Some("- do [#gamma]".to_string());
assert!(!base.same_mutation(&different));
}
}