1use crate::envelope::{Envelope, CURRENT_SCHEMA_VERSION};
4use crate::op::Op;
5
6#[derive(Clone, Debug)]
12pub struct ChangeSet {
13 pub envelope: Envelope,
15 pub ops: Vec<Op>,
17}
18
19impl ChangeSet {
20 pub fn new(envelope: Envelope, ops: Vec<Op>) -> Self {
22 Self { envelope, ops }
23 }
24}
25
26#[derive(Debug, thiserror::Error)]
30pub enum ChangeSetError {
31 #[error("NDJSON-delta input is empty; expected an envelope header line")]
32 Empty,
33 #[error("line {line} is not valid JSON: {source}")]
34 MalformedLine {
35 line: usize,
36 #[source]
37 source: serde_json::Error,
38 },
39 #[error(
40 "envelope schema_version {found} is not supported (this crate emits and \
41 accepts schema_version {expected})"
42 )]
43 UnsupportedSchemaVersion { found: u32, expected: u32 },
44 #[error("failed to serialize change-set: {0}")]
45 Serialize(#[source] serde_json::Error),
46}
47
48pub fn to_ndjson(changeset: &ChangeSet) -> Result<String, ChangeSetError> {
56 let mut out = String::new();
57 out.push_str(&serde_json::to_string(&changeset.envelope).map_err(ChangeSetError::Serialize)?);
58 out.push('\n');
59 for op in &changeset.ops {
60 out.push_str(&serde_json::to_string(op).map_err(ChangeSetError::Serialize)?);
61 out.push('\n');
62 }
63 Ok(out)
64}
65
66pub fn from_ndjson(input: &str) -> Result<ChangeSet, ChangeSetError> {
77 let mut lines = input.lines().enumerate();
78 let (_, first_line) = lines.next().ok_or(ChangeSetError::Empty)?;
79 reject_repeated_members(first_line)
80 .map_err(|source| ChangeSetError::MalformedLine { line: 1, source })?;
81 let envelope: Envelope = serde_json::from_str(first_line)
82 .map_err(|source| ChangeSetError::MalformedLine { line: 1, source })?;
83 if envelope.schema_version != CURRENT_SCHEMA_VERSION {
84 return Err(ChangeSetError::UnsupportedSchemaVersion {
85 found: envelope.schema_version,
86 expected: CURRENT_SCHEMA_VERSION,
87 });
88 }
89
90 let mut ops = Vec::new();
92 for (idx, line) in lines {
93 reject_repeated_members(line).map_err(|source| ChangeSetError::MalformedLine {
94 line: idx + 1,
95 source,
96 })?;
97 let op: Op =
98 serde_json::from_str(line).map_err(|source| ChangeSetError::MalformedLine {
99 line: idx + 1,
100 source,
101 })?;
102 ops.push(op);
103 }
104 Ok(ChangeSet { envelope, ops })
105}
106
107fn reject_repeated_members(line: &str) -> Result<(), serde_json::Error> {
114 serde_json::from_str::<UniqueMembers>(line).map(|_| ())
115}
116
117struct UniqueMembers;
118
119impl<'de> serde::Deserialize<'de> for UniqueMembers {
120 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121 deserializer.deserialize_any(UniqueMembersVisitor)
122 }
123}
124
125struct UniqueMembersVisitor;
126
127impl<'de> serde::de::Visitor<'de> for UniqueMembersVisitor {
128 type Value = UniqueMembers;
129
130 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 formatter.write_str("a JSON value")
132 }
133
134 fn visit_bool<E>(self, _: bool) -> Result<UniqueMembers, E> {
135 Ok(UniqueMembers)
136 }
137
138 fn visit_i64<E>(self, _: i64) -> Result<UniqueMembers, E> {
139 Ok(UniqueMembers)
140 }
141
142 fn visit_u64<E>(self, _: u64) -> Result<UniqueMembers, E> {
143 Ok(UniqueMembers)
144 }
145
146 fn visit_f64<E>(self, _: f64) -> Result<UniqueMembers, E> {
147 Ok(UniqueMembers)
148 }
149
150 fn visit_str<E>(self, _: &str) -> Result<UniqueMembers, E> {
151 Ok(UniqueMembers)
152 }
153
154 fn visit_unit<E>(self) -> Result<UniqueMembers, E> {
155 Ok(UniqueMembers)
156 }
157
158 fn visit_seq<A: serde::de::SeqAccess<'de>>(
159 self,
160 mut seq: A,
161 ) -> Result<UniqueMembers, A::Error> {
162 while seq.next_element::<UniqueMembers>()?.is_some() {}
163 Ok(UniqueMembers)
164 }
165
166 fn visit_map<A: serde::de::MapAccess<'de>>(
167 self,
168 mut map: A,
169 ) -> Result<UniqueMembers, A::Error> {
170 let mut seen = std::collections::HashSet::new();
171 while let Some(name) = map.next_key::<String>()? {
172 if !seen.insert(name) {
173 return Err(serde::de::Error::custom("an object repeats a member name"));
174 }
175 map.next_value::<UniqueMembers>()?;
176 }
177 Ok(UniqueMembers)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::op::{CreateOp, CreateTarget, EntityCreateFields};
185 use khive_types::{EntityKind, Id128, Namespace, Timestamp};
186
187 fn sample_changeset() -> ChangeSet {
188 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
189 let create = Op::Create(CreateOp {
190 id: Id128::from_u128(1),
191 namespace: Namespace::local(),
192 target: CreateTarget::Entity(EntityCreateFields {
193 entity_kind: EntityKind::Concept,
194 entity_type: None,
195 name: "X".into(),
196 description: None,
197 properties: Default::default(),
198 tags: vec![],
199 }),
200 });
201 ChangeSet::new(envelope, vec![create])
202 }
203
204 #[test]
205 fn round_trips_envelope_and_ops() {
206 let cs = sample_changeset();
207 let text = to_ndjson(&cs).unwrap();
208 let decoded = from_ndjson(&text).unwrap();
209 assert_eq!(decoded.envelope, cs.envelope);
210 assert_eq!(decoded.ops.len(), 1);
211 let text2 = to_ndjson(&decoded).unwrap();
212 assert_eq!(text, text2, "re-serialization must be byte-identical");
213 }
214
215 #[test]
216 fn envelope_is_line_one() {
217 let cs = sample_changeset();
218 let text = to_ndjson(&cs).unwrap();
219 let first_line = text.lines().next().unwrap();
220 let parsed_env: Envelope = serde_json::from_str(first_line).unwrap();
221 assert_eq!(parsed_env, cs.envelope);
222 }
223
224 #[test]
225 fn envelope_batch_id_round_trips_through_ndjson() {
226 let mut cs = sample_changeset();
227 cs.envelope = cs.envelope.with_batch_id("batch-xyz");
228 let text = to_ndjson(&cs).unwrap();
229 let decoded = from_ndjson(&text).unwrap();
230 assert_eq!(decoded.envelope.batch_id.as_deref(), Some("batch-xyz"));
231 assert_eq!(decoded.envelope, cs.envelope);
232 }
233
234 #[test]
235 fn envelope_without_batch_id_round_trips_through_ndjson() {
236 let cs = sample_changeset();
237 assert_eq!(cs.envelope.batch_id, None);
238 let text = to_ndjson(&cs).unwrap();
239 let decoded = from_ndjson(&text).unwrap();
240 assert_eq!(decoded.envelope.batch_id, None);
241 assert_eq!(decoded.envelope, cs.envelope);
242 }
243
244 #[test]
245 fn empty_input_errors() {
246 let result = from_ndjson("");
247 assert!(matches!(result, Err(ChangeSetError::Empty)));
248 }
249
250 #[test]
251 fn malformed_op_line_reports_correct_line_number() {
252 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
253 let env_line = serde_json::to_string(&envelope).unwrap();
254 let text = format!("{env_line}\n{{\"op\": \"create\", not json}}\n");
255 let err = from_ndjson(&text).unwrap_err();
256 match err {
257 ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
258 other => panic!("expected MalformedLine, got {other:?}"),
259 }
260 }
261
262 #[test]
263 fn blank_line_between_ops_is_malformed() {
264 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
265 let env_line = serde_json::to_string(&envelope).unwrap();
266 let text = format!("{env_line}\n\n");
267 let err = from_ndjson(&text).unwrap_err();
268 match err {
269 ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
270 other => panic!("expected MalformedLine, got {other:?}"),
271 }
272 }
273
274 fn create_line_with_repeated_property(first: &str, second: &str) -> String {
276 let id = uuid_text(7);
277 format!(
278 "{{\"op\":\"create\",\"id\":\"{id}\",\"namespace\":\"local\",\"target\":{{\
279 \"kind\":\"entity\",\"entity_kind\":\"concept\",\"name\":\"X\",\
280 \"properties\":{{\"{first}\":\"kept-out\",\"{second}\":\"benign\"}}}}}}"
281 )
282 }
283
284 fn uuid_text(n: u128) -> String {
285 serde_json::to_value(Id128::from_u128(n))
286 .unwrap()
287 .as_str()
288 .unwrap()
289 .to_owned()
290 }
291
292 fn env_line() -> String {
293 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
294 serde_json::to_string(&envelope).unwrap()
295 }
296
297 #[test]
298 fn a_distinct_property_map_decodes() {
299 let text = format!(
300 "{}\n{}\n",
301 env_line(),
302 create_line_with_repeated_property("label", "other")
303 );
304 let decoded = from_ndjson(&text).expect("distinct names decode");
305 match &decoded.ops[0] {
306 Op::Create(CreateOp {
307 target: CreateTarget::Entity(fields),
308 ..
309 }) => assert_eq!(fields.properties.len(), 2),
310 other => panic!("expected an entity create, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn a_repeated_nested_member_is_malformed_and_the_error_names_no_bytes() {
316 let text = format!(
317 "{}\n{}\n",
318 env_line(),
319 create_line_with_repeated_property("label", "label")
320 );
321 let err = from_ndjson(&text).unwrap_err();
322 let shown = err.to_string();
323 match err {
324 ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
325 other => panic!("expected MalformedLine, got {other:?}"),
326 }
327 assert!(shown.contains("repeats a member name"), "{shown}");
328 assert!(!shown.contains("label"), "{shown}");
329 assert!(!shown.contains("kept-out"), "{shown}");
330 assert!(!shown.contains("benign"), "{shown}");
331 }
332
333 #[test]
334 fn member_names_are_compared_after_unescaping() {
335 let text = format!(
337 "{}\n{}\n",
338 env_line(),
339 create_line_with_repeated_property("\\u006cabel", "label")
340 );
341 assert!(matches!(
342 from_ndjson(&text),
343 Err(ChangeSetError::MalformedLine { line: 2, .. })
344 ));
345 }
346
347 #[test]
348 fn a_repeated_top_level_or_envelope_member_is_malformed() {
349 let op = create_line_with_repeated_property("label", "other");
350 let repeated_op = op.replacen(
351 "{\"op\":\"create\",",
352 "{\"op\":\"create\",\"op\":\"create\",",
353 1,
354 );
355 assert_ne!(repeated_op, op);
356 let text = format!("{}\n{repeated_op}\n", env_line());
357 assert!(matches!(
358 from_ndjson(&text),
359 Err(ChangeSetError::MalformedLine { line: 2, .. })
360 ));
361
362 let env = env_line();
363 let repeated_env = env.replacen('{', "{\"schema_version\":1,", 1);
364 assert!(env.contains("\"schema_version\""), "{env}");
365 let text = format!("{repeated_env}\n{op}\n");
366 assert!(matches!(
367 from_ndjson(&text),
368 Err(ChangeSetError::MalformedLine { line: 1, .. })
369 ));
370 }
371
372 #[test]
373 fn unsupported_schema_version_is_rejected() {
374 let mut envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
375 envelope.schema_version = 99;
376 let env_line = serde_json::to_string(&envelope).unwrap();
377 let err = from_ndjson(&env_line).unwrap_err();
378 assert!(matches!(
379 err,
380 ChangeSetError::UnsupportedSchemaVersion {
381 found: 99,
382 expected: CURRENT_SCHEMA_VERSION
383 }
384 ));
385 }
386
387 #[test]
388 fn op_order_is_preserved() {
389 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
390 let mk_create = |n: u128| {
391 Op::Create(CreateOp {
392 id: Id128::from_u128(n),
393 namespace: Namespace::local(),
394 target: CreateTarget::Entity(EntityCreateFields {
395 entity_kind: EntityKind::Concept,
396 entity_type: None,
397 name: format!("entity-{n}"),
398 description: None,
399 properties: Default::default(),
400 tags: vec![],
401 }),
402 })
403 };
404 let ops = vec![mk_create(3), mk_create(1), mk_create(2)];
405 let cs = ChangeSet::new(envelope, ops);
406 let text = to_ndjson(&cs).unwrap();
407 let decoded = from_ndjson(&text).unwrap();
408 let ids: Vec<u128> = decoded
409 .ops
410 .iter()
411 .map(|op| match op {
412 Op::Create(c) => c.id.to_u128(),
413 _ => unreachable!(),
414 })
415 .collect();
416 assert_eq!(ids, vec![3, 1, 2]);
417 }
418}