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