1use std::collections::BTreeMap;
11
12use ipld_core::ipld::Ipld;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::id::{ChangeId, Cid};
16use crate::objects::Signature;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct Operation {
21 pub parents: Vec<Cid>,
24 pub view: Cid,
26 pub predecessors: Option<BTreeMap<String, Vec<ChangeId>>>,
30 pub author: String,
32 pub agent_id: Option<String>,
34 pub task_id: Option<String>,
36 pub host: Option<String>,
38 pub time: u64,
40 pub description: String,
42 pub signature: Option<Signature>,
46 pub extra: BTreeMap<String, Ipld>,
48}
49
50impl Operation {
51 pub const KIND: &'static str = "operation";
53
54 #[must_use]
56 pub fn new(
57 view: Cid,
58 author: impl Into<String>,
59 time: u64,
60 description: impl Into<String>,
61 ) -> Self {
62 Self {
63 parents: Vec::new(),
64 view,
65 predecessors: None,
66 author: author.into(),
67 agent_id: None,
68 task_id: None,
69 host: None,
70 time,
71 description: description.into(),
72 signature: None,
73 extra: BTreeMap::new(),
74 }
75 }
76
77 #[must_use]
79 pub fn with_parent(mut self, parent: Cid) -> Self {
80 self.parents.push(parent);
81 self
82 }
83
84 #[must_use]
86 pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
87 self.agent_id = Some(agent_id.into());
88 self
89 }
90
91 #[must_use]
93 pub fn with_task(mut self, task_id: impl Into<String>) -> Self {
94 self.task_id = Some(task_id.into());
95 self
96 }
97
98 #[must_use]
100 pub fn with_host(mut self, host: impl Into<String>) -> Self {
101 self.host = Some(host.into());
102 self
103 }
104}
105
106#[derive(Serialize, Deserialize)]
109struct OperationWire {
110 #[serde(rename = "_kind")]
111 kind: String,
112 parents: Vec<Cid>,
113 view: Cid,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 predecessors: Option<BTreeMap<String, Vec<ChangeId>>>,
116 author: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 agent_id: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 task_id: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 host: Option<String>,
123 time: u64,
124 description: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 signature: Option<Signature>,
127 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
128 extra: BTreeMap<String, Ipld>,
129}
130
131impl Serialize for Operation {
132 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
133 OperationWire {
134 kind: Self::KIND.into(),
135 parents: self.parents.clone(),
136 view: self.view.clone(),
137 predecessors: self.predecessors.clone(),
138 author: self.author.clone(),
139 agent_id: self.agent_id.clone(),
140 task_id: self.task_id.clone(),
141 host: self.host.clone(),
142 time: self.time,
143 description: self.description.clone(),
144 signature: self.signature.clone(),
145 extra: self.extra.clone(),
146 }
147 .serialize(serializer)
148 }
149}
150
151impl<'de> Deserialize<'de> for Operation {
152 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153 let w = OperationWire::deserialize(deserializer)?;
154 if w.kind != Self::KIND {
155 return Err(serde::de::Error::custom(format!(
156 "expected _kind='{}', got '{}'",
157 Self::KIND,
158 w.kind
159 )));
160 }
161 Ok(Self {
162 parents: w.parents,
163 view: w.view,
164 predecessors: w.predecessors,
165 author: w.author,
166 agent_id: w.agent_id,
167 task_id: w.task_id,
168 host: w.host,
169 time: w.time,
170 description: w.description,
171 signature: w.signature,
172 extra: w.extra,
173 })
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::codec::{from_canonical_bytes, to_canonical_bytes};
181 use crate::id::{CODEC_RAW, Multihash};
182
183 fn raw(n: u32) -> Cid {
184 Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
185 }
186
187 fn sample() -> Operation {
188 Operation::new(
189 raw(1),
190 "alice@example.org",
191 1_700_000_000_000_000,
192 "commit: init",
193 )
194 .with_agent("agent:claude")
195 .with_task("task:001")
196 .with_host("workstation-1")
197 }
198
199 #[test]
200 fn operation_round_trip_byte_identity() {
201 let original = sample();
202 let bytes = to_canonical_bytes(&original).unwrap();
203 let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
204 assert_eq!(original, decoded);
205 let bytes2 = to_canonical_bytes(&decoded).unwrap();
206 assert_eq!(bytes, bytes2);
207 }
208
209 #[test]
210 fn operation_with_predecessors_round_trip() {
211 let mut op = sample();
212 let mut preds = BTreeMap::new();
213 let key = ChangeId::from_bytes_raw([1u8; 16]).to_uuid_string();
214 preds.insert(key, vec![ChangeId::from_bytes_raw([2u8; 16])]);
215 op.predecessors = Some(preds);
216 let bytes = to_canonical_bytes(&op).unwrap();
217 let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
218 assert_eq!(op, decoded);
219 }
220
221 #[test]
222 fn operation_kind_rejection() {
223 let w = OperationWire {
224 kind: "commit".into(),
225 parents: vec![],
226 view: raw(1),
227 predecessors: None,
228 author: "x".into(),
229 agent_id: None,
230 task_id: None,
231 host: None,
232 time: 0,
233 description: String::new(),
234 signature: None,
235 extra: BTreeMap::new(),
236 };
237 let bytes = serde_ipld_dagcbor::to_vec(&w).unwrap();
238 let err = serde_ipld_dagcbor::from_slice::<Operation>(&bytes).unwrap_err();
239 assert!(err.to_string().contains("_kind"));
240 }
241
242 #[test]
243 fn operation_with_multiple_parents_round_trip() {
244 let op = sample().with_parent(raw(10)).with_parent(raw(11));
245 let bytes = to_canonical_bytes(&op).unwrap();
246 let decoded: Operation = from_canonical_bytes(&bytes).unwrap();
247 assert_eq!(decoded.parents.len(), 2);
248 assert_eq!(op, decoded);
249 }
250}