Skip to main content

topodb_json/
graph.rs

1//! Graph snapshot: the one struct every `topodb graph` output format renders.
2//! Deterministic by construction — no wall-clock fields, sorted collections.
3
4use serde::{Deserialize, Serialize};
5use topodb::{EdgeRecord, NodeRecord, PropValue, SmolStr};
6
7use crate::{
8    scope_label, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP, MEMORY_TOMBSTONE_PROPS,
9};
10
11pub const GRAPH_SNAPSHOT_VERSION: u32 = 1;
12pub const GRAPH_DEFAULT_LIMIT: usize = 500;
13pub const GRAPH_TITLE_MAX_CHARS: usize = 120;
14pub const GRAPH_MERMAID_INLINE_MAX_NODES: usize = 60;
15
16#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
17pub struct GraphSnapshot {
18    pub snapshot_version: u32,
19    pub db_path: Option<String>,
20    pub op_seq: u64,
21    pub scopes: Vec<String>,
22    pub view: GraphView,
23    pub truncated: Option<GraphTruncation>,
24    pub nodes: Vec<GraphNode>,
25    pub edges: Vec<GraphEdge>,
26}
27
28#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
29pub struct GraphView {
30    pub kind: String,
31    pub seeds: Vec<String>,
32    pub query: Option<String>,
33    pub hops: u8,
34    pub as_of: Option<i64>,
35    pub time_axis: String,
36    pub direction: String,
37}
38
39#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
40pub struct GraphTruncation {
41    pub nodes_dropped: usize,
42    pub edges_dropped: usize,
43}
44
45#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
46pub struct GraphNode {
47    pub id: String,
48    pub label: String,
49    pub title: String,
50    pub scope: String,
51    pub superseded: bool,
52    pub hop: u32,
53}
54
55#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
56pub struct GraphEdge {
57    pub from: String,
58    pub to: String,
59    pub ty: String,
60    pub scope: String,
61    pub valid_from: i64,
62    pub valid_to: Option<i64>,
63}
64
65/// Entity name, else `content` preview (≤ GRAPH_TITLE_MAX_CHARS chars,
66/// char-boundary safe, '…' suffix when cut), else the label itself.
67pub fn node_title(n: &NodeRecord) -> String {
68    let titled = if n.label == ENTITY_LABEL {
69        n.props.get(ENTITY_NAME_PROP)
70    } else {
71        n.props.get(MEMORY_CONTENT_PROP)
72    };
73    let s = match titled {
74        Some(PropValue::Str(s)) => s.as_str(),
75        _ => return n.label.to_string(),
76    };
77    // Whitespace-normalize so multi-line content stays a one-line title.
78    let flat: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
79    if flat.chars().count() <= GRAPH_TITLE_MAX_CHARS {
80        flat
81    } else {
82        let mut t: String = flat.chars().take(GRAPH_TITLE_MAX_CHARS).collect();
83        t.push('…');
84        t
85    }
86}
87
88pub fn node_superseded(n: &NodeRecord) -> bool {
89    MEMORY_TOMBSTONE_PROPS
90        .iter()
91        .any(|p| n.props.contains_key(*p))
92}
93
94pub fn graph_node(n: &NodeRecord, hop: u32) -> GraphNode {
95    GraphNode {
96        id: n.id.to_string(),
97        label: n.label.to_string(),
98        title: node_title(n),
99        scope: scope_label(&n.scope),
100        superseded: node_superseded(n),
101        hop,
102    }
103}
104
105pub fn graph_edge(e: &EdgeRecord) -> GraphEdge {
106    GraphEdge {
107        from: e.from.to_string(),
108        to: e.to.to_string(),
109        ty: e.ty.to_string(),
110        scope: scope_label(&e.scope),
111        valid_from: e.valid_from,
112        valid_to: e.valid_to,
113    }
114}
115
116pub fn to_canonical_json(s: &GraphSnapshot) -> Result<String, String> {
117    serde_json::to_string(s).map_err(|e| format!("serializing snapshot: {e}"))
118}
119
120const GRAPH_HTML_TEMPLATE: &str = include_str!("../assets/graph.html");
121
122/// Self-contained interactive HTML: the canonical JSON is embedded verbatim
123/// (with `<` escaped as `\u003c` to make a literal `</script>` breakout
124/// impossible) inside `assets/graph.html`, a vanilla-JS force-layout viewer.
125pub fn to_html(s: &GraphSnapshot) -> Result<String, String> {
126    let json = to_canonical_json(s)?;
127    // `<` only ever appears inside JSON strings, so escaping it is always
128    // legal JSON and guarantees no literal "</script>" can appear in the
129    // embedded payload.
130    let json_escaped = json.replace('<', "\\u003c");
131    let title = format!("topodb graph — {} view", s.view.kind);
132    Ok(GRAPH_HTML_TEMPLATE
133        .replace("__PAGE_TITLE__", &title)
134        .replace("__SNAPSHOT_JSON__", &json_escaped))
135}
136
137pub fn to_dot(s: &GraphSnapshot) -> String {
138    use std::fmt::Write;
139
140    let mut out = String::new();
141    let _ = writeln!(out, "digraph topodb {{");
142    let _ = writeln!(out, "rankdir=LR;");
143    let _ = writeln!(out, "node [shape=box];");
144
145    if let Some(t) = &s.truncated {
146        let _ = writeln!(
147            out,
148            "label=\"truncated: {} nodes, {} edges dropped\"; labelloc=t;",
149            t.nodes_dropped, t.edges_dropped
150        );
151    }
152
153    // Iterate over nodes (already sorted)
154    for node in &s.nodes {
155        let title_escaped = escape_dot_label(&node.title);
156        let label_escaped = escape_dot_label(&node.label);
157        let scope_escaped = escape_dot_label(&node.scope);
158        let label = if s.scopes.len() > 1 {
159            format!("{}\\n{}\\n{}", label_escaped, title_escaped, scope_escaped)
160        } else {
161            format!("{}\\n{}", label_escaped, title_escaped)
162        };
163
164        let style = if node.superseded {
165            ", style=dashed"
166        } else {
167            ""
168        };
169
170        let _ = writeln!(out, "\"{}\" [label=\"{}\"]{}", node.id, label, style);
171    }
172
173    // Iterate over edges (already sorted)
174    for edge in &s.edges {
175        let ty_escaped = escape_dot_label(&edge.ty);
176        let _ = writeln!(
177            out,
178            "\"{}\" -> \"{}\" [label=\"{}\"]",
179            edge.from, edge.to, ty_escaped
180        );
181    }
182
183    let _ = writeln!(out, "}}");
184    out
185}
186
187pub fn to_mermaid(s: &GraphSnapshot) -> String {
188    use std::fmt::Write;
189
190    let mut out = String::new();
191    let _ = writeln!(out, "graph TD");
192
193    // Create a mapping from node id to index
194    let mut id_to_index = std::collections::BTreeMap::new();
195    for (idx, node) in s.nodes.iter().enumerate() {
196        id_to_index.insert(node.id.clone(), idx);
197    }
198
199    if let Some(t) = &s.truncated {
200        let _ = writeln!(
201            out,
202            "  %% truncated: {} nodes, {} edges dropped",
203            t.nodes_dropped, t.edges_dropped
204        );
205    }
206
207    // Iterate over nodes (already sorted)
208    let mut has_superseded = false;
209    for (idx, node) in s.nodes.iter().enumerate() {
210        let label_sanitized = sanitize_mermaid_label(&node.label);
211        let title_sanitized = sanitize_mermaid_label(&node.title);
212        let superseded_class = if node.superseded {
213            has_superseded = true;
214            ":::superseded"
215        } else {
216            ""
217        };
218
219        let _ = writeln!(
220            out,
221            "  n{}[\"{}: {}\"]{}",
222            idx, label_sanitized, title_sanitized, superseded_class
223        );
224    }
225
226    // Emit truncation node if needed
227    if let Some(t) = &s.truncated {
228        let trunc_text = sanitize_mermaid_label(&format!(
229            "⚠ truncated: {} nodes, {} edges dropped",
230            t.nodes_dropped, t.edges_dropped
231        ));
232        let _ = writeln!(out, "  trunc[\"{}\"]", trunc_text);
233    }
234
235    // Iterate over edges (already sorted)
236    for edge in &s.edges {
237        if let (Some(&from_idx), Some(&to_idx)) =
238            (id_to_index.get(&edge.from), id_to_index.get(&edge.to))
239        {
240            let ty = edge.ty.replace(['|', '"'], "");
241            let _ = writeln!(out, "  n{} -->|{}| n{}", from_idx, ty, to_idx);
242        }
243    }
244
245    // Emit classDef superseded only if needed
246    if has_superseded {
247        let _ = writeln!(out, "classDef superseded opacity:0.45;");
248    }
249
250    out
251}
252
253fn escape_dot_label(s: &str) -> String {
254    let mut result = String::new();
255    for c in s.chars() {
256        match c {
257            '\\' => result.push_str("\\\\"),
258            '"' => result.push_str("\\\""),
259            '\n' => result.push_str("\\n"),
260            _ => result.push(c),
261        }
262    }
263    result
264}
265
266fn sanitize_mermaid_label(s: &str) -> String {
267    let mut result = String::new();
268    for c in s.chars() {
269        match c {
270            '"' => result.push_str("#quot;"),
271            '[' => result.push('('),
272            ']' => result.push(')'),
273            _ => result.push(c),
274        }
275    }
276    result
277}
278
279/// Parameters for ego snapshot building.
280#[derive(Clone, Debug)]
281pub struct EgoParams {
282    pub seeds: Vec<topodb::NodeId>,
283    pub query: Option<String>,
284    pub query_k: usize,
285    pub max_hops: u8,
286    pub direction: topodb::Direction,
287    pub edge_types: Option<Vec<SmolStr>>,
288    pub as_of: Option<i64>,
289    pub time_axis: topodb::TimeAxis,
290}
291
292/// Compute hop distances from seeds via undirected BFS over edges.
293/// Returns a map of node id → hop distance.
294fn hops_from(seeds: &[String], edges: &[GraphEdge]) -> std::collections::BTreeMap<String, u32> {
295    use std::collections::{BTreeMap, HashSet, VecDeque};
296
297    let mut hops: BTreeMap<String, u32> = BTreeMap::new();
298    let mut visited: HashSet<String> = HashSet::new();
299    let mut queue: VecDeque<(String, u32)> = VecDeque::new();
300
301    // Initialize seeds with hop 0
302    for seed in seeds {
303        hops.insert(seed.clone(), 0);
304        visited.insert(seed.clone());
305        queue.push_back((seed.clone(), 0));
306    }
307
308    // Build undirected adjacency map
309    let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();
310    for edge in edges {
311        adjacency
312            .entry(edge.from.clone())
313            .or_default()
314            .push(edge.to.clone());
315        adjacency
316            .entry(edge.to.clone())
317            .or_default()
318            .push(edge.from.clone());
319    }
320
321    // BFS
322    while let Some((node_id, hop)) = queue.pop_front() {
323        if let Some(neighbors) = adjacency.get(&node_id) {
324            for neighbor in neighbors {
325                if !visited.contains(neighbor) {
326                    visited.insert(neighbor.clone());
327                    let next_hop = hop + 1;
328                    hops.insert(neighbor.clone(), next_hop);
329                    queue.push_back((neighbor.clone(), next_hop));
330                }
331            }
332        }
333    }
334
335    hops
336}
337
338/// Build an ego-view snapshot from seeds and optional query.
339pub fn build_ego(
340    db: &topodb::Db,
341    scopes: &topodb::ScopeSet,
342    p: &EgoParams,
343) -> Result<GraphSnapshot, String> {
344    use std::collections::BTreeSet;
345
346    // 1. Combine seeds and query results
347    let mut all_seeds: BTreeSet<topodb::NodeId> = p.seeds.iter().cloned().collect();
348
349    if let Some(query) = &p.query {
350        let hits = db
351            .search_text(scopes, query, p.query_k)
352            .map_err(|e| format!("search_text: {e}"))?;
353        for (hit, _score) in hits {
354            all_seeds.insert(hit.id);
355        }
356    }
357
358    if all_seeds.is_empty() {
359        return Err("no seeds: pass --seed or a --query with hits".to_string());
360    }
361
362    // Convert to Vec and sort for determinism (by string representation)
363    let seeds_vec: Vec<topodb::NodeId> = all_seeds.into_iter().collect();
364    let seeds_str_vec: Vec<String> = seeds_vec.iter().map(|s| s.to_string()).collect();
365
366    // 2. Build TraversalQuery and traverse
367    let query = topodb::TraversalQuery {
368        scopes: scopes.clone(),
369        seeds: seeds_vec.clone(),
370        max_hops: p.max_hops,
371        edge_types: p.edge_types.clone(),
372        direction: p.direction,
373        as_of: p.as_of,
374        time_axis: p.time_axis,
375    };
376
377    let subgraph = db.traverse(&query).map_err(|e| format!("traverse: {e}"))?;
378
379    // 3. Convert nodes and compute hops
380    let mut nodes: Vec<GraphNode> = subgraph
381        .nodes
382        .iter()
383        .map(|n| graph_node(n, 0)) // placeholder hop, will update
384        .collect();
385    nodes.sort_by_key(|a| a.id.clone()); // Sort by id
386
387    // Compute hops
388    let edges_for_bfs: Vec<GraphEdge> = subgraph.edges.iter().map(graph_edge).collect();
389
390    let hops_map = hops_from(&seeds_str_vec, &edges_for_bfs);
391
392    for node in &mut nodes {
393        node.hop = *hops_map.get(&node.id).unwrap_or(&(p.max_hops as u32));
394    }
395
396    // 4. Sort edges
397    let mut edges_raw = subgraph.edges.clone();
398    edges_raw.sort_by_key(|a| a.id); // Sort EdgeRecords by id first
399    let mut edges: Vec<GraphEdge> = edges_raw.iter().map(graph_edge).collect();
400    // Stable sort by (from, to, ty)
401    edges.sort_by(|a, b| {
402        a.from
403            .cmp(&b.from)
404            .then_with(|| a.to.cmp(&b.to))
405            .then_with(|| a.ty.cmp(&b.ty))
406    });
407
408    // 5. Get op_seq and scopes
409    let op_seq = db.current_seq().map_err(|e| format!("current_seq: {e}"))?;
410
411    let scope_labels: Vec<String> = scopes.iter_scopes().map(|s| scope_label(&s)).collect();
412
413    // 6. Direction and TimeAxis to lowercase strings
414    let direction_str = match p.direction {
415        topodb::Direction::Out => "out",
416        topodb::Direction::In => "in",
417        topodb::Direction::Both => "both",
418    };
419
420    let time_axis_str = match p.time_axis {
421        topodb::TimeAxis::Valid => "valid",
422        topodb::TimeAxis::Recorded => "recorded",
423    };
424
425    Ok(GraphSnapshot {
426        snapshot_version: GRAPH_SNAPSHOT_VERSION,
427        db_path: None,
428        op_seq,
429        scopes: scope_labels,
430        view: GraphView {
431            kind: "ego".to_string(),
432            seeds: seeds_str_vec,
433            query: p.query.clone(),
434            hops: p.max_hops,
435            as_of: p.as_of,
436            time_axis: time_axis_str.to_string(),
437            direction: direction_str.to_string(),
438        },
439        truncated: None,
440        nodes,
441        edges,
442    })
443}
444
445/// Build a scope-view snapshot: all entities and memories in the scope,
446/// truncated to `limit` (keeping newest-first by ULID, recording honest
447/// dropout counts). All nodes have hop: 0. View is marked "scope" with
448/// empty seeds and query.
449pub fn build_scope(
450    db: &topodb::Db,
451    scopes: &topodb::ScopeSet,
452    limit: usize,
453) -> Result<GraphSnapshot, String> {
454    use std::collections::{BTreeSet, HashSet};
455
456    // 1. Collect nodes from both ENTITY_LABEL and MEMORY_LABEL (unbumped)
457    let mut all_nodes = db
458        .nodes_by_label_unbumped(scopes, crate::ENTITY_LABEL)
459        .into_iter()
460        .chain(db.nodes_by_label_unbumped(scopes, crate::MEMORY_LABEL))
461        .collect::<Vec<_>>();
462
463    // 2. Track which nodes are kept/dropped
464    let nodes_dropped = if all_nodes.len() > limit {
465        all_nodes.len() - limit
466    } else {
467        0
468    };
469
470    let mut kept_ids: BTreeSet<topodb::NodeId> = BTreeSet::new();
471    let mut dropped_ids: HashSet<topodb::NodeId> = HashSet::new();
472
473    if all_nodes.len() > limit {
474        // Sort descending (newest first, ULIDs are time-ordered)
475        all_nodes.sort_by_key(|n| std::cmp::Reverse(n.id));
476        // Keep the limit
477        let kept = all_nodes.drain(..limit).collect::<Vec<_>>();
478        // Remaining are dropped
479        for n in all_nodes.iter() {
480            dropped_ids.insert(n.id);
481        }
482        for n in kept.iter() {
483            kept_ids.insert(n.id);
484        }
485        all_nodes = kept;
486    } else {
487        for n in all_nodes.iter() {
488            kept_ids.insert(n.id);
489        }
490    }
491
492    // Re-sort kept nodes ascending for output (like build_ego does)
493    all_nodes.sort_by_key(|n| n.id);
494
495    // 3. Convert nodes and set hop to 0
496    let mut nodes: Vec<GraphNode> = all_nodes.iter().map(|n| graph_node(n, 0)).collect();
497    nodes.sort_by_key(|a| a.id.clone());
498
499    // 4. Collect edges: outgoing from kept nodes
500    let mut edges_dropped = 0;
501    let mut all_edges: Vec<topodb::EdgeRecord> = Vec::new();
502
503    for node_id in kept_ids.iter() {
504        let edges_out = db
505            .edges_from(scopes, *node_id, None, None, true, topodb::TimeAxis::Valid)
506            .map_err(|e| format!("edges_from: {e}"))?;
507        all_edges.extend(edges_out);
508    }
509
510    // Also check for edges pointing TO kept nodes from dropped nodes
511    for node_id in kept_ids.iter() {
512        let edges_in = db
513            .edges_to(scopes, *node_id, None, None, true, topodb::TimeAxis::Valid)
514            .map_err(|e| format!("edges_to: {e}"))?;
515        for edge in edges_in {
516            all_edges.push(edge);
517        }
518    }
519
520    // Deduplicate edges by id to handle edges that appear in both from and to queries
521    let mut seen_edges: HashSet<topodb::EdgeId> = HashSet::new();
522    all_edges.retain(|e| seen_edges.insert(e.id));
523
524    // 5. Filter edges: keep only those with both endpoints in kept_ids
525    let filtered_edges: Vec<topodb::EdgeRecord> = all_edges
526        .into_iter()
527        .filter(|e| {
528            if kept_ids.contains(&e.from) && kept_ids.contains(&e.to) {
529                true // both endpoints kept: render it
530            } else if kept_ids.contains(&e.from) && dropped_ids.contains(&e.to) {
531                edges_dropped += 1; // kept -> dropped
532                false
533            } else if kept_ids.contains(&e.to) && dropped_ids.contains(&e.from) {
534                edges_dropped += 1; // dropped -> kept
535                false
536            } else {
537                false // dropped <-> dropped: not counted (documented non-exhaustive)
538            }
539        })
540        .collect();
541
542    // 6. Sort edges: by raw edge id first, then stable (from, to, ty)
543    let mut edges_raw = filtered_edges;
544    edges_raw.sort_by_key(|a| a.id);
545    let mut edges: Vec<GraphEdge> = edges_raw.iter().map(graph_edge).collect();
546    edges.sort_by(|a, b| {
547        a.from
548            .cmp(&b.from)
549            .then_with(|| a.to.cmp(&b.to))
550            .then_with(|| a.ty.cmp(&b.ty))
551    });
552
553    // 7. Get op_seq and scopes
554    let op_seq = db.current_seq().map_err(|e| format!("current_seq: {e}"))?;
555    let scope_labels: Vec<String> = scopes.iter_scopes().map(|s| scope_label(&s)).collect();
556
557    // 8. Build truncation info (only if something was dropped)
558    let truncated = if nodes_dropped > 0 || edges_dropped > 0 {
559        Some(GraphTruncation {
560            nodes_dropped,
561            edges_dropped,
562        })
563    } else {
564        None
565    };
566
567    Ok(GraphSnapshot {
568        snapshot_version: GRAPH_SNAPSHOT_VERSION,
569        db_path: None,
570        op_seq,
571        scopes: scope_labels,
572        view: GraphView {
573            kind: "scope".to_string(),
574            seeds: vec![],
575            query: None,
576            hops: 0,
577            as_of: None,
578            time_axis: "valid".to_string(),
579            direction: "out".to_string(),
580        },
581        truncated,
582        nodes,
583        edges,
584    })
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use topodb::{EdgeId, NodeId, Op, PropValue, Scope};
591
592    fn node(label: &str, props: Vec<(&str, PropValue)>) -> topodb::NodeRecord {
593        topodb::NodeRecord {
594            id: NodeId::new(),
595            scope: Scope::Shared,
596            label: label.into(),
597            props: props.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
598            embedding: None,
599        }
600    }
601
602    /// a(Memory) -ABOUT-> b(Entity) -ABOUT-> c(Entity); returns (db, [a,b,c])
603    fn seed_chain(dir: &tempfile::TempDir) -> (topodb::Db, [NodeId; 3]) {
604        let db = topodb::Db::open_with(dir.path().join("t.redb"), crate::default_spec()).unwrap();
605        let (a, b, c) = (NodeId::new(), NodeId::new(), NodeId::new());
606        db.submit(vec![
607            Op::CreateNode {
608                id: a,
609                scope: Scope::Shared,
610                label: "Memory".into(),
611                props: [("content".to_string(), PropValue::Str("alpha fact".into()))]
612                    .into_iter()
613                    .collect(),
614            },
615            Op::CreateNode {
616                id: b,
617                scope: Scope::Shared,
618                label: "Entity".into(),
619                props: [("name".to_string(), PropValue::Str("Beta".into()))]
620                    .into_iter()
621                    .collect(),
622            },
623            Op::CreateNode {
624                id: c,
625                scope: Scope::Shared,
626                label: "Entity".into(),
627                props: [("name".to_string(), PropValue::Str("Gamma".into()))]
628                    .into_iter()
629                    .collect(),
630            },
631            Op::CreateEdge {
632                id: EdgeId::new(),
633                scope: Scope::Shared,
634                ty: "ABOUT".into(),
635                from: a,
636                to: b,
637                props: Default::default(),
638                valid_from: None,
639                recorded_at: None,
640            },
641            Op::CreateEdge {
642                id: EdgeId::new(),
643                scope: Scope::Shared,
644                ty: "ABOUT".into(),
645                from: b,
646                to: c,
647                props: Default::default(),
648                valid_from: None,
649                recorded_at: None,
650            },
651        ])
652        .unwrap();
653        (db, [a, b, c])
654    }
655
656    #[test]
657    fn ego_walks_hops_and_labels_them() {
658        let dir = tempfile::tempdir().unwrap();
659        let (db, [a, _b, c]) = seed_chain(&dir);
660        let scopes = crate::scope_to_scope_set(Scope::Shared);
661        let p = EgoParams {
662            seeds: vec![a],
663            query: None,
664            query_k: 3,
665            max_hops: 2,
666            direction: topodb::Direction::Both,
667            edge_types: None,
668            as_of: None,
669            time_axis: topodb::TimeAxis::Valid,
670        };
671        let snap = build_ego(&db, &scopes, &p).unwrap();
672        assert_eq!(snap.nodes.len(), 3);
673        assert_eq!(snap.edges.len(), 2);
674        assert_eq!(snap.view.kind, "ego");
675        let hop_of = |id: NodeId| {
676            snap.nodes
677                .iter()
678                .find(|n| n.id == id.to_string())
679                .unwrap()
680                .hop
681        };
682        assert_eq!(hop_of(a), 0);
683        assert_eq!(hop_of(c), 2);
684        // determinism: sorted node ids
685        let ids: Vec<_> = snap.nodes.iter().map(|n| n.id.clone()).collect();
686        let mut sorted = ids.clone();
687        sorted.sort();
688        assert_eq!(ids, sorted);
689    }
690
691    #[test]
692    fn ego_query_seeds_from_search_hits() {
693        let dir = tempfile::tempdir().unwrap();
694        let (db, [a, ..]) = seed_chain(&dir);
695        let scopes = crate::scope_to_scope_set(Scope::Shared);
696        let p = EgoParams {
697            seeds: vec![],
698            query: Some("alpha".into()),
699            query_k: 3,
700            max_hops: 1,
701            direction: topodb::Direction::Both,
702            edge_types: None,
703            as_of: None,
704            time_axis: topodb::TimeAxis::Valid,
705        };
706        let snap = build_ego(&db, &scopes, &p).unwrap();
707        assert!(snap.nodes.iter().any(|n| n.id == a.to_string()));
708        assert_eq!(snap.view.query.as_deref(), Some("alpha"));
709    }
710
711    #[test]
712    fn ego_no_seeds_is_an_error() {
713        let dir = tempfile::tempdir().unwrap();
714        let (db, _) = seed_chain(&dir);
715        let scopes = crate::scope_to_scope_set(Scope::Shared);
716        let p = EgoParams {
717            seeds: vec![],
718            query: Some("zzzznohit".into()),
719            query_k: 3,
720            max_hops: 1,
721            direction: topodb::Direction::Both,
722            edge_types: None,
723            as_of: None,
724            time_axis: topodb::TimeAxis::Valid,
725        };
726        assert!(build_ego(&db, &scopes, &p).is_err());
727    }
728
729    #[test]
730    fn title_prefers_name_for_entities_and_previews_memory_content() {
731        let e = node("Entity", vec![("name", PropValue::Str("Alice".into()))]);
732        assert_eq!(node_title(&e), "Alice");
733        let long = "x".repeat(300);
734        let m = node("Memory", vec![("content", PropValue::Str(long))]);
735        let t = node_title(&m);
736        assert!(t.chars().count() <= GRAPH_TITLE_MAX_CHARS + 1); // +1 for the ellipsis
737        assert!(t.ends_with('…'));
738    }
739
740    #[test]
741    fn title_truncates_on_char_boundary_not_bytes() {
742        let m = node("Memory", vec![("content", PropValue::Str("é".repeat(200)))]);
743        let t = node_title(&m); // must not panic on a multi-byte boundary
744        assert!(t.ends_with('…'));
745    }
746
747    #[test]
748    fn title_falls_back_to_label_when_no_titled_prop() {
749        let n = node("Widget", vec![("count", PropValue::Int(3))]);
750        assert_eq!(node_title(&n), "Widget");
751    }
752
753    #[test]
754    fn superseded_detects_tombstone_props() {
755        let live = node("Memory", vec![("content", PropValue::Str("a".into()))]);
756        assert!(!node_superseded(&live));
757        let dead = node(
758            "Memory",
759            vec![
760                ("content", PropValue::Str("a".into())),
761                ("superseded_at", PropValue::DateTime(42)),
762            ],
763        );
764        assert!(node_superseded(&dead));
765        let forgotten = node(
766            "Memory",
767            vec![
768                ("content", PropValue::Str("a".into())),
769                ("forgotten_at", PropValue::DateTime(42)),
770            ],
771        );
772        assert!(node_superseded(&forgotten));
773    }
774
775    #[test]
776    fn canonical_json_is_stable_and_round_trips() {
777        let snap = GraphSnapshot {
778            snapshot_version: GRAPH_SNAPSHOT_VERSION,
779            db_path: None,
780            op_seq: 7,
781            scopes: vec!["shared".into()],
782            view: GraphView {
783                kind: "ego".into(),
784                seeds: vec!["01X".into()],
785                query: None,
786                hops: 2,
787                as_of: None,
788                time_axis: "valid".into(),
789                direction: "both".into(),
790            },
791            truncated: None,
792            nodes: vec![],
793            edges: vec![],
794        };
795        let a = to_canonical_json(&snap).unwrap();
796        let b = to_canonical_json(&snap).unwrap();
797        assert_eq!(a, b);
798        let back: GraphSnapshot = serde_json::from_str(&a).unwrap();
799        assert_eq!(back, snap);
800    }
801
802    #[test]
803    fn scope_view_includes_all_nodes_and_internal_edges() {
804        let dir = tempfile::tempdir().unwrap();
805        let (db, _) = seed_chain(&dir);
806        let scopes = crate::scope_to_scope_set(Scope::Shared);
807        let snap = build_scope(&db, &scopes, GRAPH_DEFAULT_LIMIT).unwrap();
808        assert_eq!(snap.nodes.len(), 3);
809        assert_eq!(snap.edges.len(), 2);
810        assert_eq!(snap.view.kind, "scope");
811        assert!(snap.truncated.is_none());
812    }
813
814    #[test]
815    fn scope_view_truncates_honestly() {
816        let dir = tempfile::tempdir().unwrap();
817        let (db, [a, b, c]) = seed_chain(&dir);
818        let scopes = crate::scope_to_scope_set(Scope::Shared);
819        let snap = build_scope(&db, &scopes, 2).unwrap();
820        assert_eq!(snap.nodes.len(), 2);
821        let t = snap.truncated.expect("truncation must be recorded");
822        assert_eq!(t.nodes_dropped, 1);
823        let kept: std::collections::BTreeSet<String> =
824            snap.nodes.iter().map(|n| n.id.clone()).collect();
825        let dropped = [a, b, c]
826            .iter()
827            .find(|id| !kept.contains(&id.to_string()))
828            .unwrap()
829            .to_string();
830        // chain edges: a->b, b->c. Count edges adjacent to the dropped node.
831        let expected = [
832            (a.to_string(), b.to_string()),
833            (b.to_string(), c.to_string()),
834        ]
835        .iter()
836        .filter(|(f, t2)| *f == dropped || *t2 == dropped)
837        .count();
838        assert_eq!(
839            t.edges_dropped, expected,
840            "each dropped-adjacent edge counted exactly once"
841        );
842    }
843
844    #[test]
845    fn exports_are_byte_identical_across_calls() {
846        let dir = tempfile::tempdir().unwrap();
847        let (db, [a, ..]) = seed_chain(&dir);
848        let scopes = crate::scope_to_scope_set(Scope::Shared);
849        let s1 = to_canonical_json(&build_scope(&db, &scopes, 500).unwrap()).unwrap();
850        let s2 = to_canonical_json(&build_scope(&db, &scopes, 500).unwrap()).unwrap();
851        assert_eq!(s1, s2);
852        let p = EgoParams {
853            seeds: vec![a],
854            query: None,
855            query_k: 3,
856            max_hops: 2,
857            direction: topodb::Direction::Both,
858            edge_types: None,
859            as_of: None,
860            time_axis: topodb::TimeAxis::Valid,
861        };
862        let e1 = to_canonical_json(&build_ego(&db, &scopes, &p).unwrap()).unwrap();
863        let e2 = to_canonical_json(&build_ego(&db, &scopes, &p).unwrap()).unwrap();
864        assert_eq!(e1, e2);
865    }
866
867    #[test]
868    fn scope_view_edges_are_closed_over_rendered_nodes() {
869        let dir = tempfile::tempdir().unwrap();
870        let (db, _) = seed_chain(&dir);
871        let scopes = crate::scope_to_scope_set(Scope::Shared);
872
873        // Test with limit 2 (truncated)
874        let snap = build_scope(&db, &scopes, 2).unwrap();
875        let node_ids: std::collections::HashSet<_> =
876            snap.nodes.iter().map(|n| n.id.clone()).collect();
877        for edge in &snap.edges {
878            assert!(
879                node_ids.contains(&edge.from),
880                "edge from {} not in rendered nodes",
881                edge.from
882            );
883            assert!(
884                node_ids.contains(&edge.to),
885                "edge to {} not in rendered nodes",
886                edge.to
887            );
888        }
889
890        // Test with limit 500 (all nodes)
891        let snap = build_scope(&db, &scopes, 500).unwrap();
892        let node_ids: std::collections::HashSet<_> =
893            snap.nodes.iter().map(|n| n.id.clone()).collect();
894        for edge in &snap.edges {
895            assert!(
896                node_ids.contains(&edge.from),
897                "edge from {} not in rendered nodes",
898                edge.from
899            );
900            assert!(
901                node_ids.contains(&edge.to),
902                "edge to {} not in rendered nodes",
903                edge.to
904            );
905        }
906    }
907
908    fn tiny_snap(superseded: bool, truncated: bool) -> GraphSnapshot {
909        GraphSnapshot {
910            snapshot_version: GRAPH_SNAPSHOT_VERSION,
911            db_path: None,
912            op_seq: 1,
913            scopes: vec!["shared".into()],
914            view: GraphView {
915                kind: "scope".into(),
916                seeds: vec![],
917                query: None,
918                hops: 0,
919                as_of: None,
920                time_axis: "valid".into(),
921                direction: "out".into(),
922            },
923            truncated: truncated.then_some(GraphTruncation {
924                nodes_dropped: 2,
925                edges_dropped: 3,
926            }),
927            nodes: vec![
928                GraphNode {
929                    id: "01A".into(),
930                    label: "Memory".into(),
931                    title: "say \"hi\"".into(),
932                    scope: "shared".into(),
933                    superseded,
934                    hop: 0,
935                },
936                GraphNode {
937                    id: "01B".into(),
938                    label: "Entity".into(),
939                    title: "Bob".into(),
940                    scope: "shared".into(),
941                    superseded: false,
942                    hop: 0,
943                },
944            ],
945            edges: vec![GraphEdge {
946                from: "01A".into(),
947                to: "01B".into(),
948                ty: "ABOUT".into(),
949                scope: "shared".into(),
950                valid_from: 1,
951                valid_to: None,
952            }],
953        }
954    }
955
956    #[test]
957    fn dot_escapes_and_marks_superseded_and_truncation() {
958        let d = to_dot(&tiny_snap(true, true));
959        assert!(d.starts_with("digraph topodb {"));
960        assert!(d.contains("say \\\"hi\\\""));
961        assert!(d.contains("style=dashed"));
962        assert!(d.contains("truncated: 2 nodes, 3 edges dropped"));
963        assert!(d.contains("\"01A\" -> \"01B\""));
964        assert!(!d.contains("\\nshared")); // single-scope snapshot elides scope lines (DOT uses the two-char \n escape)
965
966        // Multi-scope test: verify two-char escape sequence and single physical line
967        let mut snap = tiny_snap(false, false);
968        snap.scopes = vec!["shared".into(), "other".into()];
969        let d = to_dot(&snap);
970        assert!(d.contains("\\nshared")); // two-char escape in Rust source becomes \n in output
971                                          // Verify it stays on one line (no raw newline inside label quotes)
972        for line in d.lines() {
973            if line.contains("01A") && line.contains("[label=") {
974                assert!(
975                    !line.contains("\n"),
976                    "node label must stay on one physical line"
977                );
978            }
979        }
980    }
981
982    #[test]
983    fn mermaid_sanitizes_ids_and_surfaces_truncation() {
984        let m = to_mermaid(&tiny_snap(false, true));
985        assert!(m.starts_with("graph TD"));
986        assert!(m.contains("n0[")); // sorted: 01A first
987        assert!(m.contains("n0 -->|ABOUT| n1"));
988        assert!(m.contains("#quot;"));
989        assert!(m.contains("truncated: 2 nodes, 3 edges dropped"));
990        assert!(!m.contains("01A[")); // raw ULIDs never used as mermaid ids
991        assert!(m.contains("n0[\"Memory: ")); // Label: title separator
992
993        // Test node label sanitization: label with `"` and `[` must be sanitized
994        let mut snap = tiny_snap(false, false);
995        snap.nodes[0].label = "Memory[bad]\"label".into();
996        let m = to_mermaid(&snap);
997        // Sanitized form should appear: [ → (, ] → ), " → #quot;
998        assert!(
999            m.contains("Memory(bad)#quot;label"),
1000            "sanitized label should appear in output"
1001        );
1002        // Raw form should NOT appear
1003        assert!(
1004            !m.contains("[\"bad\"label"),
1005            "raw label with quotes and brackets should not appear"
1006        );
1007        assert!(
1008            !m.contains("Memory[bad]\"label"),
1009            "unsanitized label should not appear"
1010        );
1011    }
1012
1013    #[test]
1014    fn dot_and_mermaid_escape_edge_types() {
1015        let mut snap = tiny_snap(false, false);
1016        snap.edges[0].ty = "he\"llo|x".into();
1017
1018        // Test dot escaping: `"` should be escaped as `\"`
1019        let d = to_dot(&snap);
1020        assert!(
1021            d.contains("he\\\"llo|x"),
1022            "dot should escape quotes in edge types"
1023        );
1024
1025        // Test mermaid stripping: both `"` and `|` should be removed
1026        let m = to_mermaid(&snap);
1027        assert!(
1028            m.contains("-->|hellox|"),
1029            "mermaid should strip pipes and quotes from edge types"
1030        );
1031        // Verify raw form is not present
1032        assert!(
1033            !m.contains("-->|he\"llo|x|"),
1034            "mermaid should not contain raw quotes or pipes in edge label"
1035        );
1036    }
1037
1038    #[test]
1039    fn mermaid_superseded_class_only_when_needed() {
1040        assert!(to_mermaid(&tiny_snap(true, false)).contains("classDef superseded"));
1041        assert!(!to_mermaid(&tiny_snap(false, false)).contains("classDef"));
1042    }
1043
1044    #[test]
1045    fn html_round_trips_the_snapshot_and_is_self_contained() {
1046        let snap = tiny_snap(false, true);
1047        let html = to_html(&snap).unwrap();
1048        // extract the embedded JSON
1049        let start = html.find("<script id=\"snapshot\"").unwrap();
1050        let json_start = html[start..].find('>').unwrap() + start + 1;
1051        let json_end = html[json_start..].find("</script>").unwrap() + json_start;
1052        let back: GraphSnapshot = serde_json::from_str(&html[json_start..json_end]).unwrap();
1053        assert_eq!(back, snap);
1054        // zero network requests
1055        assert!(!html.contains("http://"));
1056        assert!(!html.contains("https://"));
1057        // markers fully substituted
1058        assert!(!html.contains("__SNAPSHOT_JSON__"));
1059        assert!(!html.contains("__PAGE_TITLE__"));
1060        // truncation banner text present
1061        assert!(html.contains("truncated"));
1062    }
1063
1064    #[test]
1065    fn html_escapes_script_breakout() {
1066        let mut snap = tiny_snap(false, false);
1067        snap.nodes[0].title = "</script><script>alert(1)".into();
1068        let html = to_html(&snap).unwrap();
1069        let body_after_snapshot = &html[html.find("id=\"snapshot\"").unwrap()..];
1070        // the payload's literal </script> must not appear un-escaped
1071        assert!(!body_after_snapshot.contains("</script><script>alert"));
1072    }
1073
1074    #[test]
1075    #[ignore]
1076    fn html_smoke_writes_to_target_for_eyeballing() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let (db, [a, ..]) = seed_chain(&dir);
1079        let scopes = crate::scope_to_scope_set(Scope::Shared);
1080        let p = EgoParams {
1081            seeds: vec![a],
1082            query: None,
1083            query_k: 3,
1084            max_hops: 2,
1085            direction: topodb::Direction::Both,
1086            edge_types: None,
1087            as_of: None,
1088            time_axis: topodb::TimeAxis::Valid,
1089        };
1090        let snap = build_ego(&db, &scopes, &p).unwrap();
1091        let html = to_html(&snap).unwrap();
1092        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1093        let target = manifest_dir
1094            .parent()
1095            .unwrap()
1096            .parent()
1097            .unwrap()
1098            .join("target");
1099        let _ = std::fs::create_dir_all(&target);
1100        std::fs::write(target.join("graph-smoke.html"), html).unwrap();
1101    }
1102}