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";
33pub const ALIAS_LABEL: &str = "Alias";
34pub const ALIAS_NAME_PROP: &str = "name";
35pub const ALIAS_EDGE_TYPE: &str = "alias_of";
36pub const SYNONYM_LABEL: &str = "Synonym";
37pub const SYNONYM_TERM_PROP: &str = "term";
38pub const SYNONYM_EXPANSION_PROP: &str = "expansion";
39
40pub fn default_spec() -> IndexSpec {
55 IndexSpec {
56 equality: vec![
57 PropIndex {
58 label: ENTITY_LABEL.into(),
59 prop: ENTITY_NAME_PROP.into(),
60 },
61 PropIndex {
64 label: ALIAS_LABEL.into(),
65 prop: ALIAS_NAME_PROP.into(),
66 },
67 PropIndex {
68 label: SYNONYM_LABEL.into(),
69 prop: SYNONYM_TERM_PROP.into(),
70 },
71 ],
72 text: vec![
73 PropIndex {
74 label: MEMORY_LABEL.into(),
75 prop: MEMORY_CONTENT_PROP.into(),
76 },
77 PropIndex {
83 label: ENTITY_LABEL.into(),
84 prop: ENTITY_NAME_PROP.into(),
85 },
86 PropIndex {
87 label: ALIAS_LABEL.into(),
88 prop: ALIAS_NAME_PROP.into(),
89 },
90 ],
91 }
92}
93
94fn stock_generations() -> Vec<IndexSpec> {
99 let g0 = IndexSpec {
101 equality: vec![PropIndex {
102 label: ENTITY_LABEL.into(),
103 prop: ENTITY_NAME_PROP.into(),
104 }],
105 text: vec![PropIndex {
106 label: MEMORY_LABEL.into(),
107 prop: MEMORY_CONTENT_PROP.into(),
108 }],
109 };
110 let g1 = IndexSpec {
112 equality: g0.equality.clone(),
113 text: vec![
114 PropIndex {
115 label: MEMORY_LABEL.into(),
116 prop: MEMORY_CONTENT_PROP.into(),
117 },
118 PropIndex {
119 label: ENTITY_LABEL.into(),
120 prop: ENTITY_NAME_PROP.into(),
121 },
122 ],
123 };
124 vec![g0, g1]
125}
126
127pub fn upgraded_spec(persisted: IndexSpec) -> IndexSpec {
135 let sorted = |spec: &IndexSpec| {
136 let mut eq: Vec<(String, String)> = spec
137 .equality
138 .iter()
139 .map(|p| (p.label.to_string(), p.prop.clone()))
140 .collect();
141 let mut text: Vec<(String, String)> = spec
142 .text
143 .iter()
144 .map(|p| (p.label.to_string(), p.prop.clone()))
145 .collect();
146 eq.sort();
147 text.sort();
148 (eq, text)
149 };
150 let p = sorted(&persisted);
151 if stock_generations().iter().any(|g| sorted(g) == p) {
152 default_spec()
153 } else {
154 persisted
155 }
156}
157
158pub fn normalize_edge_type(raw: &str) -> Result<String, String> {
168 let lowered = raw.to_lowercase();
169 let mut out = String::with_capacity(lowered.len());
170 let mut pending_sep = false;
171 for c in lowered.chars() {
172 if c.is_whitespace() || c == '-' || c == '_' {
173 if !out.is_empty() {
174 pending_sep = true;
175 }
176 } else {
177 if pending_sep {
178 out.push('_');
179 pending_sep = false;
180 }
181 out.push(c);
182 }
183 }
184 if out.is_empty() {
185 return Err(format!(
186 "edge type {raw:?} is empty once normalized (lowercase, separators collapsed to '_')"
187 ));
188 }
189 Ok(out)
190}
191
192pub fn scope_label(scope: &Scope) -> String {
199 match scope {
200 Scope::Shared => "shared".to_string(),
201 Scope::Id(id) => id.to_string(),
202 }
203}
204
205pub const UNSUPPORTED: &str = "unsupported over MCP v0";
210
211pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
215 match v {
216 PropValue::Str(s) => Ok(Value::String(s.clone())),
217 PropValue::Int(i) => Ok(Value::Number((*i).into())),
218 PropValue::Float(f) => serde_json::Number::from_f64(*f)
219 .map(Value::Number)
220 .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
221 PropValue::Bool(b) => Ok(Value::Bool(*b)),
222 PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
223 }
224}
225
226pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
235 match v {
236 Value::String(s) => Ok(PropValue::Str(s.clone())),
237 Value::Bool(b) => Ok(PropValue::Bool(*b)),
238 Value::Number(n) => {
239 if let Some(i) = n.as_i64() {
240 Ok(PropValue::Int(i))
241 } else if n.is_u64() {
242 Err(format!("integer out of supported range (max {})", i64::MAX))
245 } else if let Some(f) = n.as_f64() {
246 Ok(PropValue::Float(f))
247 } else {
248 Err(format!("{UNSUPPORTED}: number out of range"))
249 }
250 }
251 Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
252 }
253}
254
255pub fn props_to_json(props: &Props) -> Result<Value, String> {
258 let mut map = Map::with_capacity(props.len());
259 for (k, v) in props {
260 map.insert(k.clone(), prop_value_to_json(v)?);
261 }
262 Ok(Value::Object(map))
263}
264
265pub fn json_to_props(v: &Value) -> Result<Props, String> {
282 let obj = v
283 .as_object()
284 .ok_or_else(|| "expected a JSON object for props".to_string())?;
285 let mut props = Props::new();
286 for (k, val) in obj {
287 props.insert(k.clone(), json_to_prop_value(val)?);
288 }
289 Ok(props)
290}
291
292pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
299 let obj = v
300 .as_object()
301 .ok_or_else(|| "expected a JSON object for props".to_string())?;
302 let mut out = BTreeMap::new();
303 for (k, val) in obj {
304 let entry = match val {
305 Value::Null => None,
306 other => Some(json_to_prop_value(other)?),
307 };
308 out.insert(k.clone(), entry);
309 }
310 Ok(out)
311}
312
313pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
318 let arr = v
319 .as_array()
320 .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
321 let mut out = Vec::with_capacity(arr.len());
322 for (i, el) in arr.iter().enumerate() {
323 let f = el
324 .as_f64()
325 .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
326 let f = f as f32;
327 if !f.is_finite() {
328 return Err(format!("vector element {i} is not finite"));
329 }
330 out.push(f);
331 }
332 Ok(out)
333}
334
335pub fn merge_required_prop(
346 key: &str,
347 value: PropValue,
348 extra: Option<&Value>,
349) -> Result<Props, String> {
350 let mut props = match extra {
351 Some(v) => json_to_props(v)?,
352 None => Props::new(),
353 };
354 if props.contains_key(key) {
355 return Err(format!(
356 "props must not include {key:?}: it is already set from the tool's own parameter"
357 ));
358 }
359 props.insert(key.to_string(), value);
360 Ok(props)
361}
362
363pub fn scope_to_json(scope: Scope) -> Value {
367 Value::String(match scope {
368 Scope::Shared => "shared".to_string(),
369 Scope::Id(id) => id.to_string(),
370 })
371}
372
373pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
378 let mut map = Map::new();
379 map.insert("id".into(), Value::String(n.id.to_string()));
380 map.insert("scope".into(), scope_to_json(n.scope));
381 map.insert("label".into(), Value::String(n.label.to_string()));
382 map.insert("props".into(), props_to_json(&n.props)?);
383 Ok(Value::Object(map))
384}
385
386pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
391 let mut map = Map::new();
392 map.insert("id".into(), Value::String(e.id.to_string()));
393 map.insert("scope".into(), scope_to_json(e.scope));
394 map.insert("type".into(), Value::String(e.ty.to_string()));
395 map.insert("from".into(), Value::String(e.from.to_string()));
396 map.insert("to".into(), Value::String(e.to.to_string()));
397 map.insert("props".into(), props_to_json(&e.props)?);
398 map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
399 map.insert(
400 "valid_to".into(),
401 match e.valid_to {
402 Some(t) => Value::Number(t.into()),
403 None => Value::Null,
404 },
405 );
406 Ok(Value::Object(map))
407}
408
409pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
412 let nodes: Vec<Value> = sg
413 .nodes
414 .iter()
415 .map(node_to_json)
416 .collect::<Result<_, _>>()?;
417 let edges: Vec<Value> = sg
418 .edges
419 .iter()
420 .map(edge_to_json)
421 .collect::<Result<_, _>>()?;
422 Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
423}
424
425pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
431 match scope {
432 None => Ok(default),
433 Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
434 Some(s) => ScopeId::from_str(s)
435 .map(Scope::Id)
436 .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
437 }
438}
439
440pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
443 match scope {
444 Scope::Shared => ScopeSet::default().with_shared(),
445 Scope::Id(id) => ScopeSet::of(&[id]),
446 }
447}
448
449pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
459 let ids: Vec<ScopeId> = scopes
460 .iter()
461 .filter_map(|s| match s {
462 Scope::Id(id) => Some(*id),
463 Scope::Shared => None,
464 })
465 .collect();
466 let set = ScopeSet::of(&ids);
467 if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
468 set.with_shared()
469 } else {
470 set
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use topodb::NodeId;
478
479 fn props(pairs: &[(&str, PropValue)]) -> Props {
480 pairs
481 .iter()
482 .cloned()
483 .map(|(k, v)| (k.to_string(), v))
484 .collect()
485 }
486
487 #[test]
490 fn str_round_trips() {
491 let v = PropValue::Str("hello".into());
492 let j = prop_value_to_json(&v).unwrap();
493 assert_eq!(j, Value::String("hello".into()));
494 assert_eq!(json_to_prop_value(&j).unwrap(), v);
495 }
496
497 #[test]
498 fn int_round_trips() {
499 let v = PropValue::Int(-42);
500 let j = prop_value_to_json(&v).unwrap();
501 assert_eq!(j, serde_json::json!(-42));
502 assert_eq!(json_to_prop_value(&j).unwrap(), v);
503 }
504
505 #[test]
506 fn bool_round_trips() {
507 for b in [true, false] {
508 let v = PropValue::Bool(b);
509 let j = prop_value_to_json(&v).unwrap();
510 assert_eq!(j, Value::Bool(b));
511 assert_eq!(json_to_prop_value(&j).unwrap(), v);
512 }
513 }
514
515 #[test]
518 fn float_to_json_is_a_json_number() {
519 let v = PropValue::Float(3.5);
520 let j = prop_value_to_json(&v).unwrap();
521 assert_eq!(j, serde_json::json!(3.5));
522 }
523
524 #[test]
525 fn json_integer_literal_decodes_to_int_not_float() {
526 let j = serde_json::json!(7);
527 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
528 }
529
530 #[test]
531 fn json_float_literal_decodes_to_float() {
532 let j = serde_json::json!(7.5);
533 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
534 }
535
536 #[test]
537 fn i64_max_round_trips_as_int() {
538 let v = PropValue::Int(i64::MAX);
539 let j = prop_value_to_json(&v).unwrap();
540 assert_eq!(j, serde_json::json!(i64::MAX));
541 assert_eq!(json_to_prop_value(&j).unwrap(), v);
542 }
543
544 #[test]
545 fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
546 let j = serde_json::json!(u64::MAX);
547 let err = json_to_prop_value(&j).unwrap_err();
548 assert!(
549 err.contains("integer out of supported range"),
550 "expected a clear out-of-range error, got: {err}"
551 );
552 let j = serde_json::json!(i64::MAX as u64 + 1);
554 assert!(json_to_prop_value(&j).is_err());
555 }
556
557 #[test]
558 fn non_finite_float_to_json_is_an_error() {
559 assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
560 assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
561 }
562
563 #[test]
566 fn bytes_to_json_is_unsupported() {
567 let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
568 assert_eq!(err, UNSUPPORTED);
569 }
570
571 #[test]
572 fn datetime_to_json_is_unsupported() {
573 let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
574 assert_eq!(err, UNSUPPORTED);
575 }
576
577 #[test]
578 fn json_array_to_propvalue_is_unsupported() {
579 let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
580 assert_eq!(err, UNSUPPORTED);
581 }
582
583 #[test]
584 fn json_object_to_propvalue_is_unsupported() {
585 let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
586 assert_eq!(err, UNSUPPORTED);
587 }
588
589 #[test]
590 fn json_null_to_propvalue_is_unsupported() {
591 let err = json_to_prop_value(&Value::Null).unwrap_err();
592 assert_eq!(err, UNSUPPORTED);
593 }
594
595 #[test]
598 fn props_round_trip() {
599 let p = props(&[
600 ("name", PropValue::Str("ada".into())),
601 ("age", PropValue::Int(30)),
602 ("active", PropValue::Bool(true)),
603 ("score", PropValue::Float(1.5)),
604 ]);
605 let j = props_to_json(&p).unwrap();
606 assert!(j.is_object());
607 let back = json_to_props(&j).unwrap();
608 assert_eq!(back, p);
609 }
610
611 #[test]
612 fn props_to_json_propagates_unsupported_value() {
613 let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
614 assert!(props_to_json(&p).is_err());
615 }
616
617 #[test]
618 fn json_to_props_rejects_non_object() {
619 assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
620 }
621
622 #[test]
623 fn json_to_props_propagates_unsupported_field() {
624 let j = serde_json::json!({"bad": [1, 2]});
625 assert!(json_to_props(&j).is_err());
626 }
627
628 #[test]
631 fn merge_required_prop_with_no_extra_just_sets_the_key() {
632 let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
633 assert_eq!(props.len(), 1);
634 assert_eq!(props["content"], PropValue::Str("hi".into()));
635 }
636
637 #[test]
638 fn merge_required_prop_merges_additional_fields() {
639 let extra = serde_json::json!({"source": "chat", "confidence": 3});
640 let props =
641 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
642 assert_eq!(props.len(), 3);
643 assert_eq!(props["content"], PropValue::Str("hi".into()));
644 assert_eq!(props["source"], PropValue::Str("chat".into()));
645 assert_eq!(props["confidence"], PropValue::Int(3));
646 }
647
648 #[test]
649 fn merge_required_prop_rejects_collision_with_required_key() {
650 let extra = serde_json::json!({"content": "sneaky overwrite"});
651 let err =
652 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
653 assert!(
654 err.contains("content"),
655 "error should name the colliding key: {err}"
656 );
657 let extra = serde_json::json!({"name": "sneaky"});
660 assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
661 }
662
663 #[test]
664 fn merge_required_prop_does_not_overwrite_on_collision() {
665 let extra = serde_json::json!({"content": "other"});
668 let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
669 assert!(result.is_err());
670 }
671
672 #[test]
673 fn merge_required_prop_propagates_non_object_extra() {
674 let extra = serde_json::json!([1, 2]);
675 assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
676 }
677
678 fn sample_node(scope: Scope) -> NodeRecord {
681 NodeRecord {
682 id: NodeId::new(),
683 scope,
684 label: "Entity".into(),
685 props: props(&[("name", PropValue::Str("ada".into()))]),
686 embedding: None,
687 }
688 }
689
690 fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
691 EdgeRecord {
692 id: topodb::EdgeId::new(),
693 scope,
694 ty: "ABOUT".into(),
695 from,
696 to,
697 props: Props::new(),
698 valid_from: 1_000,
699 valid_to: None,
700 }
701 }
702
703 #[test]
704 fn node_to_json_has_ulid_id_and_declared_fields() {
705 let scope = Scope::Id(ScopeId::new());
706 let n = sample_node(scope);
707 let j = node_to_json(&n).unwrap();
708 assert_eq!(j["id"], Value::String(n.id.to_string()));
709 assert_eq!(j["label"], Value::String("Entity".into()));
710 assert_eq!(j["scope"], scope_to_json(scope));
711 assert_eq!(j["props"]["name"], Value::String("ada".into()));
712 let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
714 assert_eq!(parsed, n.id);
715 }
716
717 #[test]
718 fn node_to_json_propagates_unsupported_prop() {
719 let mut n = sample_node(Scope::Shared);
720 n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
721 assert!(node_to_json(&n).is_err());
722 }
723
724 #[test]
725 fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
726 let scope = Scope::Shared;
727 let a = NodeId::new();
728 let b = NodeId::new();
729 let e = sample_edge(scope, a, b);
730 let j = edge_to_json(&e).unwrap();
731 assert_eq!(j["id"], Value::String(e.id.to_string()));
732 assert_eq!(j["from"], Value::String(a.to_string()));
733 assert_eq!(j["to"], Value::String(b.to_string()));
734 assert_eq!(j["type"], Value::String("ABOUT".into()));
735 assert_eq!(j["valid_from"], serde_json::json!(1_000));
736 assert_eq!(j["valid_to"], Value::Null);
737 }
738
739 #[test]
740 fn edge_to_json_closed_edge_has_numeric_valid_to() {
741 let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
742 e.valid_to = Some(2_000);
743 let j = edge_to_json(&e).unwrap();
744 assert_eq!(j["valid_to"], serde_json::json!(2_000));
745 }
746
747 #[test]
748 fn subgraph_to_json_nests_nodes_and_edges() {
749 let scope = Scope::Shared;
750 let a = sample_node(scope);
751 let b = sample_node(scope);
752 let e = sample_edge(scope, a.id, b.id);
753 let sg = Subgraph {
754 nodes: vec![a.clone(), b.clone()],
755 edges: vec![e.clone()],
756 };
757 let j = subgraph_to_json(&sg).unwrap();
758 assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
759 assert_eq!(j["edges"].as_array().unwrap().len(), 1);
760 assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
761 }
762
763 #[test]
766 fn edge_type_variants_normalize_to_one_form() {
767 for raw in [
768 "works_at",
769 "Works At",
770 "works-at",
771 "WORKS_AT",
772 " works at ",
773 "works--at",
774 "works_-at",
775 ] {
776 assert_eq!(
777 normalize_edge_type(raw).unwrap(),
778 "works_at",
779 "{raw:?} should normalize to works_at"
780 );
781 }
782 assert_eq!(normalize_edge_type("about").unwrap(), "about");
783 }
784
785 #[test]
786 fn edge_type_empty_after_normalization_is_an_error() {
787 for raw in ["", " ", "---", "_", " - _ "] {
788 assert!(normalize_edge_type(raw).is_err(), "{raw:?} should error");
789 }
790 }
791
792 #[test]
795 fn legacy_stock_spec_upgrades_to_current_default() {
796 let legacy = IndexSpec {
797 equality: vec![PropIndex {
798 label: ENTITY_LABEL.into(),
799 prop: ENTITY_NAME_PROP.into(),
800 }],
801 text: vec![PropIndex {
802 label: MEMORY_LABEL.into(),
803 prop: MEMORY_CONTENT_PROP.into(),
804 }],
805 };
806 assert_eq!(upgraded_spec(legacy), default_spec());
807 assert_eq!(upgraded_spec(default_spec()), default_spec());
810 }
811
812 #[test]
813 fn customized_spec_is_never_rewritten() {
814 let custom = IndexSpec {
815 equality: vec![PropIndex {
816 label: "Person".into(),
817 prop: "handle".into(),
818 }],
819 text: vec![PropIndex {
820 label: MEMORY_LABEL.into(),
821 prop: MEMORY_CONTENT_PROP.into(),
822 }],
823 };
824 assert_eq!(upgraded_spec(custom.clone()), custom);
825 }
826
827 #[test]
830 fn resolve_scope_none_uses_default() {
831 let id = ScopeId::new();
832 assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
833 assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
834 }
835
836 #[test]
837 fn resolve_scope_shared_is_case_insensitive() {
838 assert_eq!(
839 resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
840 Scope::Shared
841 );
842 assert_eq!(
843 resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
844 Scope::Shared
845 );
846 }
847
848 #[test]
849 fn resolve_scope_ulid_parses_to_id() {
850 let id = ScopeId::new();
851 let s = id.to_string();
852 assert_eq!(
853 resolve_scope(Some(&s), Scope::Shared).unwrap(),
854 Scope::Id(id)
855 );
856 }
857
858 #[test]
859 fn resolve_scope_garbage_is_a_clear_error() {
860 let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
861 assert!(err.contains("not-a-ulid"));
862 }
863
864 #[test]
865 fn scope_to_scope_set_shared_admits_only_shared() {
866 let set = scope_to_scope_set(Scope::Shared);
867 assert!(set.contains(Scope::Shared));
868 assert!(!set.contains(Scope::Id(ScopeId::new())));
869 }
870
871 #[test]
872 fn scope_to_scope_set_id_admits_only_that_id() {
873 let id = ScopeId::new();
874 let set = scope_to_scope_set(Scope::Id(id));
875 assert!(set.contains(Scope::Id(id)));
876 assert!(!set.contains(Scope::Shared));
877 assert!(!set.contains(Scope::Id(ScopeId::new())));
878 }
879
880 #[test]
881 fn scopes_to_scope_set_admits_every_member() {
882 let a = ScopeId::new();
883 let b = ScopeId::new();
884 let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
885 assert!(set.contains(Scope::Id(a)));
886 assert!(set.contains(Scope::Id(b)));
887 assert!(set.contains(Scope::Shared));
888 }
889
890 #[test]
891 fn scopes_to_scope_set_without_shared_excludes_shared() {
892 let a = ScopeId::new();
893 let set = scopes_to_scope_set(&[Scope::Id(a)]);
894 assert!(set.contains(Scope::Id(a)));
895 assert!(!set.contains(Scope::Shared));
896 }
897
898 #[test]
899 fn scopes_to_scope_set_matches_singleton_for_one_member() {
900 let a = ScopeId::new();
905 let multi = scopes_to_scope_set(&[Scope::Id(a)]);
906 let single = scope_to_scope_set(Scope::Id(a));
907 assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
908 assert_eq!(
909 multi.contains(Scope::Shared),
910 single.contains(Scope::Shared)
911 );
912
913 let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
914 let single_shared = scope_to_scope_set(Scope::Shared);
915 assert_eq!(
916 multi_shared.contains(Scope::Shared),
917 single_shared.contains(Scope::Shared)
918 );
919 }
920
921 #[test]
922 fn scopes_to_scope_set_empty_admits_nothing() {
923 let a = ScopeId::new();
924 let set = scopes_to_scope_set(&[]);
925 assert!(!set.contains(Scope::Shared));
926 assert!(!set.contains(Scope::Id(a)));
927 }
928
929 #[test]
932 fn prop_changes_null_is_remove_scalar_is_set() {
933 let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
934 let changes = json_to_prop_changes(&j).unwrap();
935 assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
936 assert_eq!(changes["stale"], None);
937 assert_eq!(changes["n"], Some(PropValue::Int(3)));
938 }
939
940 #[test]
941 fn prop_changes_rejects_non_object() {
942 assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
943 }
944
945 #[test]
946 fn prop_changes_propagates_unsupported_value() {
947 assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
949 }
950
951 #[test]
954 fn f32_vec_parses_numbers() {
955 let j = serde_json::json!([0.0, 1.5, -2, 3]);
956 assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
957 }
958
959 #[test]
960 fn f32_vec_rejects_non_array() {
961 assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
962 }
963
964 #[test]
965 fn f32_vec_rejects_non_number_element() {
966 assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
967 }
968
969 #[test]
970 fn f32_vec_rejects_overflow_to_infinity() {
971 assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
973 }
974
975 #[test]
976 fn default_spec_covers_alias_and_synonym() {
977 let s = default_spec();
978 let has = |list: &[PropIndex], l: &str, p: &str| {
979 list.iter().any(|pi| pi.label == l && pi.prop == p)
980 };
981 assert!(has(&s.equality, ALIAS_LABEL, ALIAS_NAME_PROP));
982 assert!(has(&s.equality, SYNONYM_LABEL, SYNONYM_TERM_PROP));
983 assert!(has(&s.text, ALIAS_LABEL, ALIAS_NAME_PROP));
984 }
985
986 #[test]
987 fn every_stock_generation_upgrades_to_current_default() {
988 let v0 = IndexSpec {
990 equality: vec![PropIndex {
991 label: ENTITY_LABEL.into(),
992 prop: ENTITY_NAME_PROP.into(),
993 }],
994 text: vec![PropIndex {
995 label: MEMORY_LABEL.into(),
996 prop: MEMORY_CONTENT_PROP.into(),
997 }],
998 };
999 let v1 = IndexSpec {
1001 equality: v0.equality.clone(),
1002 text: vec![
1003 PropIndex {
1004 label: MEMORY_LABEL.into(),
1005 prop: MEMORY_CONTENT_PROP.into(),
1006 },
1007 PropIndex {
1008 label: ENTITY_LABEL.into(),
1009 prop: ENTITY_NAME_PROP.into(),
1010 },
1011 ],
1012 };
1013 assert_eq!(upgraded_spec(v0), default_spec());
1014 assert_eq!(upgraded_spec(v1), default_spec());
1015 assert_eq!(upgraded_spec(default_spec()), default_spec());
1016 }
1017}