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_forget, plan_remember, plan_supersede, resolve_entities_by_name,
19 ComposeError, PlannedEntity, RememberPlan, RememberRequest, DEFAULT_REMEMBER_EDGE_TYPE,
20};
21
22mod lifecycle;
23pub use lifecycle::{
24 lifecycle_candidates, plan_purge, staleness, LifecycleCandidate, LifecycleParams,
25 LIFECYCLE_DEFAULT_LIMIT, LIFECYCLE_HALF_LIFE_EPISODIC_DAYS,
26 LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS, LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS,
27};
28
29mod retry;
30pub use retry::open_with_busy_retry;
31
32use serde_json::{Map, Value};
33use std::collections::BTreeMap;
34use std::str::FromStr;
35use topodb::{
36 EdgeRecord, IndexSpec, NodeRecord, PropIndex, PropValue, Props, Scope, ScopeId, ScopeSet,
37 Subgraph,
38};
39
40pub const ENTITY_LABEL: &str = "Entity";
48pub const ENTITY_NAME_PROP: &str = "name";
49pub const MEMORY_LABEL: &str = "Memory";
50pub const MEMORY_CONTENT_PROP: &str = "content";
51pub const MEMORY_CONTENT_HASH_PROP: &str = "content_hash";
55pub const MEMORY_SUPERSEDED_AT_PROP: &str = "superseded_at";
60pub const MEMORY_FORGOTTEN_AT_PROP: &str = "forgotten_at";
65pub const MEMORY_TOMBSTONE_PROPS: [&str; 2] = [MEMORY_SUPERSEDED_AT_PROP, MEMORY_FORGOTTEN_AT_PROP];
70
71pub const MEMORY_KIND_PROP: &str = "kind";
79pub const MEMORY_KIND_EPISODIC: &str = "episodic";
80pub const MEMORY_KIND_SEMANTIC: &str = "semantic";
81pub const MEMORY_KIND_PROCEDURAL: &str = "procedural";
82pub const MEMORY_KINDS: [&str; 3] = [
84 MEMORY_KIND_EPISODIC,
85 MEMORY_KIND_SEMANTIC,
86 MEMORY_KIND_PROCEDURAL,
87];
88pub const MEMORY_KIND_DEFAULT: &str = MEMORY_KIND_SEMANTIC;
90
91pub fn validate_memory_kind(kind: &str) -> Result<(), String> {
94 if MEMORY_KINDS.contains(&kind) {
95 Ok(())
96 } else {
97 Err(format!(
98 "kind must be one of \"episodic\", \"semantic\", \"procedural\" — got {kind:?}"
99 ))
100 }
101}
102
103pub const ALIAS_LABEL: &str = "Alias";
104pub const ALIAS_NAME_PROP: &str = "name";
105pub const ALIAS_EDGE_TYPE: &str = "alias_of";
106pub const SYNONYM_LABEL: &str = "Synonym";
107pub const SYNONYM_TERM_PROP: &str = "term";
108pub const SYNONYM_EXPANSION_PROP: &str = "expansion";
109
110pub fn default_spec() -> IndexSpec {
125 IndexSpec {
126 equality: vec![
127 PropIndex {
128 label: ENTITY_LABEL.into(),
129 prop: ENTITY_NAME_PROP.into(),
130 },
131 PropIndex {
134 label: ALIAS_LABEL.into(),
135 prop: ALIAS_NAME_PROP.into(),
136 },
137 PropIndex {
138 label: SYNONYM_LABEL.into(),
139 prop: SYNONYM_TERM_PROP.into(),
140 },
141 PropIndex {
144 label: MEMORY_LABEL.into(),
145 prop: MEMORY_CONTENT_HASH_PROP.into(),
146 },
147 ],
148 text: vec![
149 PropIndex {
150 label: MEMORY_LABEL.into(),
151 prop: MEMORY_CONTENT_PROP.into(),
152 },
153 PropIndex {
159 label: ENTITY_LABEL.into(),
160 prop: ENTITY_NAME_PROP.into(),
161 },
162 PropIndex {
163 label: ALIAS_LABEL.into(),
164 prop: ALIAS_NAME_PROP.into(),
165 },
166 ],
167 }
168}
169
170fn stock_generations() -> Vec<IndexSpec> {
175 let g0 = IndexSpec {
177 equality: vec![PropIndex {
178 label: ENTITY_LABEL.into(),
179 prop: ENTITY_NAME_PROP.into(),
180 }],
181 text: vec![PropIndex {
182 label: MEMORY_LABEL.into(),
183 prop: MEMORY_CONTENT_PROP.into(),
184 }],
185 };
186 let g1 = IndexSpec {
188 equality: g0.equality.clone(),
189 text: vec![
190 PropIndex {
191 label: MEMORY_LABEL.into(),
192 prop: MEMORY_CONTENT_PROP.into(),
193 },
194 PropIndex {
195 label: ENTITY_LABEL.into(),
196 prop: ENTITY_NAME_PROP.into(),
197 },
198 ],
199 };
200 vec![g0, g1]
201}
202
203pub fn upgraded_spec(persisted: IndexSpec) -> IndexSpec {
211 let sorted = |spec: &IndexSpec| {
212 let mut eq: Vec<(String, String)> = spec
213 .equality
214 .iter()
215 .map(|p| (p.label.to_string(), p.prop.clone()))
216 .collect();
217 let mut text: Vec<(String, String)> = spec
218 .text
219 .iter()
220 .map(|p| (p.label.to_string(), p.prop.clone()))
221 .collect();
222 eq.sort();
223 text.sort();
224 (eq, text)
225 };
226 let p = sorted(&persisted);
227 if stock_generations().iter().any(|g| sorted(g) == p) {
228 default_spec()
229 } else {
230 persisted
231 }
232}
233
234pub fn normalize_edge_type(raw: &str) -> Result<String, String> {
244 let lowered = raw.to_lowercase();
245 let mut out = String::with_capacity(lowered.len());
246 let mut pending_sep = false;
247 for c in lowered.chars() {
248 if c.is_whitespace() || c == '-' || c == '_' {
249 if !out.is_empty() {
250 pending_sep = true;
251 }
252 } else {
253 if pending_sep {
254 out.push('_');
255 pending_sep = false;
256 }
257 out.push(c);
258 }
259 }
260 if out.is_empty() {
261 return Err(format!(
262 "edge type {raw:?} is empty once normalized (lowercase, separators collapsed to '_')"
263 ));
264 }
265 Ok(out)
266}
267
268pub fn scope_label(scope: &Scope) -> String {
275 match scope {
276 Scope::Shared => "shared".to_string(),
277 Scope::Id(id) => id.to_string(),
278 }
279}
280
281pub const UNSUPPORTED: &str = "unsupported over MCP v0";
286
287pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
291 match v {
292 PropValue::Str(s) => Ok(Value::String(s.clone())),
293 PropValue::Int(i) => Ok(Value::Number((*i).into())),
294 PropValue::Float(f) => serde_json::Number::from_f64(*f)
295 .map(Value::Number)
296 .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
297 PropValue::Bool(b) => Ok(Value::Bool(*b)),
298 PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
299 }
300}
301
302pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
311 match v {
312 Value::String(s) => Ok(PropValue::Str(s.clone())),
313 Value::Bool(b) => Ok(PropValue::Bool(*b)),
314 Value::Number(n) => {
315 if let Some(i) = n.as_i64() {
316 Ok(PropValue::Int(i))
317 } else if n.is_u64() {
318 Err(format!("integer out of supported range (max {})", i64::MAX))
321 } else if let Some(f) = n.as_f64() {
322 Ok(PropValue::Float(f))
323 } else {
324 Err(format!("{UNSUPPORTED}: number out of range"))
325 }
326 }
327 Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
328 }
329}
330
331pub fn props_to_json(props: &Props) -> Result<Value, String> {
334 let mut map = Map::with_capacity(props.len());
335 for (k, v) in props {
336 map.insert(k.clone(), prop_value_to_json(v)?);
337 }
338 Ok(Value::Object(map))
339}
340
341pub fn json_to_props(v: &Value) -> Result<Props, String> {
358 let obj = v
359 .as_object()
360 .ok_or_else(|| "expected a JSON object for props".to_string())?;
361 let mut props = Props::new();
362 for (k, val) in obj {
363 props.insert(k.clone(), json_to_prop_value(val)?);
364 }
365 Ok(props)
366}
367
368pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
375 let obj = v
376 .as_object()
377 .ok_or_else(|| "expected a JSON object for props".to_string())?;
378 let mut out = BTreeMap::new();
379 for (k, val) in obj {
380 let entry = match val {
381 Value::Null => None,
382 other => Some(json_to_prop_value(other)?),
383 };
384 out.insert(k.clone(), entry);
385 }
386 Ok(out)
387}
388
389pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
394 let arr = v
395 .as_array()
396 .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
397 let mut out = Vec::with_capacity(arr.len());
398 for (i, el) in arr.iter().enumerate() {
399 let f = el
400 .as_f64()
401 .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
402 let f = f as f32;
403 if !f.is_finite() {
404 return Err(format!("vector element {i} is not finite"));
405 }
406 out.push(f);
407 }
408 Ok(out)
409}
410
411pub fn merge_required_prop(
422 key: &str,
423 value: PropValue,
424 extra: Option<&Value>,
425) -> Result<Props, String> {
426 let mut props = match extra {
427 Some(v) => json_to_props(v)?,
428 None => Props::new(),
429 };
430 if props.contains_key(key) {
431 return Err(format!(
432 "props must not include {key:?}: it is already set from the tool's own parameter"
433 ));
434 }
435 props.insert(key.to_string(), value);
436 Ok(props)
437}
438
439pub fn scope_to_json(scope: Scope) -> Value {
443 Value::String(match scope {
444 Scope::Shared => "shared".to_string(),
445 Scope::Id(id) => id.to_string(),
446 })
447}
448
449pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
454 let mut map = Map::new();
455 map.insert("id".into(), Value::String(n.id.to_string()));
456 map.insert("scope".into(), scope_to_json(n.scope));
457 map.insert("label".into(), Value::String(n.label.to_string()));
458 map.insert("props".into(), props_to_json(&n.props)?);
459 Ok(Value::Object(map))
460}
461
462pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
467 let mut map = Map::new();
468 map.insert("id".into(), Value::String(e.id.to_string()));
469 map.insert("scope".into(), scope_to_json(e.scope));
470 map.insert("type".into(), Value::String(e.ty.to_string()));
471 map.insert("from".into(), Value::String(e.from.to_string()));
472 map.insert("to".into(), Value::String(e.to.to_string()));
473 map.insert("props".into(), props_to_json(&e.props)?);
474 map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
475 map.insert(
476 "valid_to".into(),
477 match e.valid_to {
478 Some(t) => Value::Number(t.into()),
479 None => Value::Null,
480 },
481 );
482 Ok(Value::Object(map))
483}
484
485pub fn edge_live_at(e: &EdgeRecord, t: i64) -> bool {
494 e.valid_from <= t && e.valid_to.is_none_or(|vt| vt > t)
495}
496
497pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
500 let nodes: Vec<Value> = sg
501 .nodes
502 .iter()
503 .map(node_to_json)
504 .collect::<Result<_, _>>()?;
505 let edges: Vec<Value> = sg
506 .edges
507 .iter()
508 .map(edge_to_json)
509 .collect::<Result<_, _>>()?;
510 Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
511}
512
513pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
519 match scope {
520 None => Ok(default),
521 Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
522 Some(s) => ScopeId::from_str(s)
523 .map(Scope::Id)
524 .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
525 }
526}
527
528pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
531 match scope {
532 Scope::Shared => ScopeSet::default().with_shared(),
533 Scope::Id(id) => ScopeSet::of(&[id]),
534 }
535}
536
537pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
547 let ids: Vec<ScopeId> = scopes
548 .iter()
549 .filter_map(|s| match s {
550 Scope::Id(id) => Some(*id),
551 Scope::Shared => None,
552 })
553 .collect();
554 let set = ScopeSet::of(&ids);
555 if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
556 set.with_shared()
557 } else {
558 set
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use topodb::NodeId;
566
567 fn props(pairs: &[(&str, PropValue)]) -> Props {
568 pairs
569 .iter()
570 .cloned()
571 .map(|(k, v)| (k.to_string(), v))
572 .collect()
573 }
574
575 #[test]
578 fn str_round_trips() {
579 let v = PropValue::Str("hello".into());
580 let j = prop_value_to_json(&v).unwrap();
581 assert_eq!(j, Value::String("hello".into()));
582 assert_eq!(json_to_prop_value(&j).unwrap(), v);
583 }
584
585 #[test]
586 fn int_round_trips() {
587 let v = PropValue::Int(-42);
588 let j = prop_value_to_json(&v).unwrap();
589 assert_eq!(j, serde_json::json!(-42));
590 assert_eq!(json_to_prop_value(&j).unwrap(), v);
591 }
592
593 #[test]
594 fn bool_round_trips() {
595 for b in [true, false] {
596 let v = PropValue::Bool(b);
597 let j = prop_value_to_json(&v).unwrap();
598 assert_eq!(j, Value::Bool(b));
599 assert_eq!(json_to_prop_value(&j).unwrap(), v);
600 }
601 }
602
603 #[test]
606 fn float_to_json_is_a_json_number() {
607 let v = PropValue::Float(3.5);
608 let j = prop_value_to_json(&v).unwrap();
609 assert_eq!(j, serde_json::json!(3.5));
610 }
611
612 #[test]
613 fn json_integer_literal_decodes_to_int_not_float() {
614 let j = serde_json::json!(7);
615 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
616 }
617
618 #[test]
619 fn json_float_literal_decodes_to_float() {
620 let j = serde_json::json!(7.5);
621 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
622 }
623
624 #[test]
625 fn i64_max_round_trips_as_int() {
626 let v = PropValue::Int(i64::MAX);
627 let j = prop_value_to_json(&v).unwrap();
628 assert_eq!(j, serde_json::json!(i64::MAX));
629 assert_eq!(json_to_prop_value(&j).unwrap(), v);
630 }
631
632 #[test]
633 fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
634 let j = serde_json::json!(u64::MAX);
635 let err = json_to_prop_value(&j).unwrap_err();
636 assert!(
637 err.contains("integer out of supported range"),
638 "expected a clear out-of-range error, got: {err}"
639 );
640 let j = serde_json::json!(i64::MAX as u64 + 1);
642 assert!(json_to_prop_value(&j).is_err());
643 }
644
645 #[test]
646 fn non_finite_float_to_json_is_an_error() {
647 assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
648 assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
649 }
650
651 #[test]
654 fn bytes_to_json_is_unsupported() {
655 let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
656 assert_eq!(err, UNSUPPORTED);
657 }
658
659 #[test]
660 fn datetime_to_json_is_unsupported() {
661 let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
662 assert_eq!(err, UNSUPPORTED);
663 }
664
665 #[test]
666 fn json_array_to_propvalue_is_unsupported() {
667 let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
668 assert_eq!(err, UNSUPPORTED);
669 }
670
671 #[test]
672 fn json_object_to_propvalue_is_unsupported() {
673 let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
674 assert_eq!(err, UNSUPPORTED);
675 }
676
677 #[test]
678 fn json_null_to_propvalue_is_unsupported() {
679 let err = json_to_prop_value(&Value::Null).unwrap_err();
680 assert_eq!(err, UNSUPPORTED);
681 }
682
683 #[test]
686 fn props_round_trip() {
687 let p = props(&[
688 ("name", PropValue::Str("ada".into())),
689 ("age", PropValue::Int(30)),
690 ("active", PropValue::Bool(true)),
691 ("score", PropValue::Float(1.5)),
692 ]);
693 let j = props_to_json(&p).unwrap();
694 assert!(j.is_object());
695 let back = json_to_props(&j).unwrap();
696 assert_eq!(back, p);
697 }
698
699 #[test]
700 fn props_to_json_propagates_unsupported_value() {
701 let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
702 assert!(props_to_json(&p).is_err());
703 }
704
705 #[test]
706 fn json_to_props_rejects_non_object() {
707 assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
708 }
709
710 #[test]
711 fn json_to_props_propagates_unsupported_field() {
712 let j = serde_json::json!({"bad": [1, 2]});
713 assert!(json_to_props(&j).is_err());
714 }
715
716 #[test]
719 fn merge_required_prop_with_no_extra_just_sets_the_key() {
720 let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
721 assert_eq!(props.len(), 1);
722 assert_eq!(props["content"], PropValue::Str("hi".into()));
723 }
724
725 #[test]
726 fn merge_required_prop_merges_additional_fields() {
727 let extra = serde_json::json!({"source": "chat", "confidence": 3});
728 let props =
729 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
730 assert_eq!(props.len(), 3);
731 assert_eq!(props["content"], PropValue::Str("hi".into()));
732 assert_eq!(props["source"], PropValue::Str("chat".into()));
733 assert_eq!(props["confidence"], PropValue::Int(3));
734 }
735
736 #[test]
737 fn merge_required_prop_rejects_collision_with_required_key() {
738 let extra = serde_json::json!({"content": "sneaky overwrite"});
739 let err =
740 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
741 assert!(
742 err.contains("content"),
743 "error should name the colliding key: {err}"
744 );
745 let extra = serde_json::json!({"name": "sneaky"});
748 assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
749 }
750
751 #[test]
752 fn merge_required_prop_does_not_overwrite_on_collision() {
753 let extra = serde_json::json!({"content": "other"});
756 let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
757 assert!(result.is_err());
758 }
759
760 #[test]
761 fn merge_required_prop_propagates_non_object_extra() {
762 let extra = serde_json::json!([1, 2]);
763 assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
764 }
765
766 fn sample_node(scope: Scope) -> NodeRecord {
769 NodeRecord {
770 id: NodeId::new(),
771 scope,
772 label: "Entity".into(),
773 props: props(&[("name", PropValue::Str("ada".into()))]),
774 embedding: None,
775 }
776 }
777
778 fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
779 EdgeRecord {
780 id: topodb::EdgeId::new(),
781 scope,
782 ty: "ABOUT".into(),
783 from,
784 to,
785 props: Props::new(),
786 valid_from: 1_000,
787 valid_to: None,
788 }
789 }
790
791 #[test]
792 fn node_to_json_has_ulid_id_and_declared_fields() {
793 let scope = Scope::Id(ScopeId::new());
794 let n = sample_node(scope);
795 let j = node_to_json(&n).unwrap();
796 assert_eq!(j["id"], Value::String(n.id.to_string()));
797 assert_eq!(j["label"], Value::String("Entity".into()));
798 assert_eq!(j["scope"], scope_to_json(scope));
799 assert_eq!(j["props"]["name"], Value::String("ada".into()));
800 let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
802 assert_eq!(parsed, n.id);
803 }
804
805 #[test]
806 fn node_to_json_propagates_unsupported_prop() {
807 let mut n = sample_node(Scope::Shared);
808 n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
809 assert!(node_to_json(&n).is_err());
810 }
811
812 #[test]
813 fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
814 let scope = Scope::Shared;
815 let a = NodeId::new();
816 let b = NodeId::new();
817 let e = sample_edge(scope, a, b);
818 let j = edge_to_json(&e).unwrap();
819 assert_eq!(j["id"], Value::String(e.id.to_string()));
820 assert_eq!(j["from"], Value::String(a.to_string()));
821 assert_eq!(j["to"], Value::String(b.to_string()));
822 assert_eq!(j["type"], Value::String("ABOUT".into()));
823 assert_eq!(j["valid_from"], serde_json::json!(1_000));
824 assert_eq!(j["valid_to"], Value::Null);
825 }
826
827 #[test]
828 fn edge_to_json_closed_edge_has_numeric_valid_to() {
829 let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
830 e.valid_to = Some(2_000);
831 let j = edge_to_json(&e).unwrap();
832 assert_eq!(j["valid_to"], serde_json::json!(2_000));
833 }
834
835 #[test]
836 fn subgraph_to_json_nests_nodes_and_edges() {
837 let scope = Scope::Shared;
838 let a = sample_node(scope);
839 let b = sample_node(scope);
840 let e = sample_edge(scope, a.id, b.id);
841 let sg = Subgraph {
842 nodes: vec![a.clone(), b.clone()],
843 edges: vec![e.clone()],
844 };
845 let j = subgraph_to_json(&sg).unwrap();
846 assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
847 assert_eq!(j["edges"].as_array().unwrap().len(), 1);
848 assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
849 }
850
851 #[test]
854 fn edge_type_variants_normalize_to_one_form() {
855 for raw in [
856 "works_at",
857 "Works At",
858 "works-at",
859 "WORKS_AT",
860 " works at ",
861 "works--at",
862 "works_-at",
863 ] {
864 assert_eq!(
865 normalize_edge_type(raw).unwrap(),
866 "works_at",
867 "{raw:?} should normalize to works_at"
868 );
869 }
870 assert_eq!(normalize_edge_type("about").unwrap(), "about");
871 }
872
873 #[test]
874 fn edge_type_empty_after_normalization_is_an_error() {
875 for raw in ["", " ", "---", "_", " - _ "] {
876 assert!(normalize_edge_type(raw).is_err(), "{raw:?} should error");
877 }
878 }
879
880 #[test]
883 fn legacy_stock_spec_upgrades_to_current_default() {
884 let legacy = IndexSpec {
885 equality: vec![PropIndex {
886 label: ENTITY_LABEL.into(),
887 prop: ENTITY_NAME_PROP.into(),
888 }],
889 text: vec![PropIndex {
890 label: MEMORY_LABEL.into(),
891 prop: MEMORY_CONTENT_PROP.into(),
892 }],
893 };
894 assert_eq!(upgraded_spec(legacy), default_spec());
895 assert_eq!(upgraded_spec(default_spec()), default_spec());
898 }
899
900 #[test]
901 fn customized_spec_is_never_rewritten() {
902 let custom = IndexSpec {
903 equality: vec![PropIndex {
904 label: "Person".into(),
905 prop: "handle".into(),
906 }],
907 text: vec![PropIndex {
908 label: MEMORY_LABEL.into(),
909 prop: MEMORY_CONTENT_PROP.into(),
910 }],
911 };
912 assert_eq!(upgraded_spec(custom.clone()), custom);
913 }
914
915 #[test]
918 fn resolve_scope_none_uses_default() {
919 let id = ScopeId::new();
920 assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
921 assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
922 }
923
924 #[test]
925 fn resolve_scope_shared_is_case_insensitive() {
926 assert_eq!(
927 resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
928 Scope::Shared
929 );
930 assert_eq!(
931 resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
932 Scope::Shared
933 );
934 }
935
936 #[test]
937 fn resolve_scope_ulid_parses_to_id() {
938 let id = ScopeId::new();
939 let s = id.to_string();
940 assert_eq!(
941 resolve_scope(Some(&s), Scope::Shared).unwrap(),
942 Scope::Id(id)
943 );
944 }
945
946 #[test]
947 fn resolve_scope_garbage_is_a_clear_error() {
948 let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
949 assert!(err.contains("not-a-ulid"));
950 }
951
952 #[test]
953 fn scope_to_scope_set_shared_admits_only_shared() {
954 let set = scope_to_scope_set(Scope::Shared);
955 assert!(set.contains(Scope::Shared));
956 assert!(!set.contains(Scope::Id(ScopeId::new())));
957 }
958
959 #[test]
960 fn scope_to_scope_set_id_admits_only_that_id() {
961 let id = ScopeId::new();
962 let set = scope_to_scope_set(Scope::Id(id));
963 assert!(set.contains(Scope::Id(id)));
964 assert!(!set.contains(Scope::Shared));
965 assert!(!set.contains(Scope::Id(ScopeId::new())));
966 }
967
968 #[test]
969 fn scopes_to_scope_set_admits_every_member() {
970 let a = ScopeId::new();
971 let b = ScopeId::new();
972 let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
973 assert!(set.contains(Scope::Id(a)));
974 assert!(set.contains(Scope::Id(b)));
975 assert!(set.contains(Scope::Shared));
976 }
977
978 #[test]
979 fn scopes_to_scope_set_without_shared_excludes_shared() {
980 let a = ScopeId::new();
981 let set = scopes_to_scope_set(&[Scope::Id(a)]);
982 assert!(set.contains(Scope::Id(a)));
983 assert!(!set.contains(Scope::Shared));
984 }
985
986 #[test]
987 fn scopes_to_scope_set_matches_singleton_for_one_member() {
988 let a = ScopeId::new();
993 let multi = scopes_to_scope_set(&[Scope::Id(a)]);
994 let single = scope_to_scope_set(Scope::Id(a));
995 assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
996 assert_eq!(
997 multi.contains(Scope::Shared),
998 single.contains(Scope::Shared)
999 );
1000
1001 let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
1002 let single_shared = scope_to_scope_set(Scope::Shared);
1003 assert_eq!(
1004 multi_shared.contains(Scope::Shared),
1005 single_shared.contains(Scope::Shared)
1006 );
1007 }
1008
1009 #[test]
1010 fn scopes_to_scope_set_empty_admits_nothing() {
1011 let a = ScopeId::new();
1012 let set = scopes_to_scope_set(&[]);
1013 assert!(!set.contains(Scope::Shared));
1014 assert!(!set.contains(Scope::Id(a)));
1015 }
1016
1017 #[test]
1020 fn prop_changes_null_is_remove_scalar_is_set() {
1021 let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
1022 let changes = json_to_prop_changes(&j).unwrap();
1023 assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
1024 assert_eq!(changes["stale"], None);
1025 assert_eq!(changes["n"], Some(PropValue::Int(3)));
1026 }
1027
1028 #[test]
1029 fn prop_changes_rejects_non_object() {
1030 assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
1031 }
1032
1033 #[test]
1034 fn prop_changes_propagates_unsupported_value() {
1035 assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
1037 }
1038
1039 #[test]
1042 fn f32_vec_parses_numbers() {
1043 let j = serde_json::json!([0.0, 1.5, -2, 3]);
1044 assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
1045 }
1046
1047 #[test]
1048 fn f32_vec_rejects_non_array() {
1049 assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
1050 }
1051
1052 #[test]
1053 fn f32_vec_rejects_non_number_element() {
1054 assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
1055 }
1056
1057 #[test]
1058 fn f32_vec_rejects_overflow_to_infinity() {
1059 assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
1061 }
1062
1063 #[test]
1064 fn default_spec_covers_alias_and_synonym() {
1065 let s = default_spec();
1066 let has = |list: &[PropIndex], l: &str, p: &str| {
1067 list.iter().any(|pi| pi.label == l && pi.prop == p)
1068 };
1069 assert!(has(&s.equality, ALIAS_LABEL, ALIAS_NAME_PROP));
1070 assert!(has(&s.equality, SYNONYM_LABEL, SYNONYM_TERM_PROP));
1071 assert!(has(&s.text, ALIAS_LABEL, ALIAS_NAME_PROP));
1072 }
1073
1074 #[test]
1075 fn every_stock_generation_upgrades_to_current_default() {
1076 let v0 = IndexSpec {
1078 equality: vec![PropIndex {
1079 label: ENTITY_LABEL.into(),
1080 prop: ENTITY_NAME_PROP.into(),
1081 }],
1082 text: vec![PropIndex {
1083 label: MEMORY_LABEL.into(),
1084 prop: MEMORY_CONTENT_PROP.into(),
1085 }],
1086 };
1087 let v1 = IndexSpec {
1089 equality: v0.equality.clone(),
1090 text: vec![
1091 PropIndex {
1092 label: MEMORY_LABEL.into(),
1093 prop: MEMORY_CONTENT_PROP.into(),
1094 },
1095 PropIndex {
1096 label: ENTITY_LABEL.into(),
1097 prop: ENTITY_NAME_PROP.into(),
1098 },
1099 ],
1100 };
1101 assert_eq!(upgraded_spec(v0), default_spec());
1102 assert_eq!(upgraded_spec(v1), default_spec());
1103 assert_eq!(upgraded_spec(default_spec()), default_spec());
1104 }
1105}