Skip to main content

topodb_json/
lib.rs

1//! JSON ↔ engine-type conversions shared by TopoDB's JSON-speaking front ends
2//! (currently `topodb-mcp`; `topodb-cli` is next).
3//!
4//! Every function here is pure (no I/O, no `Db` access) and returns `Result<_,
5//! String>` — callers are responsible for mapping the `String` into their own
6//! error type (`topodb-mcp`'s `server.rs` maps it to an `rmcp::ErrorData`:
7//! `invalid_params` for bad input, `internal_error` otherwise). Nothing here
8//! ever panics: an unrepresentable value is always an `Err`, never an
9//! `unwrap`/`expect`.
10
11mod 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
22/// Label/prop name constants for the two built-in write shapes
23/// (`create_memory`/`create-memory`, `create_entity`/`create-entity`). Single
24/// source of truth shared by every front end (`topodb-mcp`'s default
25/// [`IndexSpec`](topodb::IndexSpec) and write tools, `topodb-cli`'s
26/// `create-entity`/`create-memory` subcommands) so writes land on exactly the
27/// `(label, prop)` pairs the default spec indexes — search and lookup work
28/// out of the box regardless of which front end wrote the data.
29pub 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
34/// The ONE canonical default [`IndexSpec`] for TopoDB's built-in write shapes,
35/// shared by every front end (`topodb-mcp` when `--spec` is omitted;
36/// `topodb-cli` when it creates a brand-new db file). Declares equality on
37/// `(Entity, name)` and text on `(Memory, content)`, using the shared
38/// [`ENTITY_LABEL`]/[`ENTITY_NAME_PROP`]/[`MEMORY_LABEL`]/[`MEMORY_CONTENT_PROP`]
39/// constants.
40///
41/// Single-sourcing this is load-bearing: because a CLI-created db and an
42/// MCP-created db are opened with a *byte-identical* persisted `index_spec`,
43/// either front end can later serve a db the other created via `open_stored`
44/// without triggering an FTS reindex or mis-declaring the equality index — and
45/// both `find` (equality on `Entity`/`name`) and `search` (text on
46/// `Memory`/`content`) work out of the box on a fresh db regardless of which
47/// tool wrote it.
48pub 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
61/// Human/JSON-facing rendering of a [`Scope`]: `"shared"` or the ULID string.
62/// Reused by every front end's `info`/`db_info`-style output and scope
63/// round-tripping. (Distinct from [`scope_to_json`], which wraps the same
64/// rendering in a `serde_json::Value` for a JSON response body; this returns
65/// a bare `String` for contexts — like a struct field — that want the label
66/// without a `Value` wrapper.)
67pub 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
74/// Error string for both directions of an unrepresentable [`PropValue`]:
75/// `Bytes` and `DateTime` have no JSON counterpart over MCP v0, and any JSON
76/// shape that isn't a string/number/bool (array, object, null) has no
77/// [`PropValue`] counterpart either.
78pub const UNSUPPORTED: &str = "unsupported over MCP v0";
79
80/// `PropValue` → `serde_json::Value`. `Str`/`Int`/`Bool` map directly; `Float`
81/// maps to a JSON number (`Err` only for a non-finite float, which JSON has no
82/// representation for). `Bytes`/`DateTime` are [`UNSUPPORTED`].
83pub 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
95/// `serde_json::Value` → `PropValue`. Strings/bools map directly. A JSON
96/// integer maps to `Int` when it fits `i64`, and is an error when it doesn't
97/// (`(i64::MAX, u64::MAX]` — silently downgrading it to a lossy `Float` would
98/// corrupt the value); only a genuine non-integer number maps to `Float`.
99/// This is the inverse of `prop_value_to_json`'s `Int`/`Float` handling.
100/// Every other JSON shape (array, object, null — and, structurally, anything
101/// that would have needed to round-trip through `Bytes`/`DateTime`) is
102/// [`UNSUPPORTED`].
103pub 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                // An integer above i64::MAX: representable in JSON but not in
112                // PropValue::Int, and f64 can't hold it losslessly either.
113                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
124/// `Props` (a `BTreeMap<String, PropValue>`) → a JSON object, propagating the
125/// first unrepresentable value as `Err`.
126pub 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
134/// A JSON object → `Props`, propagating the first unrepresentable value as
135/// `Err`. `Err` if `v` isn't a JSON object at all.
136///
137/// The inverse of `props_to_json`; `topodb-mcp`'s write tools (`create_entity`
138/// / `create_memory` / `link`) call this on the caller-supplied `props`
139/// object.
140///
141/// **v0 limitation:** a JSON integer literal below `i64::MIN` (e.g.
142/// `-99999999999999999999`) is *already* an `f64` by the time it reaches
143/// [`json_to_prop_value`] — `serde_json`'s parser itself has no `i64`-sized
144/// negative bucket wide enough to hold it, so it falls back to a lossy float
145/// at parse time, upstream of anything this module can inspect or reject
146/// (unlike the positive out-of-range case above `i64::MAX`, which parses to
147/// `u64` and so is still catchable). Undetectable and unfixable at this
148/// layer without `serde_json`'s `arbitrary_precision` feature; documented
149/// here as a known v0 gap rather than silently accepted as correct.
150pub 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
161/// A JSON object of property changes for [`topodb::Op::SetNodeProps`]. A `null`
162/// value REMOVES the key (`None`); any other JSON scalar SETS it
163/// (`Some(PropValue)`, via [`json_to_prop_value`]). `Err` if `v` isn't a JSON
164/// object, or a non-null value isn't a representable scalar. The `null`-removes
165/// convention is what lets a caller delete a prop over the wire — plain
166/// [`json_to_props`] has no way to express removal.
167pub 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
182/// A JSON array of finite numbers → `Vec<f32>`, for raw embeddings
183/// ([`topodb::Op::SetEmbedding`]) and vector-search queries. `Err` if `v` isn't
184/// a JSON array, or any element isn't a finite number. (The host computes
185/// embeddings; TopoDB stores/searches the raw floats.)
186pub 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
204/// Builds the `Props` map for a write tool that has one required, caller-named
205/// field (`create_memory`'s `content`, `create_entity`'s `name`) plus an
206/// optional JSON `props` object of additional metadata. `key`/`value` are the
207/// required field, already converted to a `PropValue`; `extra` is the tool
208/// call's optional `props` param, converted via `json_to_props`.
209///
210/// `Err` if `extra` (once converted) already contains `key` — a collision
211/// with the required field is a caller error to be corrected, never silently
212/// overwritten. `Err` also propagates straight through from `json_to_props`
213/// (non-object `extra`, or an unrepresentable value inside it).
214pub 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
232/// A `Scope` → its JSON rendering: `"shared"` or the scope's ULID string.
233/// Mirrors the `shared`/ULID label convention used across TopoDB's JSON-facing
234/// front ends (e.g. `topodb-mcp`'s `db_info` tool).
235pub 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
242/// A `NodeRecord` → JSON: `id`/`label` as strings (ULID via `Display` for
243/// `id`), `scope` per [`scope_to_json`], and `props` per [`props_to_json`].
244/// Deliberately omits the `embedding` field — no MCP v0 tool surfaces vector
245/// data (that's a later concern via dedicated embedding tools).
246pub 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
255/// An `EdgeRecord` → JSON: `id`/`from`/`to` as ULID strings, `type` for `ty`
256/// (JSON-friendlier than the Rust keyword-adjacent field name), `scope` per
257/// [`scope_to_json`], `props` per [`props_to_json`], and the temporal bounds
258/// `valid_from`/`valid_to` (`valid_to` is `null` while the edge is open).
259pub 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
278/// A `Subgraph` → `{"nodes": [...], "edges": [...]}`, each element per
279/// [`node_to_json`]/[`edge_to_json`].
280pub 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
294/// Resolves a tool's optional `scope` string param to a `Scope`: `None` →
295/// `default` (the server's configured default scope); `Some("shared")`
296/// (case-insensitive) → `Scope::Shared`; `Some(<ulid>)` → `Scope::Id`; any
297/// other string → a clear `Err`. Mirrors `topodb-mcp`'s `config::parse_scope`
298/// "shared" / ULID contract, generalized to the `Option` (tool-call) case.
299pub 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
309/// A resolved `Scope` → the singleton `ScopeSet` a read call needs: `Shared`
310/// admits only the shared scope, `Id(id)` admits only that one scope id.
311pub 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
318/// Several resolved `Scope`s → the `ScopeSet` a multi-scope read runs against.
319/// `Scope::Shared` sets the set's `include_shared` flag; each `Scope::Id`
320/// becomes a member id. This is the only constructor that can produce a
321/// genuinely multi-member `ScopeSet` — [`scope_to_scope_set`] always collapses
322/// to a singleton, which is why "this project *plus* shared" was previously
323/// unexpressible from any client.
324///
325/// An empty slice yields a set that admits nothing. Callers must not hand a
326/// read an empty set expecting "everything" — there is no unscoped read.
327pub 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    // --- PropValue <-> Value: Str/Int/Bool both ways ---
357
358    #[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    // --- Float <-> JSON number: JSON int -> Int, JSON float -> Float ---
385
386    #[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        // And just past the i64 boundary too, not only at the extreme.
422        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    // --- Bytes/DateTime unsupported, both directions ---
433
434    #[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    // --- Props <-> JSON object ---
465
466    #[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    // --- merge_required_prop: the create_memory/create_entity collision rule ---
498
499    #[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        // And the same for `name` (create_entity's required key), to confirm
527        // this isn't hardcoded to "content".
528        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        // The collision must be rejected outright, not silently resolved by
535        // either value winning — assert no props map is returned at all.
536        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    // --- node/edge/subgraph -> JSON ---
548
549    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        // `id` round-trips through NodeId's ULID Display/FromStr.
582        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    // --- scope resolution ---
633
634    #[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        // The new multi-member constructor must agree with the existing
706        // single-scope one for a one-element input — that equivalence is what
707        // makes seeding the server's default read set from a 1-length list
708        // backwards compatible.
709        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    // --- json_to_prop_changes: null removes, scalar sets ---
735
736    #[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        // A nested array is not a representable scalar (and is not null).
753        assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
754    }
755
756    // --- json_to_f32_vec ---
757
758    #[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        // Finite as f64 but overflows f32 -> must be rejected, not silently Inf.
777        assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
778    }
779}