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