1mod batch;
12pub use batch::resolve_batch;
13
14use serde_json::{Map, Value};
15use std::collections::BTreeMap;
16use std::str::FromStr;
17use topodb::{
18 EdgeRecord, IndexSpec, NodeRecord, PropIndex, PropValue, Props, Scope, ScopeId, ScopeSet,
19 Subgraph,
20};
21
22pub const ENTITY_LABEL: &str = "Entity";
30pub const ENTITY_NAME_PROP: &str = "name";
31pub const MEMORY_LABEL: &str = "Memory";
32pub const MEMORY_CONTENT_PROP: &str = "content";
33
34pub fn default_spec() -> IndexSpec {
49 IndexSpec {
50 equality: vec![PropIndex {
51 label: ENTITY_LABEL.into(),
52 prop: ENTITY_NAME_PROP.into(),
53 }],
54 text: vec![PropIndex {
55 label: MEMORY_LABEL.into(),
56 prop: MEMORY_CONTENT_PROP.into(),
57 }],
58 }
59}
60
61pub fn scope_label(scope: &Scope) -> String {
68 match scope {
69 Scope::Shared => "shared".to_string(),
70 Scope::Id(id) => id.to_string(),
71 }
72}
73
74pub const UNSUPPORTED: &str = "unsupported over MCP v0";
79
80pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
84 match v {
85 PropValue::Str(s) => Ok(Value::String(s.clone())),
86 PropValue::Int(i) => Ok(Value::Number((*i).into())),
87 PropValue::Float(f) => serde_json::Number::from_f64(*f)
88 .map(Value::Number)
89 .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
90 PropValue::Bool(b) => Ok(Value::Bool(*b)),
91 PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
92 }
93}
94
95pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
104 match v {
105 Value::String(s) => Ok(PropValue::Str(s.clone())),
106 Value::Bool(b) => Ok(PropValue::Bool(*b)),
107 Value::Number(n) => {
108 if let Some(i) = n.as_i64() {
109 Ok(PropValue::Int(i))
110 } else if n.is_u64() {
111 Err(format!("integer out of supported range (max {})", i64::MAX))
114 } else if let Some(f) = n.as_f64() {
115 Ok(PropValue::Float(f))
116 } else {
117 Err(format!("{UNSUPPORTED}: number out of range"))
118 }
119 }
120 Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
121 }
122}
123
124pub fn props_to_json(props: &Props) -> Result<Value, String> {
127 let mut map = Map::with_capacity(props.len());
128 for (k, v) in props {
129 map.insert(k.clone(), prop_value_to_json(v)?);
130 }
131 Ok(Value::Object(map))
132}
133
134pub fn json_to_props(v: &Value) -> Result<Props, String> {
151 let obj = v
152 .as_object()
153 .ok_or_else(|| "expected a JSON object for props".to_string())?;
154 let mut props = Props::new();
155 for (k, val) in obj {
156 props.insert(k.clone(), json_to_prop_value(val)?);
157 }
158 Ok(props)
159}
160
161pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
168 let obj = v
169 .as_object()
170 .ok_or_else(|| "expected a JSON object for props".to_string())?;
171 let mut out = BTreeMap::new();
172 for (k, val) in obj {
173 let entry = match val {
174 Value::Null => None,
175 other => Some(json_to_prop_value(other)?),
176 };
177 out.insert(k.clone(), entry);
178 }
179 Ok(out)
180}
181
182pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
187 let arr = v
188 .as_array()
189 .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
190 let mut out = Vec::with_capacity(arr.len());
191 for (i, el) in arr.iter().enumerate() {
192 let f = el
193 .as_f64()
194 .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
195 let f = f as f32;
196 if !f.is_finite() {
197 return Err(format!("vector element {i} is not finite"));
198 }
199 out.push(f);
200 }
201 Ok(out)
202}
203
204pub fn merge_required_prop(
215 key: &str,
216 value: PropValue,
217 extra: Option<&Value>,
218) -> Result<Props, String> {
219 let mut props = match extra {
220 Some(v) => json_to_props(v)?,
221 None => Props::new(),
222 };
223 if props.contains_key(key) {
224 return Err(format!(
225 "props must not include {key:?}: it is already set from the tool's own parameter"
226 ));
227 }
228 props.insert(key.to_string(), value);
229 Ok(props)
230}
231
232pub fn scope_to_json(scope: Scope) -> Value {
236 Value::String(match scope {
237 Scope::Shared => "shared".to_string(),
238 Scope::Id(id) => id.to_string(),
239 })
240}
241
242pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
247 let mut map = Map::new();
248 map.insert("id".into(), Value::String(n.id.to_string()));
249 map.insert("scope".into(), scope_to_json(n.scope));
250 map.insert("label".into(), Value::String(n.label.to_string()));
251 map.insert("props".into(), props_to_json(&n.props)?);
252 Ok(Value::Object(map))
253}
254
255pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
260 let mut map = Map::new();
261 map.insert("id".into(), Value::String(e.id.to_string()));
262 map.insert("scope".into(), scope_to_json(e.scope));
263 map.insert("type".into(), Value::String(e.ty.to_string()));
264 map.insert("from".into(), Value::String(e.from.to_string()));
265 map.insert("to".into(), Value::String(e.to.to_string()));
266 map.insert("props".into(), props_to_json(&e.props)?);
267 map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
268 map.insert(
269 "valid_to".into(),
270 match e.valid_to {
271 Some(t) => Value::Number(t.into()),
272 None => Value::Null,
273 },
274 );
275 Ok(Value::Object(map))
276}
277
278pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
281 let nodes: Vec<Value> = sg
282 .nodes
283 .iter()
284 .map(node_to_json)
285 .collect::<Result<_, _>>()?;
286 let edges: Vec<Value> = sg
287 .edges
288 .iter()
289 .map(edge_to_json)
290 .collect::<Result<_, _>>()?;
291 Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
292}
293
294pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
300 match scope {
301 None => Ok(default),
302 Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
303 Some(s) => ScopeId::from_str(s)
304 .map(Scope::Id)
305 .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
306 }
307}
308
309pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
312 match scope {
313 Scope::Shared => ScopeSet::default().with_shared(),
314 Scope::Id(id) => ScopeSet::of(&[id]),
315 }
316}
317
318pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
328 let ids: Vec<ScopeId> = scopes
329 .iter()
330 .filter_map(|s| match s {
331 Scope::Id(id) => Some(*id),
332 Scope::Shared => None,
333 })
334 .collect();
335 let set = ScopeSet::of(&ids);
336 if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
337 set.with_shared()
338 } else {
339 set
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use topodb::NodeId;
347
348 fn props(pairs: &[(&str, PropValue)]) -> Props {
349 pairs
350 .iter()
351 .cloned()
352 .map(|(k, v)| (k.to_string(), v))
353 .collect()
354 }
355
356 #[test]
359 fn str_round_trips() {
360 let v = PropValue::Str("hello".into());
361 let j = prop_value_to_json(&v).unwrap();
362 assert_eq!(j, Value::String("hello".into()));
363 assert_eq!(json_to_prop_value(&j).unwrap(), v);
364 }
365
366 #[test]
367 fn int_round_trips() {
368 let v = PropValue::Int(-42);
369 let j = prop_value_to_json(&v).unwrap();
370 assert_eq!(j, serde_json::json!(-42));
371 assert_eq!(json_to_prop_value(&j).unwrap(), v);
372 }
373
374 #[test]
375 fn bool_round_trips() {
376 for b in [true, false] {
377 let v = PropValue::Bool(b);
378 let j = prop_value_to_json(&v).unwrap();
379 assert_eq!(j, Value::Bool(b));
380 assert_eq!(json_to_prop_value(&j).unwrap(), v);
381 }
382 }
383
384 #[test]
387 fn float_to_json_is_a_json_number() {
388 let v = PropValue::Float(3.5);
389 let j = prop_value_to_json(&v).unwrap();
390 assert_eq!(j, serde_json::json!(3.5));
391 }
392
393 #[test]
394 fn json_integer_literal_decodes_to_int_not_float() {
395 let j = serde_json::json!(7);
396 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
397 }
398
399 #[test]
400 fn json_float_literal_decodes_to_float() {
401 let j = serde_json::json!(7.5);
402 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
403 }
404
405 #[test]
406 fn i64_max_round_trips_as_int() {
407 let v = PropValue::Int(i64::MAX);
408 let j = prop_value_to_json(&v).unwrap();
409 assert_eq!(j, serde_json::json!(i64::MAX));
410 assert_eq!(json_to_prop_value(&j).unwrap(), v);
411 }
412
413 #[test]
414 fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
415 let j = serde_json::json!(u64::MAX);
416 let err = json_to_prop_value(&j).unwrap_err();
417 assert!(
418 err.contains("integer out of supported range"),
419 "expected a clear out-of-range error, got: {err}"
420 );
421 let j = serde_json::json!(i64::MAX as u64 + 1);
423 assert!(json_to_prop_value(&j).is_err());
424 }
425
426 #[test]
427 fn non_finite_float_to_json_is_an_error() {
428 assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
429 assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
430 }
431
432 #[test]
435 fn bytes_to_json_is_unsupported() {
436 let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
437 assert_eq!(err, UNSUPPORTED);
438 }
439
440 #[test]
441 fn datetime_to_json_is_unsupported() {
442 let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
443 assert_eq!(err, UNSUPPORTED);
444 }
445
446 #[test]
447 fn json_array_to_propvalue_is_unsupported() {
448 let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
449 assert_eq!(err, UNSUPPORTED);
450 }
451
452 #[test]
453 fn json_object_to_propvalue_is_unsupported() {
454 let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
455 assert_eq!(err, UNSUPPORTED);
456 }
457
458 #[test]
459 fn json_null_to_propvalue_is_unsupported() {
460 let err = json_to_prop_value(&Value::Null).unwrap_err();
461 assert_eq!(err, UNSUPPORTED);
462 }
463
464 #[test]
467 fn props_round_trip() {
468 let p = props(&[
469 ("name", PropValue::Str("ada".into())),
470 ("age", PropValue::Int(30)),
471 ("active", PropValue::Bool(true)),
472 ("score", PropValue::Float(1.5)),
473 ]);
474 let j = props_to_json(&p).unwrap();
475 assert!(j.is_object());
476 let back = json_to_props(&j).unwrap();
477 assert_eq!(back, p);
478 }
479
480 #[test]
481 fn props_to_json_propagates_unsupported_value() {
482 let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
483 assert!(props_to_json(&p).is_err());
484 }
485
486 #[test]
487 fn json_to_props_rejects_non_object() {
488 assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
489 }
490
491 #[test]
492 fn json_to_props_propagates_unsupported_field() {
493 let j = serde_json::json!({"bad": [1, 2]});
494 assert!(json_to_props(&j).is_err());
495 }
496
497 #[test]
500 fn merge_required_prop_with_no_extra_just_sets_the_key() {
501 let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
502 assert_eq!(props.len(), 1);
503 assert_eq!(props["content"], PropValue::Str("hi".into()));
504 }
505
506 #[test]
507 fn merge_required_prop_merges_additional_fields() {
508 let extra = serde_json::json!({"source": "chat", "confidence": 3});
509 let props =
510 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
511 assert_eq!(props.len(), 3);
512 assert_eq!(props["content"], PropValue::Str("hi".into()));
513 assert_eq!(props["source"], PropValue::Str("chat".into()));
514 assert_eq!(props["confidence"], PropValue::Int(3));
515 }
516
517 #[test]
518 fn merge_required_prop_rejects_collision_with_required_key() {
519 let extra = serde_json::json!({"content": "sneaky overwrite"});
520 let err =
521 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
522 assert!(
523 err.contains("content"),
524 "error should name the colliding key: {err}"
525 );
526 let extra = serde_json::json!({"name": "sneaky"});
529 assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
530 }
531
532 #[test]
533 fn merge_required_prop_does_not_overwrite_on_collision() {
534 let extra = serde_json::json!({"content": "other"});
537 let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn merge_required_prop_propagates_non_object_extra() {
543 let extra = serde_json::json!([1, 2]);
544 assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
545 }
546
547 fn sample_node(scope: Scope) -> NodeRecord {
550 NodeRecord {
551 id: NodeId::new(),
552 scope,
553 label: "Entity".into(),
554 props: props(&[("name", PropValue::Str("ada".into()))]),
555 embedding: None,
556 }
557 }
558
559 fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
560 EdgeRecord {
561 id: topodb::EdgeId::new(),
562 scope,
563 ty: "ABOUT".into(),
564 from,
565 to,
566 props: Props::new(),
567 valid_from: 1_000,
568 valid_to: None,
569 }
570 }
571
572 #[test]
573 fn node_to_json_has_ulid_id_and_declared_fields() {
574 let scope = Scope::Id(ScopeId::new());
575 let n = sample_node(scope);
576 let j = node_to_json(&n).unwrap();
577 assert_eq!(j["id"], Value::String(n.id.to_string()));
578 assert_eq!(j["label"], Value::String("Entity".into()));
579 assert_eq!(j["scope"], scope_to_json(scope));
580 assert_eq!(j["props"]["name"], Value::String("ada".into()));
581 let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
583 assert_eq!(parsed, n.id);
584 }
585
586 #[test]
587 fn node_to_json_propagates_unsupported_prop() {
588 let mut n = sample_node(Scope::Shared);
589 n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
590 assert!(node_to_json(&n).is_err());
591 }
592
593 #[test]
594 fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
595 let scope = Scope::Shared;
596 let a = NodeId::new();
597 let b = NodeId::new();
598 let e = sample_edge(scope, a, b);
599 let j = edge_to_json(&e).unwrap();
600 assert_eq!(j["id"], Value::String(e.id.to_string()));
601 assert_eq!(j["from"], Value::String(a.to_string()));
602 assert_eq!(j["to"], Value::String(b.to_string()));
603 assert_eq!(j["type"], Value::String("ABOUT".into()));
604 assert_eq!(j["valid_from"], serde_json::json!(1_000));
605 assert_eq!(j["valid_to"], Value::Null);
606 }
607
608 #[test]
609 fn edge_to_json_closed_edge_has_numeric_valid_to() {
610 let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
611 e.valid_to = Some(2_000);
612 let j = edge_to_json(&e).unwrap();
613 assert_eq!(j["valid_to"], serde_json::json!(2_000));
614 }
615
616 #[test]
617 fn subgraph_to_json_nests_nodes_and_edges() {
618 let scope = Scope::Shared;
619 let a = sample_node(scope);
620 let b = sample_node(scope);
621 let e = sample_edge(scope, a.id, b.id);
622 let sg = Subgraph {
623 nodes: vec![a.clone(), b.clone()],
624 edges: vec![e.clone()],
625 };
626 let j = subgraph_to_json(&sg).unwrap();
627 assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
628 assert_eq!(j["edges"].as_array().unwrap().len(), 1);
629 assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
630 }
631
632 #[test]
635 fn resolve_scope_none_uses_default() {
636 let id = ScopeId::new();
637 assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
638 assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
639 }
640
641 #[test]
642 fn resolve_scope_shared_is_case_insensitive() {
643 assert_eq!(
644 resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
645 Scope::Shared
646 );
647 assert_eq!(
648 resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
649 Scope::Shared
650 );
651 }
652
653 #[test]
654 fn resolve_scope_ulid_parses_to_id() {
655 let id = ScopeId::new();
656 let s = id.to_string();
657 assert_eq!(
658 resolve_scope(Some(&s), Scope::Shared).unwrap(),
659 Scope::Id(id)
660 );
661 }
662
663 #[test]
664 fn resolve_scope_garbage_is_a_clear_error() {
665 let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
666 assert!(err.contains("not-a-ulid"));
667 }
668
669 #[test]
670 fn scope_to_scope_set_shared_admits_only_shared() {
671 let set = scope_to_scope_set(Scope::Shared);
672 assert!(set.contains(Scope::Shared));
673 assert!(!set.contains(Scope::Id(ScopeId::new())));
674 }
675
676 #[test]
677 fn scope_to_scope_set_id_admits_only_that_id() {
678 let id = ScopeId::new();
679 let set = scope_to_scope_set(Scope::Id(id));
680 assert!(set.contains(Scope::Id(id)));
681 assert!(!set.contains(Scope::Shared));
682 assert!(!set.contains(Scope::Id(ScopeId::new())));
683 }
684
685 #[test]
686 fn scopes_to_scope_set_admits_every_member() {
687 let a = ScopeId::new();
688 let b = ScopeId::new();
689 let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
690 assert!(set.contains(Scope::Id(a)));
691 assert!(set.contains(Scope::Id(b)));
692 assert!(set.contains(Scope::Shared));
693 }
694
695 #[test]
696 fn scopes_to_scope_set_without_shared_excludes_shared() {
697 let a = ScopeId::new();
698 let set = scopes_to_scope_set(&[Scope::Id(a)]);
699 assert!(set.contains(Scope::Id(a)));
700 assert!(!set.contains(Scope::Shared));
701 }
702
703 #[test]
704 fn scopes_to_scope_set_matches_singleton_for_one_member() {
705 let a = ScopeId::new();
710 let multi = scopes_to_scope_set(&[Scope::Id(a)]);
711 let single = scope_to_scope_set(Scope::Id(a));
712 assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
713 assert_eq!(
714 multi.contains(Scope::Shared),
715 single.contains(Scope::Shared)
716 );
717
718 let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
719 let single_shared = scope_to_scope_set(Scope::Shared);
720 assert_eq!(
721 multi_shared.contains(Scope::Shared),
722 single_shared.contains(Scope::Shared)
723 );
724 }
725
726 #[test]
727 fn scopes_to_scope_set_empty_admits_nothing() {
728 let a = ScopeId::new();
729 let set = scopes_to_scope_set(&[]);
730 assert!(!set.contains(Scope::Shared));
731 assert!(!set.contains(Scope::Id(a)));
732 }
733
734 #[test]
737 fn prop_changes_null_is_remove_scalar_is_set() {
738 let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
739 let changes = json_to_prop_changes(&j).unwrap();
740 assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
741 assert_eq!(changes["stale"], None);
742 assert_eq!(changes["n"], Some(PropValue::Int(3)));
743 }
744
745 #[test]
746 fn prop_changes_rejects_non_object() {
747 assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
748 }
749
750 #[test]
751 fn prop_changes_propagates_unsupported_value() {
752 assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
754 }
755
756 #[test]
759 fn f32_vec_parses_numbers() {
760 let j = serde_json::json!([0.0, 1.5, -2, 3]);
761 assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
762 }
763
764 #[test]
765 fn f32_vec_rejects_non_array() {
766 assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
767 }
768
769 #[test]
770 fn f32_vec_rejects_non_number_element() {
771 assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
772 }
773
774 #[test]
775 fn f32_vec_rejects_overflow_to_infinity() {
776 assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
778 }
779}