1mod batch;
13pub use batch::resolve_batch;
14
15mod compose;
16pub use compose::{
17 content_hash, entity_dedup_key, existing_memory, find_existing_entity, memory_props,
18 normalize_content, plan_remember, resolve_entities_by_name, ComposeError, PlannedEntity,
19 RememberPlan, RememberRequest, DEFAULT_REMEMBER_EDGE_TYPE,
20};
21
22mod retry;
23pub use retry::open_with_busy_retry;
24
25use serde_json::{Map, Value};
26use std::collections::BTreeMap;
27use std::str::FromStr;
28use topodb::{
29 EdgeRecord, IndexSpec, NodeRecord, PropIndex, PropValue, Props, Scope, ScopeId, ScopeSet,
30 Subgraph,
31};
32
33pub const ENTITY_LABEL: &str = "Entity";
41pub const ENTITY_NAME_PROP: &str = "name";
42pub const MEMORY_LABEL: &str = "Memory";
43pub const MEMORY_CONTENT_PROP: &str = "content";
44pub const MEMORY_CONTENT_HASH_PROP: &str = "content_hash";
48pub const MEMORY_SUPERSEDED_AT_PROP: &str = "superseded_at";
53pub const ALIAS_LABEL: &str = "Alias";
54pub const ALIAS_NAME_PROP: &str = "name";
55pub const ALIAS_EDGE_TYPE: &str = "alias_of";
56pub const SYNONYM_LABEL: &str = "Synonym";
57pub const SYNONYM_TERM_PROP: &str = "term";
58pub const SYNONYM_EXPANSION_PROP: &str = "expansion";
59
60pub fn default_spec() -> IndexSpec {
75 IndexSpec {
76 equality: vec![
77 PropIndex {
78 label: ENTITY_LABEL.into(),
79 prop: ENTITY_NAME_PROP.into(),
80 },
81 PropIndex {
84 label: ALIAS_LABEL.into(),
85 prop: ALIAS_NAME_PROP.into(),
86 },
87 PropIndex {
88 label: SYNONYM_LABEL.into(),
89 prop: SYNONYM_TERM_PROP.into(),
90 },
91 PropIndex {
94 label: MEMORY_LABEL.into(),
95 prop: MEMORY_CONTENT_HASH_PROP.into(),
96 },
97 ],
98 text: vec![
99 PropIndex {
100 label: MEMORY_LABEL.into(),
101 prop: MEMORY_CONTENT_PROP.into(),
102 },
103 PropIndex {
109 label: ENTITY_LABEL.into(),
110 prop: ENTITY_NAME_PROP.into(),
111 },
112 PropIndex {
113 label: ALIAS_LABEL.into(),
114 prop: ALIAS_NAME_PROP.into(),
115 },
116 ],
117 }
118}
119
120fn stock_generations() -> Vec<IndexSpec> {
125 let g0 = IndexSpec {
127 equality: vec![PropIndex {
128 label: ENTITY_LABEL.into(),
129 prop: ENTITY_NAME_PROP.into(),
130 }],
131 text: vec![PropIndex {
132 label: MEMORY_LABEL.into(),
133 prop: MEMORY_CONTENT_PROP.into(),
134 }],
135 };
136 let g1 = IndexSpec {
138 equality: g0.equality.clone(),
139 text: vec![
140 PropIndex {
141 label: MEMORY_LABEL.into(),
142 prop: MEMORY_CONTENT_PROP.into(),
143 },
144 PropIndex {
145 label: ENTITY_LABEL.into(),
146 prop: ENTITY_NAME_PROP.into(),
147 },
148 ],
149 };
150 vec![g0, g1]
151}
152
153pub fn upgraded_spec(persisted: IndexSpec) -> IndexSpec {
161 let sorted = |spec: &IndexSpec| {
162 let mut eq: Vec<(String, String)> = spec
163 .equality
164 .iter()
165 .map(|p| (p.label.to_string(), p.prop.clone()))
166 .collect();
167 let mut text: Vec<(String, String)> = spec
168 .text
169 .iter()
170 .map(|p| (p.label.to_string(), p.prop.clone()))
171 .collect();
172 eq.sort();
173 text.sort();
174 (eq, text)
175 };
176 let p = sorted(&persisted);
177 if stock_generations().iter().any(|g| sorted(g) == p) {
178 default_spec()
179 } else {
180 persisted
181 }
182}
183
184pub fn normalize_edge_type(raw: &str) -> Result<String, String> {
194 let lowered = raw.to_lowercase();
195 let mut out = String::with_capacity(lowered.len());
196 let mut pending_sep = false;
197 for c in lowered.chars() {
198 if c.is_whitespace() || c == '-' || c == '_' {
199 if !out.is_empty() {
200 pending_sep = true;
201 }
202 } else {
203 if pending_sep {
204 out.push('_');
205 pending_sep = false;
206 }
207 out.push(c);
208 }
209 }
210 if out.is_empty() {
211 return Err(format!(
212 "edge type {raw:?} is empty once normalized (lowercase, separators collapsed to '_')"
213 ));
214 }
215 Ok(out)
216}
217
218pub fn scope_label(scope: &Scope) -> String {
225 match scope {
226 Scope::Shared => "shared".to_string(),
227 Scope::Id(id) => id.to_string(),
228 }
229}
230
231pub const UNSUPPORTED: &str = "unsupported over MCP v0";
236
237pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
241 match v {
242 PropValue::Str(s) => Ok(Value::String(s.clone())),
243 PropValue::Int(i) => Ok(Value::Number((*i).into())),
244 PropValue::Float(f) => serde_json::Number::from_f64(*f)
245 .map(Value::Number)
246 .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
247 PropValue::Bool(b) => Ok(Value::Bool(*b)),
248 PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
249 }
250}
251
252pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
261 match v {
262 Value::String(s) => Ok(PropValue::Str(s.clone())),
263 Value::Bool(b) => Ok(PropValue::Bool(*b)),
264 Value::Number(n) => {
265 if let Some(i) = n.as_i64() {
266 Ok(PropValue::Int(i))
267 } else if n.is_u64() {
268 Err(format!("integer out of supported range (max {})", i64::MAX))
271 } else if let Some(f) = n.as_f64() {
272 Ok(PropValue::Float(f))
273 } else {
274 Err(format!("{UNSUPPORTED}: number out of range"))
275 }
276 }
277 Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
278 }
279}
280
281pub fn props_to_json(props: &Props) -> Result<Value, String> {
284 let mut map = Map::with_capacity(props.len());
285 for (k, v) in props {
286 map.insert(k.clone(), prop_value_to_json(v)?);
287 }
288 Ok(Value::Object(map))
289}
290
291pub fn json_to_props(v: &Value) -> Result<Props, String> {
308 let obj = v
309 .as_object()
310 .ok_or_else(|| "expected a JSON object for props".to_string())?;
311 let mut props = Props::new();
312 for (k, val) in obj {
313 props.insert(k.clone(), json_to_prop_value(val)?);
314 }
315 Ok(props)
316}
317
318pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
325 let obj = v
326 .as_object()
327 .ok_or_else(|| "expected a JSON object for props".to_string())?;
328 let mut out = BTreeMap::new();
329 for (k, val) in obj {
330 let entry = match val {
331 Value::Null => None,
332 other => Some(json_to_prop_value(other)?),
333 };
334 out.insert(k.clone(), entry);
335 }
336 Ok(out)
337}
338
339pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
344 let arr = v
345 .as_array()
346 .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
347 let mut out = Vec::with_capacity(arr.len());
348 for (i, el) in arr.iter().enumerate() {
349 let f = el
350 .as_f64()
351 .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
352 let f = f as f32;
353 if !f.is_finite() {
354 return Err(format!("vector element {i} is not finite"));
355 }
356 out.push(f);
357 }
358 Ok(out)
359}
360
361pub fn merge_required_prop(
372 key: &str,
373 value: PropValue,
374 extra: Option<&Value>,
375) -> Result<Props, String> {
376 let mut props = match extra {
377 Some(v) => json_to_props(v)?,
378 None => Props::new(),
379 };
380 if props.contains_key(key) {
381 return Err(format!(
382 "props must not include {key:?}: it is already set from the tool's own parameter"
383 ));
384 }
385 props.insert(key.to_string(), value);
386 Ok(props)
387}
388
389pub fn scope_to_json(scope: Scope) -> Value {
393 Value::String(match scope {
394 Scope::Shared => "shared".to_string(),
395 Scope::Id(id) => id.to_string(),
396 })
397}
398
399pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
404 let mut map = Map::new();
405 map.insert("id".into(), Value::String(n.id.to_string()));
406 map.insert("scope".into(), scope_to_json(n.scope));
407 map.insert("label".into(), Value::String(n.label.to_string()));
408 map.insert("props".into(), props_to_json(&n.props)?);
409 Ok(Value::Object(map))
410}
411
412pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
417 let mut map = Map::new();
418 map.insert("id".into(), Value::String(e.id.to_string()));
419 map.insert("scope".into(), scope_to_json(e.scope));
420 map.insert("type".into(), Value::String(e.ty.to_string()));
421 map.insert("from".into(), Value::String(e.from.to_string()));
422 map.insert("to".into(), Value::String(e.to.to_string()));
423 map.insert("props".into(), props_to_json(&e.props)?);
424 map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
425 map.insert(
426 "valid_to".into(),
427 match e.valid_to {
428 Some(t) => Value::Number(t.into()),
429 None => Value::Null,
430 },
431 );
432 Ok(Value::Object(map))
433}
434
435pub fn edge_live_at(e: &EdgeRecord, t: i64) -> bool {
444 e.valid_from <= t && e.valid_to.is_none_or(|vt| vt > t)
445}
446
447pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
450 let nodes: Vec<Value> = sg
451 .nodes
452 .iter()
453 .map(node_to_json)
454 .collect::<Result<_, _>>()?;
455 let edges: Vec<Value> = sg
456 .edges
457 .iter()
458 .map(edge_to_json)
459 .collect::<Result<_, _>>()?;
460 Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
461}
462
463pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
469 match scope {
470 None => Ok(default),
471 Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
472 Some(s) => ScopeId::from_str(s)
473 .map(Scope::Id)
474 .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
475 }
476}
477
478pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
481 match scope {
482 Scope::Shared => ScopeSet::default().with_shared(),
483 Scope::Id(id) => ScopeSet::of(&[id]),
484 }
485}
486
487pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
497 let ids: Vec<ScopeId> = scopes
498 .iter()
499 .filter_map(|s| match s {
500 Scope::Id(id) => Some(*id),
501 Scope::Shared => None,
502 })
503 .collect();
504 let set = ScopeSet::of(&ids);
505 if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
506 set.with_shared()
507 } else {
508 set
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use topodb::NodeId;
516
517 fn props(pairs: &[(&str, PropValue)]) -> Props {
518 pairs
519 .iter()
520 .cloned()
521 .map(|(k, v)| (k.to_string(), v))
522 .collect()
523 }
524
525 #[test]
528 fn str_round_trips() {
529 let v = PropValue::Str("hello".into());
530 let j = prop_value_to_json(&v).unwrap();
531 assert_eq!(j, Value::String("hello".into()));
532 assert_eq!(json_to_prop_value(&j).unwrap(), v);
533 }
534
535 #[test]
536 fn int_round_trips() {
537 let v = PropValue::Int(-42);
538 let j = prop_value_to_json(&v).unwrap();
539 assert_eq!(j, serde_json::json!(-42));
540 assert_eq!(json_to_prop_value(&j).unwrap(), v);
541 }
542
543 #[test]
544 fn bool_round_trips() {
545 for b in [true, false] {
546 let v = PropValue::Bool(b);
547 let j = prop_value_to_json(&v).unwrap();
548 assert_eq!(j, Value::Bool(b));
549 assert_eq!(json_to_prop_value(&j).unwrap(), v);
550 }
551 }
552
553 #[test]
556 fn float_to_json_is_a_json_number() {
557 let v = PropValue::Float(3.5);
558 let j = prop_value_to_json(&v).unwrap();
559 assert_eq!(j, serde_json::json!(3.5));
560 }
561
562 #[test]
563 fn json_integer_literal_decodes_to_int_not_float() {
564 let j = serde_json::json!(7);
565 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
566 }
567
568 #[test]
569 fn json_float_literal_decodes_to_float() {
570 let j = serde_json::json!(7.5);
571 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
572 }
573
574 #[test]
575 fn i64_max_round_trips_as_int() {
576 let v = PropValue::Int(i64::MAX);
577 let j = prop_value_to_json(&v).unwrap();
578 assert_eq!(j, serde_json::json!(i64::MAX));
579 assert_eq!(json_to_prop_value(&j).unwrap(), v);
580 }
581
582 #[test]
583 fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
584 let j = serde_json::json!(u64::MAX);
585 let err = json_to_prop_value(&j).unwrap_err();
586 assert!(
587 err.contains("integer out of supported range"),
588 "expected a clear out-of-range error, got: {err}"
589 );
590 let j = serde_json::json!(i64::MAX as u64 + 1);
592 assert!(json_to_prop_value(&j).is_err());
593 }
594
595 #[test]
596 fn non_finite_float_to_json_is_an_error() {
597 assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
598 assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
599 }
600
601 #[test]
604 fn bytes_to_json_is_unsupported() {
605 let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
606 assert_eq!(err, UNSUPPORTED);
607 }
608
609 #[test]
610 fn datetime_to_json_is_unsupported() {
611 let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
612 assert_eq!(err, UNSUPPORTED);
613 }
614
615 #[test]
616 fn json_array_to_propvalue_is_unsupported() {
617 let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
618 assert_eq!(err, UNSUPPORTED);
619 }
620
621 #[test]
622 fn json_object_to_propvalue_is_unsupported() {
623 let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
624 assert_eq!(err, UNSUPPORTED);
625 }
626
627 #[test]
628 fn json_null_to_propvalue_is_unsupported() {
629 let err = json_to_prop_value(&Value::Null).unwrap_err();
630 assert_eq!(err, UNSUPPORTED);
631 }
632
633 #[test]
636 fn props_round_trip() {
637 let p = props(&[
638 ("name", PropValue::Str("ada".into())),
639 ("age", PropValue::Int(30)),
640 ("active", PropValue::Bool(true)),
641 ("score", PropValue::Float(1.5)),
642 ]);
643 let j = props_to_json(&p).unwrap();
644 assert!(j.is_object());
645 let back = json_to_props(&j).unwrap();
646 assert_eq!(back, p);
647 }
648
649 #[test]
650 fn props_to_json_propagates_unsupported_value() {
651 let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
652 assert!(props_to_json(&p).is_err());
653 }
654
655 #[test]
656 fn json_to_props_rejects_non_object() {
657 assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
658 }
659
660 #[test]
661 fn json_to_props_propagates_unsupported_field() {
662 let j = serde_json::json!({"bad": [1, 2]});
663 assert!(json_to_props(&j).is_err());
664 }
665
666 #[test]
669 fn merge_required_prop_with_no_extra_just_sets_the_key() {
670 let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
671 assert_eq!(props.len(), 1);
672 assert_eq!(props["content"], PropValue::Str("hi".into()));
673 }
674
675 #[test]
676 fn merge_required_prop_merges_additional_fields() {
677 let extra = serde_json::json!({"source": "chat", "confidence": 3});
678 let props =
679 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
680 assert_eq!(props.len(), 3);
681 assert_eq!(props["content"], PropValue::Str("hi".into()));
682 assert_eq!(props["source"], PropValue::Str("chat".into()));
683 assert_eq!(props["confidence"], PropValue::Int(3));
684 }
685
686 #[test]
687 fn merge_required_prop_rejects_collision_with_required_key() {
688 let extra = serde_json::json!({"content": "sneaky overwrite"});
689 let err =
690 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
691 assert!(
692 err.contains("content"),
693 "error should name the colliding key: {err}"
694 );
695 let extra = serde_json::json!({"name": "sneaky"});
698 assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
699 }
700
701 #[test]
702 fn merge_required_prop_does_not_overwrite_on_collision() {
703 let extra = serde_json::json!({"content": "other"});
706 let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
707 assert!(result.is_err());
708 }
709
710 #[test]
711 fn merge_required_prop_propagates_non_object_extra() {
712 let extra = serde_json::json!([1, 2]);
713 assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
714 }
715
716 fn sample_node(scope: Scope) -> NodeRecord {
719 NodeRecord {
720 id: NodeId::new(),
721 scope,
722 label: "Entity".into(),
723 props: props(&[("name", PropValue::Str("ada".into()))]),
724 embedding: None,
725 }
726 }
727
728 fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
729 EdgeRecord {
730 id: topodb::EdgeId::new(),
731 scope,
732 ty: "ABOUT".into(),
733 from,
734 to,
735 props: Props::new(),
736 valid_from: 1_000,
737 valid_to: None,
738 }
739 }
740
741 #[test]
742 fn node_to_json_has_ulid_id_and_declared_fields() {
743 let scope = Scope::Id(ScopeId::new());
744 let n = sample_node(scope);
745 let j = node_to_json(&n).unwrap();
746 assert_eq!(j["id"], Value::String(n.id.to_string()));
747 assert_eq!(j["label"], Value::String("Entity".into()));
748 assert_eq!(j["scope"], scope_to_json(scope));
749 assert_eq!(j["props"]["name"], Value::String("ada".into()));
750 let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
752 assert_eq!(parsed, n.id);
753 }
754
755 #[test]
756 fn node_to_json_propagates_unsupported_prop() {
757 let mut n = sample_node(Scope::Shared);
758 n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
759 assert!(node_to_json(&n).is_err());
760 }
761
762 #[test]
763 fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
764 let scope = Scope::Shared;
765 let a = NodeId::new();
766 let b = NodeId::new();
767 let e = sample_edge(scope, a, b);
768 let j = edge_to_json(&e).unwrap();
769 assert_eq!(j["id"], Value::String(e.id.to_string()));
770 assert_eq!(j["from"], Value::String(a.to_string()));
771 assert_eq!(j["to"], Value::String(b.to_string()));
772 assert_eq!(j["type"], Value::String("ABOUT".into()));
773 assert_eq!(j["valid_from"], serde_json::json!(1_000));
774 assert_eq!(j["valid_to"], Value::Null);
775 }
776
777 #[test]
778 fn edge_to_json_closed_edge_has_numeric_valid_to() {
779 let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
780 e.valid_to = Some(2_000);
781 let j = edge_to_json(&e).unwrap();
782 assert_eq!(j["valid_to"], serde_json::json!(2_000));
783 }
784
785 #[test]
786 fn subgraph_to_json_nests_nodes_and_edges() {
787 let scope = Scope::Shared;
788 let a = sample_node(scope);
789 let b = sample_node(scope);
790 let e = sample_edge(scope, a.id, b.id);
791 let sg = Subgraph {
792 nodes: vec![a.clone(), b.clone()],
793 edges: vec![e.clone()],
794 };
795 let j = subgraph_to_json(&sg).unwrap();
796 assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
797 assert_eq!(j["edges"].as_array().unwrap().len(), 1);
798 assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
799 }
800
801 #[test]
804 fn edge_type_variants_normalize_to_one_form() {
805 for raw in [
806 "works_at",
807 "Works At",
808 "works-at",
809 "WORKS_AT",
810 " works at ",
811 "works--at",
812 "works_-at",
813 ] {
814 assert_eq!(
815 normalize_edge_type(raw).unwrap(),
816 "works_at",
817 "{raw:?} should normalize to works_at"
818 );
819 }
820 assert_eq!(normalize_edge_type("about").unwrap(), "about");
821 }
822
823 #[test]
824 fn edge_type_empty_after_normalization_is_an_error() {
825 for raw in ["", " ", "---", "_", " - _ "] {
826 assert!(normalize_edge_type(raw).is_err(), "{raw:?} should error");
827 }
828 }
829
830 #[test]
833 fn legacy_stock_spec_upgrades_to_current_default() {
834 let legacy = IndexSpec {
835 equality: vec![PropIndex {
836 label: ENTITY_LABEL.into(),
837 prop: ENTITY_NAME_PROP.into(),
838 }],
839 text: vec![PropIndex {
840 label: MEMORY_LABEL.into(),
841 prop: MEMORY_CONTENT_PROP.into(),
842 }],
843 };
844 assert_eq!(upgraded_spec(legacy), default_spec());
845 assert_eq!(upgraded_spec(default_spec()), default_spec());
848 }
849
850 #[test]
851 fn customized_spec_is_never_rewritten() {
852 let custom = IndexSpec {
853 equality: vec![PropIndex {
854 label: "Person".into(),
855 prop: "handle".into(),
856 }],
857 text: vec![PropIndex {
858 label: MEMORY_LABEL.into(),
859 prop: MEMORY_CONTENT_PROP.into(),
860 }],
861 };
862 assert_eq!(upgraded_spec(custom.clone()), custom);
863 }
864
865 #[test]
868 fn resolve_scope_none_uses_default() {
869 let id = ScopeId::new();
870 assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
871 assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
872 }
873
874 #[test]
875 fn resolve_scope_shared_is_case_insensitive() {
876 assert_eq!(
877 resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
878 Scope::Shared
879 );
880 assert_eq!(
881 resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
882 Scope::Shared
883 );
884 }
885
886 #[test]
887 fn resolve_scope_ulid_parses_to_id() {
888 let id = ScopeId::new();
889 let s = id.to_string();
890 assert_eq!(
891 resolve_scope(Some(&s), Scope::Shared).unwrap(),
892 Scope::Id(id)
893 );
894 }
895
896 #[test]
897 fn resolve_scope_garbage_is_a_clear_error() {
898 let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
899 assert!(err.contains("not-a-ulid"));
900 }
901
902 #[test]
903 fn scope_to_scope_set_shared_admits_only_shared() {
904 let set = scope_to_scope_set(Scope::Shared);
905 assert!(set.contains(Scope::Shared));
906 assert!(!set.contains(Scope::Id(ScopeId::new())));
907 }
908
909 #[test]
910 fn scope_to_scope_set_id_admits_only_that_id() {
911 let id = ScopeId::new();
912 let set = scope_to_scope_set(Scope::Id(id));
913 assert!(set.contains(Scope::Id(id)));
914 assert!(!set.contains(Scope::Shared));
915 assert!(!set.contains(Scope::Id(ScopeId::new())));
916 }
917
918 #[test]
919 fn scopes_to_scope_set_admits_every_member() {
920 let a = ScopeId::new();
921 let b = ScopeId::new();
922 let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
923 assert!(set.contains(Scope::Id(a)));
924 assert!(set.contains(Scope::Id(b)));
925 assert!(set.contains(Scope::Shared));
926 }
927
928 #[test]
929 fn scopes_to_scope_set_without_shared_excludes_shared() {
930 let a = ScopeId::new();
931 let set = scopes_to_scope_set(&[Scope::Id(a)]);
932 assert!(set.contains(Scope::Id(a)));
933 assert!(!set.contains(Scope::Shared));
934 }
935
936 #[test]
937 fn scopes_to_scope_set_matches_singleton_for_one_member() {
938 let a = ScopeId::new();
943 let multi = scopes_to_scope_set(&[Scope::Id(a)]);
944 let single = scope_to_scope_set(Scope::Id(a));
945 assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
946 assert_eq!(
947 multi.contains(Scope::Shared),
948 single.contains(Scope::Shared)
949 );
950
951 let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
952 let single_shared = scope_to_scope_set(Scope::Shared);
953 assert_eq!(
954 multi_shared.contains(Scope::Shared),
955 single_shared.contains(Scope::Shared)
956 );
957 }
958
959 #[test]
960 fn scopes_to_scope_set_empty_admits_nothing() {
961 let a = ScopeId::new();
962 let set = scopes_to_scope_set(&[]);
963 assert!(!set.contains(Scope::Shared));
964 assert!(!set.contains(Scope::Id(a)));
965 }
966
967 #[test]
970 fn prop_changes_null_is_remove_scalar_is_set() {
971 let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
972 let changes = json_to_prop_changes(&j).unwrap();
973 assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
974 assert_eq!(changes["stale"], None);
975 assert_eq!(changes["n"], Some(PropValue::Int(3)));
976 }
977
978 #[test]
979 fn prop_changes_rejects_non_object() {
980 assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
981 }
982
983 #[test]
984 fn prop_changes_propagates_unsupported_value() {
985 assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
987 }
988
989 #[test]
992 fn f32_vec_parses_numbers() {
993 let j = serde_json::json!([0.0, 1.5, -2, 3]);
994 assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
995 }
996
997 #[test]
998 fn f32_vec_rejects_non_array() {
999 assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
1000 }
1001
1002 #[test]
1003 fn f32_vec_rejects_non_number_element() {
1004 assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
1005 }
1006
1007 #[test]
1008 fn f32_vec_rejects_overflow_to_infinity() {
1009 assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
1011 }
1012
1013 #[test]
1014 fn default_spec_covers_alias_and_synonym() {
1015 let s = default_spec();
1016 let has = |list: &[PropIndex], l: &str, p: &str| {
1017 list.iter().any(|pi| pi.label == l && pi.prop == p)
1018 };
1019 assert!(has(&s.equality, ALIAS_LABEL, ALIAS_NAME_PROP));
1020 assert!(has(&s.equality, SYNONYM_LABEL, SYNONYM_TERM_PROP));
1021 assert!(has(&s.text, ALIAS_LABEL, ALIAS_NAME_PROP));
1022 }
1023
1024 #[test]
1025 fn every_stock_generation_upgrades_to_current_default() {
1026 let v0 = IndexSpec {
1028 equality: vec![PropIndex {
1029 label: ENTITY_LABEL.into(),
1030 prop: ENTITY_NAME_PROP.into(),
1031 }],
1032 text: vec![PropIndex {
1033 label: MEMORY_LABEL.into(),
1034 prop: MEMORY_CONTENT_PROP.into(),
1035 }],
1036 };
1037 let v1 = IndexSpec {
1039 equality: v0.equality.clone(),
1040 text: vec![
1041 PropIndex {
1042 label: MEMORY_LABEL.into(),
1043 prop: MEMORY_CONTENT_PROP.into(),
1044 },
1045 PropIndex {
1046 label: ENTITY_LABEL.into(),
1047 prop: ENTITY_NAME_PROP.into(),
1048 },
1049 ],
1050 };
1051 assert_eq!(upgraded_spec(v0), default_spec());
1052 assert_eq!(upgraded_spec(v1), default_spec());
1053 assert_eq!(upgraded_spec(default_spec()), default_spec());
1054 }
1055}