1use crate::envelope::{Envelope, CURRENT_SCHEMA_VERSION};
4use crate::op::Op;
5
6#[derive(Clone, Debug)]
12pub struct ChangeSet {
13 pub envelope: Envelope,
14 pub ops: Vec<Op>,
15}
16
17impl ChangeSet {
18 pub fn new(envelope: Envelope, ops: Vec<Op>) -> Self {
19 Self { envelope, ops }
20 }
21}
22
23#[derive(Debug, thiserror::Error)]
26pub enum ChangeSetError {
27 #[error("NDJSON-delta input is empty; expected an envelope header line")]
28 Empty,
29 #[error("line {line} is not valid JSON: {source}")]
30 MalformedLine {
31 line: usize,
32 #[source]
33 source: serde_json::Error,
34 },
35 #[error(
36 "envelope schema_version {found} is not supported (this crate emits and \
37 accepts schema_version {expected})"
38 )]
39 UnsupportedSchemaVersion { found: u32, expected: u32 },
40 #[error("failed to serialize change-set: {0}")]
41 Serialize(#[source] serde_json::Error),
42}
43
44pub fn to_ndjson(changeset: &ChangeSet) -> Result<String, ChangeSetError> {
48 let mut out = String::new();
49 out.push_str(&serde_json::to_string(&changeset.envelope).map_err(ChangeSetError::Serialize)?);
50 out.push('\n');
51 for op in &changeset.ops {
52 out.push_str(&serde_json::to_string(op).map_err(ChangeSetError::Serialize)?);
53 out.push('\n');
54 }
55 Ok(out)
56}
57
58pub fn from_ndjson(input: &str) -> Result<ChangeSet, ChangeSetError> {
65 let mut lines = input.lines().enumerate();
66 let (_, first_line) = lines.next().ok_or(ChangeSetError::Empty)?;
67 let envelope: Envelope = serde_json::from_str(first_line)
68 .map_err(|source| ChangeSetError::MalformedLine { line: 1, source })?;
69 if envelope.schema_version != CURRENT_SCHEMA_VERSION {
70 return Err(ChangeSetError::UnsupportedSchemaVersion {
71 found: envelope.schema_version,
72 expected: CURRENT_SCHEMA_VERSION,
73 });
74 }
75
76 let mut ops = Vec::new();
81 for (idx, line) in lines {
82 let op: Op =
83 serde_json::from_str(line).map_err(|source| ChangeSetError::MalformedLine {
84 line: idx + 1,
85 source,
86 })?;
87 ops.push(op);
88 }
89 Ok(ChangeSet { envelope, ops })
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::op::{CreateOp, CreateTarget, EntityCreateFields};
96 use khive_types::{EntityKind, Id128, Namespace, Timestamp};
97
98 fn sample_changeset() -> ChangeSet {
99 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
100 let create = Op::Create(CreateOp {
101 id: Id128::from_u128(1),
102 namespace: Namespace::local(),
103 target: CreateTarget::Entity(EntityCreateFields {
104 entity_kind: EntityKind::Concept,
105 entity_type: None,
106 name: "X".into(),
107 description: None,
108 properties: Default::default(),
109 tags: vec![],
110 }),
111 });
112 ChangeSet::new(envelope, vec![create])
113 }
114
115 #[test]
116 fn round_trips_envelope_and_ops() {
117 let cs = sample_changeset();
118 let text = to_ndjson(&cs).unwrap();
119 let decoded = from_ndjson(&text).unwrap();
120 assert_eq!(decoded.envelope, cs.envelope);
121 assert_eq!(decoded.ops.len(), 1);
122 let text2 = to_ndjson(&decoded).unwrap();
123 assert_eq!(text, text2, "re-serialization must be byte-identical");
124 }
125
126 #[test]
127 fn envelope_is_line_one() {
128 let cs = sample_changeset();
129 let text = to_ndjson(&cs).unwrap();
130 let first_line = text.lines().next().unwrap();
131 let parsed_env: Envelope = serde_json::from_str(first_line).unwrap();
132 assert_eq!(parsed_env, cs.envelope);
133 }
134
135 #[test]
136 fn envelope_batch_id_round_trips_through_ndjson() {
137 let mut cs = sample_changeset();
138 cs.envelope = cs.envelope.with_batch_id("batch-xyz");
139 let text = to_ndjson(&cs).unwrap();
140 let decoded = from_ndjson(&text).unwrap();
141 assert_eq!(decoded.envelope.batch_id.as_deref(), Some("batch-xyz"));
142 assert_eq!(decoded.envelope, cs.envelope);
143 }
144
145 #[test]
146 fn envelope_without_batch_id_round_trips_through_ndjson() {
147 let cs = sample_changeset();
148 assert_eq!(cs.envelope.batch_id, None);
149 let text = to_ndjson(&cs).unwrap();
150 let decoded = from_ndjson(&text).unwrap();
151 assert_eq!(decoded.envelope.batch_id, None);
152 assert_eq!(decoded.envelope, cs.envelope);
153 }
154
155 #[test]
156 fn empty_input_errors() {
157 let result = from_ndjson("");
158 assert!(matches!(result, Err(ChangeSetError::Empty)));
159 }
160
161 #[test]
162 fn malformed_op_line_reports_correct_line_number() {
163 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
164 let env_line = serde_json::to_string(&envelope).unwrap();
165 let text = format!("{env_line}\n{{\"op\": \"create\", not json}}\n");
166 let err = from_ndjson(&text).unwrap_err();
167 match err {
168 ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
169 other => panic!("expected MalformedLine, got {other:?}"),
170 }
171 }
172
173 #[test]
174 fn blank_line_between_ops_is_malformed() {
175 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
176 let env_line = serde_json::to_string(&envelope).unwrap();
177 let text = format!("{env_line}\n\n");
178 let err = from_ndjson(&text).unwrap_err();
179 match err {
180 ChangeSetError::MalformedLine { line, .. } => assert_eq!(line, 2),
181 other => panic!("expected MalformedLine, got {other:?}"),
182 }
183 }
184
185 #[test]
186 fn unsupported_schema_version_is_rejected() {
187 let mut envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
188 envelope.schema_version = 99;
189 let env_line = serde_json::to_string(&envelope).unwrap();
190 let err = from_ndjson(&env_line).unwrap_err();
191 assert!(matches!(
192 err,
193 ChangeSetError::UnsupportedSchemaVersion {
194 found: 99,
195 expected: CURRENT_SCHEMA_VERSION
196 }
197 ));
198 }
199
200 #[test]
201 fn op_order_is_preserved() {
202 let envelope = Envelope::new("agent:test", "family:sonnet", Timestamp::from_secs(1));
203 let mk_create = |n: u128| {
204 Op::Create(CreateOp {
205 id: Id128::from_u128(n),
206 namespace: Namespace::local(),
207 target: CreateTarget::Entity(EntityCreateFields {
208 entity_kind: EntityKind::Concept,
209 entity_type: None,
210 name: format!("entity-{n}"),
211 description: None,
212 properties: Default::default(),
213 tags: vec![],
214 }),
215 })
216 };
217 let ops = vec![mk_create(3), mk_create(1), mk_create(2)];
218 let cs = ChangeSet::new(envelope, ops);
219 let text = to_ndjson(&cs).unwrap();
220 let decoded = from_ndjson(&text).unwrap();
221 let ids: Vec<u128> = decoded
222 .ops
223 .iter()
224 .map(|op| match op {
225 Op::Create(c) => c.id.to_u128(),
226 _ => unreachable!(),
227 })
228 .collect();
229 assert_eq!(ids, vec![3, 1, 2]);
230 }
231}