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//! Most functions here are pure (no I/O, no `Db` access) and return `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`. Exception: the `compose` module reads from a `Db` to
10//! plan writes, but never writes itself.
11
12mod batch;
13pub use batch::resolve_batch;
14
15mod compose;
16pub use compose::{
17    content_hash, entity_dedup_key, existing_memory, find_existing_entity, memory_props,
18    normalize_content, plan_remember, resolve_entities_by_name, ComposeError, PlannedEntity,
19    RememberPlan, RememberRequest, DEFAULT_REMEMBER_EDGE_TYPE,
20};
21
22mod retry;
23pub use retry::open_with_busy_retry;
24
25use serde_json::{Map, Value};
26use std::collections::BTreeMap;
27use std::str::FromStr;
28use topodb::{
29    EdgeRecord, IndexSpec, NodeRecord, PropIndex, PropValue, Props, Scope, ScopeId, ScopeSet,
30    Subgraph,
31};
32
33/// Label/prop name constants for the two built-in write shapes
34/// (`create_memory`/`create-memory`, `create_entity`/`create-entity`). Single
35/// source of truth shared by every front end (`topodb-mcp`'s default
36/// [`IndexSpec`](topodb::IndexSpec) and write tools, `topodb-cli`'s
37/// `create-entity`/`create-memory` subcommands) so writes land on exactly the
38/// `(label, prop)` pairs the default spec indexes — search and lookup work
39/// out of the box regardless of which front end wrote the data.
40pub const ENTITY_LABEL: &str = "Entity";
41pub const ENTITY_NAME_PROP: &str = "name";
42pub const MEMORY_LABEL: &str = "Memory";
43pub const MEMORY_CONTENT_PROP: &str = "content";
44/// Equality-indexed hash of a memory's normalized content, used to dedup a
45/// re-stored fact to the existing node instead of minting a duplicate. Set by
46/// the write front ends (`remember`/`create_memory`), never by a caller.
47pub const MEMORY_CONTENT_HASH_PROP: &str = "content_hash";
48/// Millisecond timestamp at which a memory was superseded by a newer fact.
49/// Set by `remember`'s `supersedes`; recall drops a memory whose value here is
50/// `<=` the query's `now` (so an `as_of` before it still sees the old fact).
51/// The node is not deleted — supersession dates a fact, keeping its history.
52pub const MEMORY_SUPERSEDED_AT_PROP: &str = "superseded_at";
53pub const ALIAS_LABEL: &str = "Alias";
54pub const ALIAS_NAME_PROP: &str = "name";
55pub const ALIAS_EDGE_TYPE: &str = "alias_of";
56pub const SYNONYM_LABEL: &str = "Synonym";
57pub const SYNONYM_TERM_PROP: &str = "term";
58pub const SYNONYM_EXPANSION_PROP: &str = "expansion";
59
60/// The ONE canonical default [`IndexSpec`] for TopoDB's built-in write shapes,
61/// shared by every front end (`topodb-mcp` when `--spec` is omitted;
62/// `topodb-cli` when it creates a brand-new db file). Declares equality on
63/// `(Entity, name)`, `(Alias, name)`, and `(Synonym, term)`, and text on
64/// `(Memory, content)`, `(Entity, name)`, and `(Alias, name)`, using the shared
65/// label and property constants.
66///
67/// Single-sourcing this is load-bearing: because a CLI-created db and an
68/// MCP-created db are opened with a *byte-identical* persisted `index_spec`,
69/// either front end can later serve a db the other created via `open_stored`
70/// without triggering an FTS reindex or mis-declaring the equality index — and
71/// both `find` (equality on `Entity`/`name`) and `search` (text on
72/// `Memory`/`content`) work out of the box on a fresh db regardless of which
73/// tool wrote it.
74pub fn default_spec() -> IndexSpec {
75    IndexSpec {
76        equality: vec![
77            PropIndex {
78                label: ENTITY_LABEL.into(),
79                prop: ENTITY_NAME_PROP.into(),
80            },
81            // Aliases resolve exactly like entity names (upsert/find probe
82            // both); synonym terms are looked up per query word.
83            PropIndex {
84                label: ALIAS_LABEL.into(),
85                prop: ALIAS_NAME_PROP.into(),
86            },
87            PropIndex {
88                label: SYNONYM_LABEL.into(),
89                prop: SYNONYM_TERM_PROP.into(),
90            },
91            // A memory's normalized-content hash, so a re-stored fact resolves
92            // to its existing node (content-verified) instead of duplicating.
93            PropIndex {
94                label: MEMORY_LABEL.into(),
95                prop: MEMORY_CONTENT_HASH_PROP.into(),
96            },
97        ],
98        text: vec![
99            PropIndex {
100                label: MEMORY_LABEL.into(),
101                prop: MEMORY_CONTENT_PROP.into(),
102            },
103            // Entity names are text-indexed too (not just equality-indexed)
104            // so `search_memories` can find an entity by name — without
105            // this, a search for "Drew" returns only Memory nodes whose
106            // content happens to mention the name, and the entity itself is
107            // reachable only by exact `find_by_prop`.
108            PropIndex {
109                label: ENTITY_LABEL.into(),
110                prop: ENTITY_NAME_PROP.into(),
111            },
112            PropIndex {
113                label: ALIAS_LABEL.into(),
114                prop: ALIAS_NAME_PROP.into(),
115            },
116        ],
117    }
118}
119
120/// Every stock spec generation this crate has ever shipped, oldest first.
121/// A persisted spec equal (order-insensitively) to ANY of them upgrades to
122/// the current `default_spec`; anything else is a customization and is
123/// returned unchanged.
124fn stock_generations() -> Vec<IndexSpec> {
125    // g0 (pre-0.0.9): equality (Entity, name); text (Memory, content).
126    let g0 = IndexSpec {
127        equality: vec![PropIndex {
128            label: ENTITY_LABEL.into(),
129            prop: ENTITY_NAME_PROP.into(),
130        }],
131        text: vec![PropIndex {
132            label: MEMORY_LABEL.into(),
133            prop: MEMORY_CONTENT_PROP.into(),
134        }],
135    };
136    // g1: g0 + text (Entity, name).
137    let g1 = IndexSpec {
138        equality: g0.equality.clone(),
139        text: vec![
140            PropIndex {
141                label: MEMORY_LABEL.into(),
142                prop: MEMORY_CONTENT_PROP.into(),
143            },
144            PropIndex {
145                label: ENTITY_LABEL.into(),
146                prop: ENTITY_NAME_PROP.into(),
147            },
148        ],
149    };
150    vec![g0, g1]
151}
152
153/// Maps a db's persisted spec forward when — and only when — it is exactly a
154/// stock default this crate has shipped: any recognized stock generation
155/// upgrades to the current [`default_spec`]. Any other spec — a `--spec`
156/// customization, however small — is returned unchanged: silently rewriting a
157/// declared spec would reindex data behind its owner's back. Comparison is
158/// order-insensitive, matching how the engine's `ensure_index_spec` compares
159/// specs (it sorts both lists before persisting).
160pub fn upgraded_spec(persisted: IndexSpec) -> IndexSpec {
161    let sorted = |spec: &IndexSpec| {
162        let mut eq: Vec<(String, String)> = spec
163            .equality
164            .iter()
165            .map(|p| (p.label.to_string(), p.prop.clone()))
166            .collect();
167        let mut text: Vec<(String, String)> = spec
168            .text
169            .iter()
170            .map(|p| (p.label.to_string(), p.prop.clone()))
171            .collect();
172        eq.sort();
173        text.sort();
174        (eq, text)
175    };
176    let p = sorted(&persisted);
177    if stock_generations().iter().any(|g| sorted(g) == p) {
178        default_spec()
179    } else {
180        persisted
181    }
182}
183
184/// Canonical form for edge types: Unicode-lowercased, with runs of
185/// whitespace, hyphens, and underscores collapsed to a single underscore
186/// (leading/trailing separators dropped). `"Works At"`, `"works-at"`, and
187/// `"works_at"` all normalize to `"works_at"` — one relation, one vocabulary
188/// entry, instead of three parallel edge types that silently fragment
189/// traversal filters. Every front-end write path (`link` tool, batch `link`
190/// command, CLI) passes edge types through here; read-side type filters
191/// should probe both the raw and normalized forms so edges written before
192/// normalization stay reachable. `Err` on a type that normalizes to empty.
193pub fn normalize_edge_type(raw: &str) -> Result<String, String> {
194    let lowered = raw.to_lowercase();
195    let mut out = String::with_capacity(lowered.len());
196    let mut pending_sep = false;
197    for c in lowered.chars() {
198        if c.is_whitespace() || c == '-' || c == '_' {
199            if !out.is_empty() {
200                pending_sep = true;
201            }
202        } else {
203            if pending_sep {
204                out.push('_');
205                pending_sep = false;
206            }
207            out.push(c);
208        }
209    }
210    if out.is_empty() {
211        return Err(format!(
212            "edge type {raw:?} is empty once normalized (lowercase, separators collapsed to '_')"
213        ));
214    }
215    Ok(out)
216}
217
218/// Human/JSON-facing rendering of a [`Scope`]: `"shared"` or the ULID string.
219/// Reused by every front end's `info`/`db_info`-style output and scope
220/// round-tripping. (Distinct from [`scope_to_json`], which wraps the same
221/// rendering in a `serde_json::Value` for a JSON response body; this returns
222/// a bare `String` for contexts — like a struct field — that want the label
223/// without a `Value` wrapper.)
224pub fn scope_label(scope: &Scope) -> String {
225    match scope {
226        Scope::Shared => "shared".to_string(),
227        Scope::Id(id) => id.to_string(),
228    }
229}
230
231/// Error string for both directions of an unrepresentable [`PropValue`]:
232/// `Bytes` and `DateTime` have no JSON counterpart over MCP v0, and any JSON
233/// shape that isn't a string/number/bool (array, object, null) has no
234/// [`PropValue`] counterpart either.
235pub const UNSUPPORTED: &str = "unsupported over MCP v0";
236
237/// `PropValue` → `serde_json::Value`. `Str`/`Int`/`Bool` map directly; `Float`
238/// maps to a JSON number (`Err` only for a non-finite float, which JSON has no
239/// representation for). `Bytes`/`DateTime` are [`UNSUPPORTED`].
240pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
241    match v {
242        PropValue::Str(s) => Ok(Value::String(s.clone())),
243        PropValue::Int(i) => Ok(Value::Number((*i).into())),
244        PropValue::Float(f) => serde_json::Number::from_f64(*f)
245            .map(Value::Number)
246            .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
247        PropValue::Bool(b) => Ok(Value::Bool(*b)),
248        PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
249    }
250}
251
252/// `serde_json::Value` → `PropValue`. Strings/bools map directly. A JSON
253/// integer maps to `Int` when it fits `i64`, and is an error when it doesn't
254/// (`(i64::MAX, u64::MAX]` — silently downgrading it to a lossy `Float` would
255/// corrupt the value); only a genuine non-integer number maps to `Float`.
256/// This is the inverse of `prop_value_to_json`'s `Int`/`Float` handling.
257/// Every other JSON shape (array, object, null — and, structurally, anything
258/// that would have needed to round-trip through `Bytes`/`DateTime`) is
259/// [`UNSUPPORTED`].
260pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
261    match v {
262        Value::String(s) => Ok(PropValue::Str(s.clone())),
263        Value::Bool(b) => Ok(PropValue::Bool(*b)),
264        Value::Number(n) => {
265            if let Some(i) = n.as_i64() {
266                Ok(PropValue::Int(i))
267            } else if n.is_u64() {
268                // An integer above i64::MAX: representable in JSON but not in
269                // PropValue::Int, and f64 can't hold it losslessly either.
270                Err(format!("integer out of supported range (max {})", i64::MAX))
271            } else if let Some(f) = n.as_f64() {
272                Ok(PropValue::Float(f))
273            } else {
274                Err(format!("{UNSUPPORTED}: number out of range"))
275            }
276        }
277        Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
278    }
279}
280
281/// `Props` (a `BTreeMap<String, PropValue>`) → a JSON object, propagating the
282/// first unrepresentable value as `Err`.
283pub fn props_to_json(props: &Props) -> Result<Value, String> {
284    let mut map = Map::with_capacity(props.len());
285    for (k, v) in props {
286        map.insert(k.clone(), prop_value_to_json(v)?);
287    }
288    Ok(Value::Object(map))
289}
290
291/// A JSON object → `Props`, propagating the first unrepresentable value as
292/// `Err`. `Err` if `v` isn't a JSON object at all.
293///
294/// The inverse of `props_to_json`; `topodb-mcp`'s write tools (`create_entity`
295/// / `create_memory` / `link`) call this on the caller-supplied `props`
296/// object.
297///
298/// **v0 limitation:** a JSON integer literal below `i64::MIN` (e.g.
299/// `-99999999999999999999`) is *already* an `f64` by the time it reaches
300/// [`json_to_prop_value`] — `serde_json`'s parser itself has no `i64`-sized
301/// negative bucket wide enough to hold it, so it falls back to a lossy float
302/// at parse time, upstream of anything this module can inspect or reject
303/// (unlike the positive out-of-range case above `i64::MAX`, which parses to
304/// `u64` and so is still catchable). Undetectable and unfixable at this
305/// layer without `serde_json`'s `arbitrary_precision` feature; documented
306/// here as a known v0 gap rather than silently accepted as correct.
307pub fn json_to_props(v: &Value) -> Result<Props, String> {
308    let obj = v
309        .as_object()
310        .ok_or_else(|| "expected a JSON object for props".to_string())?;
311    let mut props = Props::new();
312    for (k, val) in obj {
313        props.insert(k.clone(), json_to_prop_value(val)?);
314    }
315    Ok(props)
316}
317
318/// A JSON object of property changes for [`topodb::Op::SetNodeProps`]. A `null`
319/// value REMOVES the key (`None`); any other JSON scalar SETS it
320/// (`Some(PropValue)`, via [`json_to_prop_value`]). `Err` if `v` isn't a JSON
321/// object, or a non-null value isn't a representable scalar. The `null`-removes
322/// convention is what lets a caller delete a prop over the wire — plain
323/// [`json_to_props`] has no way to express removal.
324pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
325    let obj = v
326        .as_object()
327        .ok_or_else(|| "expected a JSON object for props".to_string())?;
328    let mut out = BTreeMap::new();
329    for (k, val) in obj {
330        let entry = match val {
331            Value::Null => None,
332            other => Some(json_to_prop_value(other)?),
333        };
334        out.insert(k.clone(), entry);
335    }
336    Ok(out)
337}
338
339/// A JSON array of finite numbers → `Vec<f32>`, for raw embeddings
340/// ([`topodb::Op::SetEmbedding`]) and vector-search queries. `Err` if `v` isn't
341/// a JSON array, or any element isn't a finite number. (The host computes
342/// embeddings; TopoDB stores/searches the raw floats.)
343pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
344    let arr = v
345        .as_array()
346        .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
347    let mut out = Vec::with_capacity(arr.len());
348    for (i, el) in arr.iter().enumerate() {
349        let f = el
350            .as_f64()
351            .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
352        let f = f as f32;
353        if !f.is_finite() {
354            return Err(format!("vector element {i} is not finite"));
355        }
356        out.push(f);
357    }
358    Ok(out)
359}
360
361/// Builds the `Props` map for a write tool that has one required, caller-named
362/// field (`create_memory`'s `content`, `create_entity`'s `name`) plus an
363/// optional JSON `props` object of additional metadata. `key`/`value` are the
364/// required field, already converted to a `PropValue`; `extra` is the tool
365/// call's optional `props` param, converted via `json_to_props`.
366///
367/// `Err` if `extra` (once converted) already contains `key` — a collision
368/// with the required field is a caller error to be corrected, never silently
369/// overwritten. `Err` also propagates straight through from `json_to_props`
370/// (non-object `extra`, or an unrepresentable value inside it).
371pub fn merge_required_prop(
372    key: &str,
373    value: PropValue,
374    extra: Option<&Value>,
375) -> Result<Props, String> {
376    let mut props = match extra {
377        Some(v) => json_to_props(v)?,
378        None => Props::new(),
379    };
380    if props.contains_key(key) {
381        return Err(format!(
382            "props must not include {key:?}: it is already set from the tool's own parameter"
383        ));
384    }
385    props.insert(key.to_string(), value);
386    Ok(props)
387}
388
389/// A `Scope` → its JSON rendering: `"shared"` or the scope's ULID string.
390/// Mirrors the `shared`/ULID label convention used across TopoDB's JSON-facing
391/// front ends (e.g. `topodb-mcp`'s `db_info` tool).
392pub fn scope_to_json(scope: Scope) -> Value {
393    Value::String(match scope {
394        Scope::Shared => "shared".to_string(),
395        Scope::Id(id) => id.to_string(),
396    })
397}
398
399/// A `NodeRecord` → JSON: `id`/`label` as strings (ULID via `Display` for
400/// `id`), `scope` per [`scope_to_json`], and `props` per [`props_to_json`].
401/// Deliberately omits the `embedding` field — no MCP v0 tool surfaces vector
402/// data (that's a later concern via dedicated embedding tools).
403pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
404    let mut map = Map::new();
405    map.insert("id".into(), Value::String(n.id.to_string()));
406    map.insert("scope".into(), scope_to_json(n.scope));
407    map.insert("label".into(), Value::String(n.label.to_string()));
408    map.insert("props".into(), props_to_json(&n.props)?);
409    Ok(Value::Object(map))
410}
411
412/// An `EdgeRecord` → JSON: `id`/`from`/`to` as ULID strings, `type` for `ty`
413/// (JSON-friendlier than the Rust keyword-adjacent field name), `scope` per
414/// [`scope_to_json`], `props` per [`props_to_json`], and the temporal bounds
415/// `valid_from`/`valid_to` (`valid_to` is `null` while the edge is open).
416pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
417    let mut map = Map::new();
418    map.insert("id".into(), Value::String(e.id.to_string()));
419    map.insert("scope".into(), scope_to_json(e.scope));
420    map.insert("type".into(), Value::String(e.ty.to_string()));
421    map.insert("from".into(), Value::String(e.from.to_string()));
422    map.insert("to".into(), Value::String(e.to.to_string()));
423    map.insert("props".into(), props_to_json(&e.props)?);
424    map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
425    map.insert(
426        "valid_to".into(),
427        match e.valid_to {
428            Some(t) => Value::Number(t.into()),
429            None => Value::Null,
430        },
431    );
432    Ok(Value::Object(map))
433}
434
435/// Determine whether an edge is "live" (valid/present) at a given Unix-ms
436/// timestamp. The validity window is inclusive on the lower bound
437/// (`valid_from <= t`) and exclusive on the upper bound (`valid_to > t`).
438/// Open edges (no `valid_to`) are treated as eternally open.
439///
440/// Used by both `topodb-mcp`'s `get_edges` tool and `topodb-cli`'s
441/// `get-edges` command to filter edges by `--as-of` time, ensuring consistent
442/// liveness semantics across all frontends.
443pub fn edge_live_at(e: &EdgeRecord, t: i64) -> bool {
444    e.valid_from <= t && e.valid_to.is_none_or(|vt| vt > t)
445}
446
447/// A `Subgraph` → `{"nodes": [...], "edges": [...]}`, each element per
448/// [`node_to_json`]/[`edge_to_json`].
449pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
450    let nodes: Vec<Value> = sg
451        .nodes
452        .iter()
453        .map(node_to_json)
454        .collect::<Result<_, _>>()?;
455    let edges: Vec<Value> = sg
456        .edges
457        .iter()
458        .map(edge_to_json)
459        .collect::<Result<_, _>>()?;
460    Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
461}
462
463/// Resolves a tool's optional `scope` string param to a `Scope`: `None` →
464/// `default` (the server's configured default scope); `Some("shared")`
465/// (case-insensitive) → `Scope::Shared`; `Some(<ulid>)` → `Scope::Id`; any
466/// other string → a clear `Err`. Mirrors `topodb-mcp`'s `config::parse_scope`
467/// "shared" / ULID contract, generalized to the `Option` (tool-call) case.
468pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
469    match scope {
470        None => Ok(default),
471        Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
472        Some(s) => ScopeId::from_str(s)
473            .map(Scope::Id)
474            .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
475    }
476}
477
478/// A resolved `Scope` → the singleton `ScopeSet` a read call needs: `Shared`
479/// admits only the shared scope, `Id(id)` admits only that one scope id.
480pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
481    match scope {
482        Scope::Shared => ScopeSet::default().with_shared(),
483        Scope::Id(id) => ScopeSet::of(&[id]),
484    }
485}
486
487/// Several resolved `Scope`s → the `ScopeSet` a multi-scope read runs against.
488/// `Scope::Shared` sets the set's `include_shared` flag; each `Scope::Id`
489/// becomes a member id. This is the only constructor that can produce a
490/// genuinely multi-member `ScopeSet` — [`scope_to_scope_set`] always collapses
491/// to a singleton, which is why "this project *plus* shared" was previously
492/// unexpressible from any client.
493///
494/// An empty slice yields a set that admits nothing. Callers must not hand a
495/// read an empty set expecting "everything" — there is no unscoped read.
496pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
497    let ids: Vec<ScopeId> = scopes
498        .iter()
499        .filter_map(|s| match s {
500            Scope::Id(id) => Some(*id),
501            Scope::Shared => None,
502        })
503        .collect();
504    let set = ScopeSet::of(&ids);
505    if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
506        set.with_shared()
507    } else {
508        set
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use topodb::NodeId;
516
517    fn props(pairs: &[(&str, PropValue)]) -> Props {
518        pairs
519            .iter()
520            .cloned()
521            .map(|(k, v)| (k.to_string(), v))
522            .collect()
523    }
524
525    // --- PropValue <-> Value: Str/Int/Bool both ways ---
526
527    #[test]
528    fn str_round_trips() {
529        let v = PropValue::Str("hello".into());
530        let j = prop_value_to_json(&v).unwrap();
531        assert_eq!(j, Value::String("hello".into()));
532        assert_eq!(json_to_prop_value(&j).unwrap(), v);
533    }
534
535    #[test]
536    fn int_round_trips() {
537        let v = PropValue::Int(-42);
538        let j = prop_value_to_json(&v).unwrap();
539        assert_eq!(j, serde_json::json!(-42));
540        assert_eq!(json_to_prop_value(&j).unwrap(), v);
541    }
542
543    #[test]
544    fn bool_round_trips() {
545        for b in [true, false] {
546            let v = PropValue::Bool(b);
547            let j = prop_value_to_json(&v).unwrap();
548            assert_eq!(j, Value::Bool(b));
549            assert_eq!(json_to_prop_value(&j).unwrap(), v);
550        }
551    }
552
553    // --- Float <-> JSON number: JSON int -> Int, JSON float -> Float ---
554
555    #[test]
556    fn float_to_json_is_a_json_number() {
557        let v = PropValue::Float(3.5);
558        let j = prop_value_to_json(&v).unwrap();
559        assert_eq!(j, serde_json::json!(3.5));
560    }
561
562    #[test]
563    fn json_integer_literal_decodes_to_int_not_float() {
564        let j = serde_json::json!(7);
565        assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
566    }
567
568    #[test]
569    fn json_float_literal_decodes_to_float() {
570        let j = serde_json::json!(7.5);
571        assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
572    }
573
574    #[test]
575    fn i64_max_round_trips_as_int() {
576        let v = PropValue::Int(i64::MAX);
577        let j = prop_value_to_json(&v).unwrap();
578        assert_eq!(j, serde_json::json!(i64::MAX));
579        assert_eq!(json_to_prop_value(&j).unwrap(), v);
580    }
581
582    #[test]
583    fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
584        let j = serde_json::json!(u64::MAX);
585        let err = json_to_prop_value(&j).unwrap_err();
586        assert!(
587            err.contains("integer out of supported range"),
588            "expected a clear out-of-range error, got: {err}"
589        );
590        // And just past the i64 boundary too, not only at the extreme.
591        let j = serde_json::json!(i64::MAX as u64 + 1);
592        assert!(json_to_prop_value(&j).is_err());
593    }
594
595    #[test]
596    fn non_finite_float_to_json_is_an_error() {
597        assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
598        assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
599    }
600
601    // --- Bytes/DateTime unsupported, both directions ---
602
603    #[test]
604    fn bytes_to_json_is_unsupported() {
605        let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
606        assert_eq!(err, UNSUPPORTED);
607    }
608
609    #[test]
610    fn datetime_to_json_is_unsupported() {
611        let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
612        assert_eq!(err, UNSUPPORTED);
613    }
614
615    #[test]
616    fn json_array_to_propvalue_is_unsupported() {
617        let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
618        assert_eq!(err, UNSUPPORTED);
619    }
620
621    #[test]
622    fn json_object_to_propvalue_is_unsupported() {
623        let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
624        assert_eq!(err, UNSUPPORTED);
625    }
626
627    #[test]
628    fn json_null_to_propvalue_is_unsupported() {
629        let err = json_to_prop_value(&Value::Null).unwrap_err();
630        assert_eq!(err, UNSUPPORTED);
631    }
632
633    // --- Props <-> JSON object ---
634
635    #[test]
636    fn props_round_trip() {
637        let p = props(&[
638            ("name", PropValue::Str("ada".into())),
639            ("age", PropValue::Int(30)),
640            ("active", PropValue::Bool(true)),
641            ("score", PropValue::Float(1.5)),
642        ]);
643        let j = props_to_json(&p).unwrap();
644        assert!(j.is_object());
645        let back = json_to_props(&j).unwrap();
646        assert_eq!(back, p);
647    }
648
649    #[test]
650    fn props_to_json_propagates_unsupported_value() {
651        let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
652        assert!(props_to_json(&p).is_err());
653    }
654
655    #[test]
656    fn json_to_props_rejects_non_object() {
657        assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
658    }
659
660    #[test]
661    fn json_to_props_propagates_unsupported_field() {
662        let j = serde_json::json!({"bad": [1, 2]});
663        assert!(json_to_props(&j).is_err());
664    }
665
666    // --- merge_required_prop: the create_memory/create_entity collision rule ---
667
668    #[test]
669    fn merge_required_prop_with_no_extra_just_sets_the_key() {
670        let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
671        assert_eq!(props.len(), 1);
672        assert_eq!(props["content"], PropValue::Str("hi".into()));
673    }
674
675    #[test]
676    fn merge_required_prop_merges_additional_fields() {
677        let extra = serde_json::json!({"source": "chat", "confidence": 3});
678        let props =
679            merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
680        assert_eq!(props.len(), 3);
681        assert_eq!(props["content"], PropValue::Str("hi".into()));
682        assert_eq!(props["source"], PropValue::Str("chat".into()));
683        assert_eq!(props["confidence"], PropValue::Int(3));
684    }
685
686    #[test]
687    fn merge_required_prop_rejects_collision_with_required_key() {
688        let extra = serde_json::json!({"content": "sneaky overwrite"});
689        let err =
690            merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
691        assert!(
692            err.contains("content"),
693            "error should name the colliding key: {err}"
694        );
695        // And the same for `name` (create_entity's required key), to confirm
696        // this isn't hardcoded to "content".
697        let extra = serde_json::json!({"name": "sneaky"});
698        assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
699    }
700
701    #[test]
702    fn merge_required_prop_does_not_overwrite_on_collision() {
703        // The collision must be rejected outright, not silently resolved by
704        // either value winning — assert no props map is returned at all.
705        let extra = serde_json::json!({"content": "other"});
706        let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
707        assert!(result.is_err());
708    }
709
710    #[test]
711    fn merge_required_prop_propagates_non_object_extra() {
712        let extra = serde_json::json!([1, 2]);
713        assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
714    }
715
716    // --- node/edge/subgraph -> JSON ---
717
718    fn sample_node(scope: Scope) -> NodeRecord {
719        NodeRecord {
720            id: NodeId::new(),
721            scope,
722            label: "Entity".into(),
723            props: props(&[("name", PropValue::Str("ada".into()))]),
724            embedding: None,
725        }
726    }
727
728    fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
729        EdgeRecord {
730            id: topodb::EdgeId::new(),
731            scope,
732            ty: "ABOUT".into(),
733            from,
734            to,
735            props: Props::new(),
736            valid_from: 1_000,
737            valid_to: None,
738        }
739    }
740
741    #[test]
742    fn node_to_json_has_ulid_id_and_declared_fields() {
743        let scope = Scope::Id(ScopeId::new());
744        let n = sample_node(scope);
745        let j = node_to_json(&n).unwrap();
746        assert_eq!(j["id"], Value::String(n.id.to_string()));
747        assert_eq!(j["label"], Value::String("Entity".into()));
748        assert_eq!(j["scope"], scope_to_json(scope));
749        assert_eq!(j["props"]["name"], Value::String("ada".into()));
750        // `id` round-trips through NodeId's ULID Display/FromStr.
751        let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
752        assert_eq!(parsed, n.id);
753    }
754
755    #[test]
756    fn node_to_json_propagates_unsupported_prop() {
757        let mut n = sample_node(Scope::Shared);
758        n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
759        assert!(node_to_json(&n).is_err());
760    }
761
762    #[test]
763    fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
764        let scope = Scope::Shared;
765        let a = NodeId::new();
766        let b = NodeId::new();
767        let e = sample_edge(scope, a, b);
768        let j = edge_to_json(&e).unwrap();
769        assert_eq!(j["id"], Value::String(e.id.to_string()));
770        assert_eq!(j["from"], Value::String(a.to_string()));
771        assert_eq!(j["to"], Value::String(b.to_string()));
772        assert_eq!(j["type"], Value::String("ABOUT".into()));
773        assert_eq!(j["valid_from"], serde_json::json!(1_000));
774        assert_eq!(j["valid_to"], Value::Null);
775    }
776
777    #[test]
778    fn edge_to_json_closed_edge_has_numeric_valid_to() {
779        let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
780        e.valid_to = Some(2_000);
781        let j = edge_to_json(&e).unwrap();
782        assert_eq!(j["valid_to"], serde_json::json!(2_000));
783    }
784
785    #[test]
786    fn subgraph_to_json_nests_nodes_and_edges() {
787        let scope = Scope::Shared;
788        let a = sample_node(scope);
789        let b = sample_node(scope);
790        let e = sample_edge(scope, a.id, b.id);
791        let sg = Subgraph {
792            nodes: vec![a.clone(), b.clone()],
793            edges: vec![e.clone()],
794        };
795        let j = subgraph_to_json(&sg).unwrap();
796        assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
797        assert_eq!(j["edges"].as_array().unwrap().len(), 1);
798        assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
799    }
800
801    // --- normalize_edge_type: one relation, one vocabulary entry ---
802
803    #[test]
804    fn edge_type_variants_normalize_to_one_form() {
805        for raw in [
806            "works_at",
807            "Works At",
808            "works-at",
809            "WORKS_AT",
810            " works  at ",
811            "works--at",
812            "works_-at",
813        ] {
814            assert_eq!(
815                normalize_edge_type(raw).unwrap(),
816                "works_at",
817                "{raw:?} should normalize to works_at"
818            );
819        }
820        assert_eq!(normalize_edge_type("about").unwrap(), "about");
821    }
822
823    #[test]
824    fn edge_type_empty_after_normalization_is_an_error() {
825        for raw in ["", "   ", "---", "_", " - _ "] {
826            assert!(normalize_edge_type(raw).is_err(), "{raw:?} should error");
827        }
828    }
829
830    // --- upgraded_spec: stock specs upgrade, customized specs don't ---
831
832    #[test]
833    fn legacy_stock_spec_upgrades_to_current_default() {
834        let legacy = IndexSpec {
835            equality: vec![PropIndex {
836                label: ENTITY_LABEL.into(),
837                prop: ENTITY_NAME_PROP.into(),
838            }],
839            text: vec![PropIndex {
840                label: MEMORY_LABEL.into(),
841                prop: MEMORY_CONTENT_PROP.into(),
842            }],
843        };
844        assert_eq!(upgraded_spec(legacy), default_spec());
845        // Idempotent: the current default maps to itself... via the
846        // not-legacy branch (it is not byte-equal to the legacy spec).
847        assert_eq!(upgraded_spec(default_spec()), default_spec());
848    }
849
850    #[test]
851    fn customized_spec_is_never_rewritten() {
852        let custom = IndexSpec {
853            equality: vec![PropIndex {
854                label: "Person".into(),
855                prop: "handle".into(),
856            }],
857            text: vec![PropIndex {
858                label: MEMORY_LABEL.into(),
859                prop: MEMORY_CONTENT_PROP.into(),
860            }],
861        };
862        assert_eq!(upgraded_spec(custom.clone()), custom);
863    }
864
865    // --- scope resolution ---
866
867    #[test]
868    fn resolve_scope_none_uses_default() {
869        let id = ScopeId::new();
870        assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
871        assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
872    }
873
874    #[test]
875    fn resolve_scope_shared_is_case_insensitive() {
876        assert_eq!(
877            resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
878            Scope::Shared
879        );
880        assert_eq!(
881            resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
882            Scope::Shared
883        );
884    }
885
886    #[test]
887    fn resolve_scope_ulid_parses_to_id() {
888        let id = ScopeId::new();
889        let s = id.to_string();
890        assert_eq!(
891            resolve_scope(Some(&s), Scope::Shared).unwrap(),
892            Scope::Id(id)
893        );
894    }
895
896    #[test]
897    fn resolve_scope_garbage_is_a_clear_error() {
898        let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
899        assert!(err.contains("not-a-ulid"));
900    }
901
902    #[test]
903    fn scope_to_scope_set_shared_admits_only_shared() {
904        let set = scope_to_scope_set(Scope::Shared);
905        assert!(set.contains(Scope::Shared));
906        assert!(!set.contains(Scope::Id(ScopeId::new())));
907    }
908
909    #[test]
910    fn scope_to_scope_set_id_admits_only_that_id() {
911        let id = ScopeId::new();
912        let set = scope_to_scope_set(Scope::Id(id));
913        assert!(set.contains(Scope::Id(id)));
914        assert!(!set.contains(Scope::Shared));
915        assert!(!set.contains(Scope::Id(ScopeId::new())));
916    }
917
918    #[test]
919    fn scopes_to_scope_set_admits_every_member() {
920        let a = ScopeId::new();
921        let b = ScopeId::new();
922        let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
923        assert!(set.contains(Scope::Id(a)));
924        assert!(set.contains(Scope::Id(b)));
925        assert!(set.contains(Scope::Shared));
926    }
927
928    #[test]
929    fn scopes_to_scope_set_without_shared_excludes_shared() {
930        let a = ScopeId::new();
931        let set = scopes_to_scope_set(&[Scope::Id(a)]);
932        assert!(set.contains(Scope::Id(a)));
933        assert!(!set.contains(Scope::Shared));
934    }
935
936    #[test]
937    fn scopes_to_scope_set_matches_singleton_for_one_member() {
938        // The new multi-member constructor must agree with the existing
939        // single-scope one for a one-element input — that equivalence is what
940        // makes seeding the server's default read set from a 1-length list
941        // backwards compatible.
942        let a = ScopeId::new();
943        let multi = scopes_to_scope_set(&[Scope::Id(a)]);
944        let single = scope_to_scope_set(Scope::Id(a));
945        assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
946        assert_eq!(
947            multi.contains(Scope::Shared),
948            single.contains(Scope::Shared)
949        );
950
951        let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
952        let single_shared = scope_to_scope_set(Scope::Shared);
953        assert_eq!(
954            multi_shared.contains(Scope::Shared),
955            single_shared.contains(Scope::Shared)
956        );
957    }
958
959    #[test]
960    fn scopes_to_scope_set_empty_admits_nothing() {
961        let a = ScopeId::new();
962        let set = scopes_to_scope_set(&[]);
963        assert!(!set.contains(Scope::Shared));
964        assert!(!set.contains(Scope::Id(a)));
965    }
966
967    // --- json_to_prop_changes: null removes, scalar sets ---
968
969    #[test]
970    fn prop_changes_null_is_remove_scalar_is_set() {
971        let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
972        let changes = json_to_prop_changes(&j).unwrap();
973        assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
974        assert_eq!(changes["stale"], None);
975        assert_eq!(changes["n"], Some(PropValue::Int(3)));
976    }
977
978    #[test]
979    fn prop_changes_rejects_non_object() {
980        assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
981    }
982
983    #[test]
984    fn prop_changes_propagates_unsupported_value() {
985        // A nested array is not a representable scalar (and is not null).
986        assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
987    }
988
989    // --- json_to_f32_vec ---
990
991    #[test]
992    fn f32_vec_parses_numbers() {
993        let j = serde_json::json!([0.0, 1.5, -2, 3]);
994        assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
995    }
996
997    #[test]
998    fn f32_vec_rejects_non_array() {
999        assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
1000    }
1001
1002    #[test]
1003    fn f32_vec_rejects_non_number_element() {
1004        assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
1005    }
1006
1007    #[test]
1008    fn f32_vec_rejects_overflow_to_infinity() {
1009        // Finite as f64 but overflows f32 -> must be rejected, not silently Inf.
1010        assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
1011    }
1012
1013    #[test]
1014    fn default_spec_covers_alias_and_synonym() {
1015        let s = default_spec();
1016        let has = |list: &[PropIndex], l: &str, p: &str| {
1017            list.iter().any(|pi| pi.label == l && pi.prop == p)
1018        };
1019        assert!(has(&s.equality, ALIAS_LABEL, ALIAS_NAME_PROP));
1020        assert!(has(&s.equality, SYNONYM_LABEL, SYNONYM_TERM_PROP));
1021        assert!(has(&s.text, ALIAS_LABEL, ALIAS_NAME_PROP));
1022    }
1023
1024    #[test]
1025    fn every_stock_generation_upgrades_to_current_default() {
1026        // v0 (pre-0.0.9): eq (Entity,name); text (Memory,content).
1027        let v0 = IndexSpec {
1028            equality: vec![PropIndex {
1029                label: ENTITY_LABEL.into(),
1030                prop: ENTITY_NAME_PROP.into(),
1031            }],
1032            text: vec![PropIndex {
1033                label: MEMORY_LABEL.into(),
1034                prop: MEMORY_CONTENT_PROP.into(),
1035            }],
1036        };
1037        // v1: v0 + text (Entity,name).
1038        let v1 = IndexSpec {
1039            equality: v0.equality.clone(),
1040            text: vec![
1041                PropIndex {
1042                    label: MEMORY_LABEL.into(),
1043                    prop: MEMORY_CONTENT_PROP.into(),
1044                },
1045                PropIndex {
1046                    label: ENTITY_LABEL.into(),
1047                    prop: ENTITY_NAME_PROP.into(),
1048                },
1049            ],
1050        };
1051        assert_eq!(upgraded_spec(v0), default_spec());
1052        assert_eq!(upgraded_spec(v1), default_spec());
1053        assert_eq!(upgraded_spec(default_spec()), default_spec());
1054    }
1055}