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";
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
40/// The ONE canonical default [`IndexSpec`] for TopoDB's built-in write shapes,
41/// shared by every front end (`topodb-mcp` when `--spec` is omitted;
42/// `topodb-cli` when it creates a brand-new db file). Declares equality on
43/// `(Entity, name)`, `(Alias, name)`, and `(Synonym, term)`, and text on
44/// `(Memory, content)`, `(Entity, name)`, and `(Alias, name)`, using the shared
45/// label and property constants.
46///
47/// Single-sourcing this is load-bearing: because a CLI-created db and an
48/// MCP-created db are opened with a *byte-identical* persisted `index_spec`,
49/// either front end can later serve a db the other created via `open_stored`
50/// without triggering an FTS reindex or mis-declaring the equality index — and
51/// both `find` (equality on `Entity`/`name`) and `search` (text on
52/// `Memory`/`content`) work out of the box on a fresh db regardless of which
53/// tool wrote it.
54pub fn default_spec() -> IndexSpec {
55    IndexSpec {
56        equality: vec![
57            PropIndex {
58                label: ENTITY_LABEL.into(),
59                prop: ENTITY_NAME_PROP.into(),
60            },
61            // Aliases resolve exactly like entity names (upsert/find probe
62            // both); synonym terms are looked up per query word.
63            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            // Entity names are text-indexed too (not just equality-indexed)
78            // so `search_memories` can find an entity by name — without
79            // this, a search for "Drew" returns only Memory nodes whose
80            // content happens to mention the name, and the entity itself is
81            // reachable only by exact `find_by_prop`.
82            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
94/// Every stock spec generation this crate has ever shipped, oldest first.
95/// A persisted spec equal (order-insensitively) to ANY of them upgrades to
96/// the current `default_spec`; anything else is a customization and is
97/// returned unchanged.
98fn stock_generations() -> Vec<IndexSpec> {
99    // g0 (pre-0.0.9): equality (Entity, name); text (Memory, content).
100    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    // g1: g0 + text (Entity, name).
111    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
127/// Maps a db's persisted spec forward when — and only when — it is exactly a
128/// stock default this crate has shipped: any recognized stock generation
129/// upgrades to the current [`default_spec`]. Any other spec — a `--spec`
130/// customization, however small — is returned unchanged: silently rewriting a
131/// declared spec would reindex data behind its owner's back. Comparison is
132/// order-insensitive, matching how the engine's `ensure_index_spec` compares
133/// specs (it sorts both lists before persisting).
134pub 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
158/// Canonical form for edge types: Unicode-lowercased, with runs of
159/// whitespace, hyphens, and underscores collapsed to a single underscore
160/// (leading/trailing separators dropped). `"Works At"`, `"works-at"`, and
161/// `"works_at"` all normalize to `"works_at"` — one relation, one vocabulary
162/// entry, instead of three parallel edge types that silently fragment
163/// traversal filters. Every front-end write path (`link` tool, batch `link`
164/// command, CLI) passes edge types through here; read-side type filters
165/// should probe both the raw and normalized forms so edges written before
166/// normalization stay reachable. `Err` on a type that normalizes to empty.
167pub 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
192/// Human/JSON-facing rendering of a [`Scope`]: `"shared"` or the ULID string.
193/// Reused by every front end's `info`/`db_info`-style output and scope
194/// round-tripping. (Distinct from [`scope_to_json`], which wraps the same
195/// rendering in a `serde_json::Value` for a JSON response body; this returns
196/// a bare `String` for contexts — like a struct field — that want the label
197/// without a `Value` wrapper.)
198pub 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
205/// Error string for both directions of an unrepresentable [`PropValue`]:
206/// `Bytes` and `DateTime` have no JSON counterpart over MCP v0, and any JSON
207/// shape that isn't a string/number/bool (array, object, null) has no
208/// [`PropValue`] counterpart either.
209pub const UNSUPPORTED: &str = "unsupported over MCP v0";
210
211/// `PropValue` → `serde_json::Value`. `Str`/`Int`/`Bool` map directly; `Float`
212/// maps to a JSON number (`Err` only for a non-finite float, which JSON has no
213/// representation for). `Bytes`/`DateTime` are [`UNSUPPORTED`].
214pub 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
226/// `serde_json::Value` → `PropValue`. Strings/bools map directly. A JSON
227/// integer maps to `Int` when it fits `i64`, and is an error when it doesn't
228/// (`(i64::MAX, u64::MAX]` — silently downgrading it to a lossy `Float` would
229/// corrupt the value); only a genuine non-integer number maps to `Float`.
230/// This is the inverse of `prop_value_to_json`'s `Int`/`Float` handling.
231/// Every other JSON shape (array, object, null — and, structurally, anything
232/// that would have needed to round-trip through `Bytes`/`DateTime`) is
233/// [`UNSUPPORTED`].
234pub 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                // An integer above i64::MAX: representable in JSON but not in
243                // PropValue::Int, and f64 can't hold it losslessly either.
244                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
255/// `Props` (a `BTreeMap<String, PropValue>`) → a JSON object, propagating the
256/// first unrepresentable value as `Err`.
257pub 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
265/// A JSON object → `Props`, propagating the first unrepresentable value as
266/// `Err`. `Err` if `v` isn't a JSON object at all.
267///
268/// The inverse of `props_to_json`; `topodb-mcp`'s write tools (`create_entity`
269/// / `create_memory` / `link`) call this on the caller-supplied `props`
270/// object.
271///
272/// **v0 limitation:** a JSON integer literal below `i64::MIN` (e.g.
273/// `-99999999999999999999`) is *already* an `f64` by the time it reaches
274/// [`json_to_prop_value`] — `serde_json`'s parser itself has no `i64`-sized
275/// negative bucket wide enough to hold it, so it falls back to a lossy float
276/// at parse time, upstream of anything this module can inspect or reject
277/// (unlike the positive out-of-range case above `i64::MAX`, which parses to
278/// `u64` and so is still catchable). Undetectable and unfixable at this
279/// layer without `serde_json`'s `arbitrary_precision` feature; documented
280/// here as a known v0 gap rather than silently accepted as correct.
281pub 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
292/// A JSON object of property changes for [`topodb::Op::SetNodeProps`]. A `null`
293/// value REMOVES the key (`None`); any other JSON scalar SETS it
294/// (`Some(PropValue)`, via [`json_to_prop_value`]). `Err` if `v` isn't a JSON
295/// object, or a non-null value isn't a representable scalar. The `null`-removes
296/// convention is what lets a caller delete a prop over the wire — plain
297/// [`json_to_props`] has no way to express removal.
298pub 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
313/// A JSON array of finite numbers → `Vec<f32>`, for raw embeddings
314/// ([`topodb::Op::SetEmbedding`]) and vector-search queries. `Err` if `v` isn't
315/// a JSON array, or any element isn't a finite number. (The host computes
316/// embeddings; TopoDB stores/searches the raw floats.)
317pub 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
335/// Builds the `Props` map for a write tool that has one required, caller-named
336/// field (`create_memory`'s `content`, `create_entity`'s `name`) plus an
337/// optional JSON `props` object of additional metadata. `key`/`value` are the
338/// required field, already converted to a `PropValue`; `extra` is the tool
339/// call's optional `props` param, converted via `json_to_props`.
340///
341/// `Err` if `extra` (once converted) already contains `key` — a collision
342/// with the required field is a caller error to be corrected, never silently
343/// overwritten. `Err` also propagates straight through from `json_to_props`
344/// (non-object `extra`, or an unrepresentable value inside it).
345pub 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
363/// A `Scope` → its JSON rendering: `"shared"` or the scope's ULID string.
364/// Mirrors the `shared`/ULID label convention used across TopoDB's JSON-facing
365/// front ends (e.g. `topodb-mcp`'s `db_info` tool).
366pub 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
373/// A `NodeRecord` → JSON: `id`/`label` as strings (ULID via `Display` for
374/// `id`), `scope` per [`scope_to_json`], and `props` per [`props_to_json`].
375/// Deliberately omits the `embedding` field — no MCP v0 tool surfaces vector
376/// data (that's a later concern via dedicated embedding tools).
377pub 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
386/// An `EdgeRecord` → JSON: `id`/`from`/`to` as ULID strings, `type` for `ty`
387/// (JSON-friendlier than the Rust keyword-adjacent field name), `scope` per
388/// [`scope_to_json`], `props` per [`props_to_json`], and the temporal bounds
389/// `valid_from`/`valid_to` (`valid_to` is `null` while the edge is open).
390pub 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
409/// A `Subgraph` → `{"nodes": [...], "edges": [...]}`, each element per
410/// [`node_to_json`]/[`edge_to_json`].
411pub 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
425/// Resolves a tool's optional `scope` string param to a `Scope`: `None` →
426/// `default` (the server's configured default scope); `Some("shared")`
427/// (case-insensitive) → `Scope::Shared`; `Some(<ulid>)` → `Scope::Id`; any
428/// other string → a clear `Err`. Mirrors `topodb-mcp`'s `config::parse_scope`
429/// "shared" / ULID contract, generalized to the `Option` (tool-call) case.
430pub 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
440/// A resolved `Scope` → the singleton `ScopeSet` a read call needs: `Shared`
441/// admits only the shared scope, `Id(id)` admits only that one scope id.
442pub 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
449/// Several resolved `Scope`s → the `ScopeSet` a multi-scope read runs against.
450/// `Scope::Shared` sets the set's `include_shared` flag; each `Scope::Id`
451/// becomes a member id. This is the only constructor that can produce a
452/// genuinely multi-member `ScopeSet` — [`scope_to_scope_set`] always collapses
453/// to a singleton, which is why "this project *plus* shared" was previously
454/// unexpressible from any client.
455///
456/// An empty slice yields a set that admits nothing. Callers must not hand a
457/// read an empty set expecting "everything" — there is no unscoped read.
458pub 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    // --- PropValue <-> Value: Str/Int/Bool both ways ---
488
489    #[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    // --- Float <-> JSON number: JSON int -> Int, JSON float -> Float ---
516
517    #[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        // And just past the i64 boundary too, not only at the extreme.
553        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    // --- Bytes/DateTime unsupported, both directions ---
564
565    #[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    // --- Props <-> JSON object ---
596
597    #[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    // --- merge_required_prop: the create_memory/create_entity collision rule ---
629
630    #[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        // And the same for `name` (create_entity's required key), to confirm
658        // this isn't hardcoded to "content".
659        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        // The collision must be rejected outright, not silently resolved by
666        // either value winning — assert no props map is returned at all.
667        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    // --- node/edge/subgraph -> JSON ---
679
680    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        // `id` round-trips through NodeId's ULID Display/FromStr.
713        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    // --- normalize_edge_type: one relation, one vocabulary entry ---
764
765    #[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    // --- upgraded_spec: stock specs upgrade, customized specs don't ---
793
794    #[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        // Idempotent: the current default maps to itself... via the
808        // not-legacy branch (it is not byte-equal to the legacy spec).
809        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    // --- scope resolution ---
828
829    #[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        // The new multi-member constructor must agree with the existing
901        // single-scope one for a one-element input — that equivalence is what
902        // makes seeding the server's default read set from a 1-length list
903        // backwards compatible.
904        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    // --- json_to_prop_changes: null removes, scalar sets ---
930
931    #[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        // A nested array is not a representable scalar (and is not null).
948        assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
949    }
950
951    // --- json_to_f32_vec ---
952
953    #[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        // Finite as f64 but overflows f32 -> must be rejected, not silently Inf.
972        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        // v0 (pre-0.0.9): eq (Entity,name); text (Memory,content).
989        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        // v1: v0 + text (Entity,name).
1000        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}