1use 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
65pub 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 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
122pub fn to_html(s: &GraphSnapshot) -> Result<String, String> {
126 let json = to_canonical_json(s)?;
127 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 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 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 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 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 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 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 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#[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
292fn 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 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 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 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
338pub 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 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 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 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 let mut nodes: Vec<GraphNode> = subgraph
381 .nodes
382 .iter()
383 .map(|n| graph_node(n, 0)) .collect();
385 nodes.sort_by_key(|a| a.id.clone()); 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 let mut edges_raw = subgraph.edges.clone();
398 edges_raw.sort_by_key(|a| a.id); let mut edges: Vec<GraphEdge> = edges_raw.iter().map(graph_edge).collect();
400 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 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 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
445pub 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 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 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 all_nodes.sort_by_key(|n| std::cmp::Reverse(n.id));
476 let kept = all_nodes.drain(..limit).collect::<Vec<_>>();
478 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 all_nodes.sort_by_key(|n| n.id);
494
495 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 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 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 let mut seen_edges: HashSet<topodb::EdgeId> = HashSet::new();
522 all_edges.retain(|e| seen_edges.insert(e.id));
523
524 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 } else if kept_ids.contains(&e.from) && dropped_ids.contains(&e.to) {
531 edges_dropped += 1; false
533 } else if kept_ids.contains(&e.to) && dropped_ids.contains(&e.from) {
534 edges_dropped += 1; false
536 } else {
537 false }
539 })
540 .collect();
541
542 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 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 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 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 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); 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); 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 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 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 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")); 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")); 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[")); 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[")); assert!(m.contains("n0[\"Memory: ")); let mut snap = tiny_snap(false, false);
995 snap.nodes[0].label = "Memory[bad]\"label".into();
996 let m = to_mermaid(&snap);
997 assert!(
999 m.contains("Memory(bad)#quot;label"),
1000 "sanitized label should appear in output"
1001 );
1002 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 let d = to_dot(&snap);
1020 assert!(
1021 d.contains("he\\\"llo|x"),
1022 "dot should escape quotes in edge types"
1023 );
1024
1025 let m = to_mermaid(&snap);
1027 assert!(
1028 m.contains("-->|hellox|"),
1029 "mermaid should strip pipes and quotes from edge types"
1030 );
1031 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 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 assert!(!html.contains("http://"));
1056 assert!(!html.contains("https://"));
1057 assert!(!html.contains("__SNAPSHOT_JSON__"));
1059 assert!(!html.contains("__PAGE_TITLE__"));
1060 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 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}