1use crate::{
12 json_to_f32_vec, json_to_prop_changes, json_to_props, merge_required_prop, resolve_scope,
13 ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP, MEMORY_LABEL,
14};
15use serde_json::Value;
16use std::str::FromStr;
17use topodb::{EdgeId, NodeId, Op, PropValue, Props, Scope};
18
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum IdKind {
21 Node,
22 Edge,
23}
24
25fn kind_name(k: IdKind) -> &'static str {
26 match k {
27 IdKind::Node => "node",
28 IdKind::Edge => "edge",
29 }
30}
31
32fn req_str(obj: &serde_json::Map<String, Value>, key: &str, idx: usize) -> Result<String, String> {
34 obj.get(key)
35 .and_then(|v| v.as_str())
36 .map(|s| s.to_string())
37 .ok_or_else(|| format!("command #{idx}: missing string field {key:?}"))
38}
39
40fn opt_i64(
43 obj: &serde_json::Map<String, Value>,
44 key: &str,
45 idx: usize,
46) -> Result<Option<i64>, String> {
47 match obj.get(key) {
48 None | Some(Value::Null) => Ok(None),
49 Some(v) => v
50 .as_i64()
51 .map(Some)
52 .ok_or_else(|| format!("command #{idx}: {key:?} must be an integer")),
53 }
54}
55
56fn scope_of(
60 obj: &serde_json::Map<String, Value>,
61 default: Scope,
62 idx: usize,
63) -> Result<Scope, String> {
64 match obj.get("scope") {
65 None | Some(Value::Null) => Ok(default),
66 Some(Value::String(s)) => {
67 resolve_scope(Some(s), default).map_err(|e| format!("command #{idx}: {e}"))
68 }
69 Some(_) => Err(format!("command #{idx}: \"scope\" must be a string")),
70 }
71}
72
73fn resolve_ref(
77 raw: &str,
78 expected: IdKind,
79 produced: &[Option<(String, IdKind)>],
80 field: &str,
81 idx: usize,
82) -> Result<String, String> {
83 let Some(rest) = raw.strip_prefix('#') else {
84 return Ok(raw.to_string());
85 };
86 let n: usize = rest
87 .parse()
88 .map_err(|_| format!("command #{idx}: {field} back-ref {raw:?} is not #<number>"))?;
89 if n >= idx {
90 return Err(format!(
91 "command #{idx}: {field} back-ref {raw:?} must point to an earlier command"
92 ));
93 }
94 match &produced[n] {
95 Some((id, kind)) if *kind == expected => Ok(id.clone()),
96 Some((_, kind)) => Err(format!(
97 "command #{idx}: {field} back-ref #{n} refers to a {} id but a {} id is required here",
98 kind_name(*kind),
99 kind_name(expected)
100 )),
101 None => Err(format!(
102 "command #{idx}: {field} back-ref #{n} refers to a command that produces no id"
103 )),
104 }
105}
106
107fn parse_node(s: &str, field: &str, idx: usize) -> Result<NodeId, String> {
108 NodeId::from_str(s).map_err(|e| format!("command #{idx}: invalid {field} node id {s:?}: {e}"))
109}
110
111fn parse_edge(s: &str, field: &str, idx: usize) -> Result<EdgeId, String> {
112 EdgeId::from_str(s).map_err(|e| format!("command #{idx}: invalid {field} edge id {s:?}: {e}"))
113}
114
115pub fn resolve_batch(
116 batch: &Value,
117 default_scope: Scope,
118) -> Result<(Vec<Op>, Vec<Option<String>>), String> {
119 let arr = batch
120 .as_array()
121 .ok_or_else(|| "batch must be a JSON array of command objects".to_string())?;
122 let mut produced: Vec<Option<(String, IdKind)>> = Vec::with_capacity(arr.len());
123 let mut ops: Vec<Op> = Vec::with_capacity(arr.len());
124
125 for (idx, cmd) in arr.iter().enumerate() {
126 let obj = cmd
127 .as_object()
128 .ok_or_else(|| format!("command #{idx}: expected a JSON object"))?;
129 let op_name = obj
130 .get("op")
131 .and_then(|v| v.as_str())
132 .ok_or_else(|| format!("command #{idx}: missing string field \"op\""))?;
133
134 match op_name {
135 "create_memory" => {
136 let content = req_str(obj, "content", idx)?;
137 let scope = scope_of(obj, default_scope, idx)?;
138 let props = merge_required_prop(
139 MEMORY_CONTENT_PROP,
140 PropValue::Str(content),
141 obj.get("props"),
142 )
143 .map_err(|e| format!("command #{idx}: {e}"))?;
144 let id = NodeId::new();
145 produced.push(Some((id.to_string(), IdKind::Node)));
146 ops.push(Op::CreateNode {
147 id,
148 scope,
149 label: MEMORY_LABEL.into(),
150 props,
151 });
152 }
153 "create_entity" => {
154 let name = req_str(obj, "name", idx)?;
155 let scope = scope_of(obj, default_scope, idx)?;
156 let props =
157 merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name), obj.get("props"))
158 .map_err(|e| format!("command #{idx}: {e}"))?;
159 let id = NodeId::new();
160 produced.push(Some((id.to_string(), IdKind::Node)));
161 ops.push(Op::CreateNode {
162 id,
163 scope,
164 label: ENTITY_LABEL.into(),
165 props,
166 });
167 }
168 "create_node" => {
169 let label = req_str(obj, "label", idx)?;
170 if label.is_empty() {
171 return Err(format!("command #{idx}: \"label\" must be non-empty"));
172 }
173 if label == ENTITY_LABEL {
174 return Err(format!(
175 "command #{idx}: label {ENTITY_LABEL:?} is reserved — use the create_entity command"
176 ));
177 }
178 if label == MEMORY_LABEL {
179 return Err(format!(
180 "command #{idx}: label {MEMORY_LABEL:?} is reserved — use the create_memory command"
181 ));
182 }
183 let scope = scope_of(obj, default_scope, idx)?;
184 let props = match obj.get("props") {
185 Some(v) => json_to_props(v).map_err(|e| format!("command #{idx}: {e}"))?,
186 None => Props::new(),
187 };
188 let id = NodeId::new();
189 produced.push(Some((id.to_string(), IdKind::Node)));
190 ops.push(Op::CreateNode {
191 id,
192 scope,
193 label: label.into(),
194 props,
195 });
196 }
197 "link" => {
198 let from_raw = req_str(obj, "from", idx)?;
199 let to_raw = req_str(obj, "to", idx)?;
200 let ty = req_str(obj, "type", idx)?;
201 let scope = scope_of(obj, default_scope, idx)?;
202 let from = parse_node(
203 &resolve_ref(&from_raw, IdKind::Node, &produced, "from", idx)?,
204 "from",
205 idx,
206 )?;
207 let to = parse_node(
208 &resolve_ref(&to_raw, IdKind::Node, &produced, "to", idx)?,
209 "to",
210 idx,
211 )?;
212 let props = match obj.get("props") {
213 Some(v) => json_to_props(v).map_err(|e| format!("command #{idx}: {e}"))?,
214 None => Props::new(),
215 };
216 let valid_from = opt_i64(obj, "valid_from", idx)?;
217 let id = EdgeId::new();
218 produced.push(Some((id.to_string(), IdKind::Edge)));
219 ops.push(Op::CreateEdge {
220 id,
221 scope,
222 ty: ty.into(),
223 from,
224 to,
225 props,
226 valid_from,
227 });
228 }
229 "set_node_props" => {
230 let id_raw = req_str(obj, "id", idx)?;
231 let id = parse_node(
232 &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
233 "id",
234 idx,
235 )?;
236 let props_val = obj
237 .get("props")
238 .ok_or_else(|| format!("command #{idx}: set_node_props requires \"props\""))?;
239 let props =
240 json_to_prop_changes(props_val).map_err(|e| format!("command #{idx}: {e}"))?;
241 produced.push(None);
242 ops.push(Op::SetNodeProps { id, props });
243 }
244 "remove_node" => {
245 let id_raw = req_str(obj, "id", idx)?;
246 let id = parse_node(
247 &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
248 "id",
249 idx,
250 )?;
251 produced.push(None);
252 ops.push(Op::RemoveNode { id });
253 }
254 "close_edge" => {
255 let id_raw = req_str(obj, "id", idx)?;
256 let id = parse_edge(
257 &resolve_ref(&id_raw, IdKind::Edge, &produced, "id", idx)?,
258 "id",
259 idx,
260 )?;
261 let valid_to = opt_i64(obj, "valid_to", idx)?;
262 produced.push(None);
263 ops.push(Op::CloseEdge { id, valid_to });
264 }
265 "set_embedding" => {
266 let id_raw = req_str(obj, "id", idx)?;
267 let id = parse_node(
268 &resolve_ref(&id_raw, IdKind::Node, &produced, "id", idx)?,
269 "id",
270 idx,
271 )?;
272 let model = req_str(obj, "model", idx)?;
273 let vector_val = obj
274 .get("vector")
275 .ok_or_else(|| format!("command #{idx}: set_embedding requires \"vector\""))?;
276 let vector =
277 json_to_f32_vec(vector_val).map_err(|e| format!("command #{idx}: {e}"))?;
278 produced.push(None);
279 ops.push(Op::SetEmbedding { id, model, vector });
280 }
281 other => return Err(format!("command #{idx}: unknown op {other:?}")),
282 }
283 }
284
285 let produced_ids = produced.into_iter().map(|p| p.map(|(id, _)| id)).collect();
286 Ok((ops, produced_ids))
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use topodb::ScopeId;
293
294 fn ids(ops: &[Op]) -> Vec<String> {
295 ops.iter()
296 .map(|op| match op {
297 Op::CreateNode { id, .. } => id.to_string(),
298 Op::CreateEdge { id, .. } => id.to_string(),
299 _ => String::new(),
300 })
301 .collect()
302 }
303
304 #[test]
305 fn create_and_backref_link_resolves_to_generated_ids() {
306 let batch = serde_json::json!([
307 { "op": "create_entity", "name": "Ada" },
308 { "op": "create_memory", "content": "met Ada" },
309 { "op": "link", "from": "#1", "to": "#0", "type": "about" }
310 ]);
311 let (ops, produced) = resolve_batch(&batch, Scope::Shared).unwrap();
312 assert_eq!(ops.len(), 3);
313 let node_ids = ids(&ops);
314 match &ops[2] {
316 Op::CreateEdge { from, to, ty, .. } => {
317 assert_eq!(from.to_string(), node_ids[1]);
318 assert_eq!(to.to_string(), node_ids[0]);
319 assert_eq!(ty.as_str(), "about");
320 }
321 other => panic!("expected CreateEdge, got {other:?}"),
322 }
323 assert_eq!(produced[0].as_deref(), Some(node_ids[0].as_str()));
325 assert_eq!(produced[1].as_deref(), Some(node_ids[1].as_str()));
326 assert!(produced[2].is_some());
327 }
328
329 #[test]
330 fn set_node_props_null_removes_key() {
331 let batch = serde_json::json!([
332 { "op": "set_node_props", "id": "00000000000000000000000000",
333 "props": { "stale": null, "status": "x" } }
334 ]);
335 let (ops, produced) = resolve_batch(&batch, Scope::Shared).unwrap();
336 match &ops[0] {
337 Op::SetNodeProps { props, .. } => {
338 assert_eq!(props["stale"], None);
339 assert_eq!(props["status"], Some(PropValue::Str("x".into())));
340 }
341 other => panic!("expected SetNodeProps, got {other:?}"),
342 }
343 assert_eq!(produced[0], None);
344 }
345
346 #[test]
347 fn forward_reference_is_rejected() {
348 let batch = serde_json::json!([
349 { "op": "link", "from": "#1", "to": "#1", "type": "x" },
350 { "op": "create_entity", "name": "late" }
351 ]);
352 let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
353 assert!(err.contains("earlier"), "got: {err}");
354 }
355
356 #[test]
357 fn node_slot_rejects_edge_backref() {
358 let batch = serde_json::json!([
361 { "op": "create_entity", "name": "a" },
362 { "op": "create_entity", "name": "b" },
363 { "op": "link", "from": "#0", "to": "#1", "type": "x" },
364 { "op": "link", "from": "#2", "to": "#0", "type": "y" }
365 ]);
366 let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
367 assert!(err.contains("edge") && err.contains("node"), "got: {err}");
368 }
369
370 #[test]
371 fn close_edge_backref_to_in_batch_link_is_allowed() {
372 let batch = serde_json::json!([
373 { "op": "create_entity", "name": "a" },
374 { "op": "create_entity", "name": "b" },
375 { "op": "link", "from": "#0", "to": "#1", "type": "x" },
376 { "op": "close_edge", "id": "#2" }
377 ]);
378 let (ops, _) = resolve_batch(&batch, Scope::Shared).unwrap();
379 let edge_id = match &ops[2] {
380 Op::CreateEdge { id, .. } => id.to_string(),
381 _ => unreachable!(),
382 };
383 match &ops[3] {
384 Op::CloseEdge { id, valid_to } => {
385 assert_eq!(id.to_string(), edge_id);
386 assert_eq!(*valid_to, None); }
388 other => panic!("expected CloseEdge, got {other:?}"),
389 }
390 }
391
392 #[test]
393 fn unknown_op_names_the_index() {
394 let batch = serde_json::json!([{ "op": "nope" }]);
395 let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
396 assert!(err.contains("#0") && err.contains("nope"), "got: {err}");
397 }
398
399 #[test]
400 fn non_array_batch_is_rejected() {
401 let err = resolve_batch(&serde_json::json!({"op": "x"}), Scope::Shared).unwrap_err();
402 assert!(err.contains("array"), "got: {err}");
403 }
404
405 #[test]
406 fn set_embedding_parses_vector() {
407 let batch = serde_json::json!([
408 { "op": "set_embedding", "id": "00000000000000000000000000",
409 "model": "m", "vector": [0.1, 0.2, 0.3] }
410 ]);
411 let (ops, _) = resolve_batch(&batch, Scope::Shared).unwrap();
412 match &ops[0] {
413 Op::SetEmbedding { model, vector, .. } => {
414 assert_eq!(model, "m");
415 assert_eq!(vector.len(), 3);
416 }
417 other => panic!("expected SetEmbedding, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn create_node_with_arbitrary_label_props_and_backref() {
423 let batch = serde_json::json!([
424 { "op": "create_node", "label": "Episode",
425 "props": { "goal": "fix the bug", "turns": 3 } },
426 { "op": "create_node", "label": "RetrievalEvent",
427 "props": { "query": "bug history" } },
428 { "op": "link", "from": "#0", "to": "#1", "type": "ISSUED" }
429 ]);
430 let (ops, ids) = resolve_batch(&batch, Scope::Shared).unwrap();
431 assert_eq!(ops.len(), 3);
432 assert!(ids[0].is_some() && ids[1].is_some() && ids[2].is_some());
433 match &ops[0] {
434 Op::CreateNode { label, props, .. } => {
435 assert_eq!(label, "Episode");
436 assert_eq!(
437 props.get("goal"),
438 Some(&PropValue::Str("fix the bug".into()))
439 );
440 assert_eq!(props.get("turns"), Some(&PropValue::Int(3)));
441 }
442 other => panic!("expected CreateNode, got {other:?}"),
443 }
444 match &ops[2] {
445 Op::CreateEdge { from, to, .. } => {
446 assert_eq!(from.to_string(), ids[0].clone().unwrap());
447 assert_eq!(to.to_string(), ids[1].clone().unwrap());
448 }
449 other => panic!("expected CreateEdge, got {other:?}"),
450 }
451 }
452
453 #[test]
454 fn link_honours_an_explicit_scope() {
455 let other = ScopeId::new();
456 let batch = serde_json::json!([
457 { "op": "create_entity", "name": "a" },
458 { "op": "create_entity", "name": "b" },
459 { "op": "link", "from": "#0", "to": "#1", "type": "x", "scope": other.to_string() }
460 ]);
461 let (ops, _produced) = resolve_batch(&batch, Scope::Shared).unwrap();
463 match &ops[2] {
464 Op::CreateEdge { scope, .. } => assert_eq!(*scope, Scope::Id(other)),
465 other_op => panic!("expected CreateEdge, got {other_op:?}"),
466 }
467 }
468
469 #[test]
470 fn create_node_requires_nonempty_label() {
471 for batch in [
472 serde_json::json!([{ "op": "create_node" }]),
473 serde_json::json!([{ "op": "create_node", "label": "" }]),
474 ] {
475 assert!(resolve_batch(&batch, Scope::Shared).is_err());
476 }
477 }
478
479 #[test]
480 fn create_node_rejects_reserved_labels() {
481 for (label, dedicated) in [("Entity", "create_entity"), ("Memory", "create_memory")] {
482 let batch = serde_json::json!([{ "op": "create_node", "label": label }]);
483 let err = resolve_batch(&batch, Scope::Shared).unwrap_err();
484 assert!(
485 err.contains("#0") && err.contains("reserved") && err.contains(dedicated),
486 "label {label:?}: got: {err}"
487 );
488 }
489 }
490
491 #[test]
492 fn create_node_scope_field_is_honored() {
493 let batch = serde_json::json!([
494 { "op": "create_node", "label": "Harness", "scope": "shared" }
495 ]);
496 let (ops, _) = resolve_batch(&batch, Scope::Id(topodb::ScopeId::new())).unwrap();
497 match &ops[0] {
498 Op::CreateNode { scope, .. } => assert_eq!(*scope, Scope::Shared),
499 other => panic!("expected CreateNode, got {other:?}"),
500 }
501 }
502
503 #[test]
504 fn link_without_scope_falls_back_to_the_default() {
505 let default_id = ScopeId::new();
506 let batch = serde_json::json!([
507 { "op": "create_entity", "name": "a" },
508 { "op": "create_entity", "name": "b" },
509 { "op": "link", "from": "#0", "to": "#1", "type": "x" }
510 ]);
511 let (ops, _produced) = resolve_batch(&batch, Scope::Id(default_id)).unwrap();
512 match &ops[2] {
513 Op::CreateEdge { scope, .. } => assert_eq!(*scope, Scope::Id(default_id)),
514 other_op => panic!("expected CreateEdge, got {other_op:?}"),
515 }
516 }
517}