use serde::{Deserialize, Serialize};
use topodb::{EdgeRecord, NodeRecord, PropValue, SmolStr};
use crate::{
scope_label, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP, MEMORY_TOMBSTONE_PROPS,
};
pub const GRAPH_SNAPSHOT_VERSION: u32 = 1;
pub const GRAPH_DEFAULT_LIMIT: usize = 500;
pub const GRAPH_TITLE_MAX_CHARS: usize = 120;
pub const GRAPH_MERMAID_INLINE_MAX_NODES: usize = 60;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct GraphSnapshot {
pub snapshot_version: u32,
pub db_path: Option<String>,
pub op_seq: u64,
pub scopes: Vec<String>,
pub view: GraphView,
pub truncated: Option<GraphTruncation>,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct GraphView {
pub kind: String,
pub seeds: Vec<String>,
pub query: Option<String>,
pub hops: u8,
pub as_of: Option<i64>,
pub time_axis: String,
pub direction: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct GraphTruncation {
pub nodes_dropped: usize,
pub edges_dropped: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct GraphNode {
pub id: String,
pub label: String,
pub title: String,
pub scope: String,
pub superseded: bool,
pub hop: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct GraphEdge {
pub from: String,
pub to: String,
pub ty: String,
pub scope: String,
pub valid_from: i64,
pub valid_to: Option<i64>,
}
pub fn node_title(n: &NodeRecord) -> String {
let titled = if n.label == ENTITY_LABEL {
n.props.get(ENTITY_NAME_PROP)
} else {
n.props.get(MEMORY_CONTENT_PROP)
};
let s = match titled {
Some(PropValue::Str(s)) => s.as_str(),
_ => return n.label.to_string(),
};
let flat: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= GRAPH_TITLE_MAX_CHARS {
flat
} else {
let mut t: String = flat.chars().take(GRAPH_TITLE_MAX_CHARS).collect();
t.push('…');
t
}
}
pub fn node_superseded(n: &NodeRecord) -> bool {
MEMORY_TOMBSTONE_PROPS
.iter()
.any(|p| n.props.contains_key(*p))
}
pub fn graph_node(n: &NodeRecord, hop: u32) -> GraphNode {
GraphNode {
id: n.id.to_string(),
label: n.label.to_string(),
title: node_title(n),
scope: scope_label(&n.scope),
superseded: node_superseded(n),
hop,
}
}
pub fn graph_edge(e: &EdgeRecord) -> GraphEdge {
GraphEdge {
from: e.from.to_string(),
to: e.to.to_string(),
ty: e.ty.to_string(),
scope: scope_label(&e.scope),
valid_from: e.valid_from,
valid_to: e.valid_to,
}
}
pub fn to_canonical_json(s: &GraphSnapshot) -> Result<String, String> {
serde_json::to_string(s).map_err(|e| format!("serializing snapshot: {e}"))
}
const GRAPH_HTML_TEMPLATE: &str = include_str!("../assets/graph.html");
pub fn to_html(s: &GraphSnapshot) -> Result<String, String> {
let json = to_canonical_json(s)?;
let json_escaped = json.replace('<', "\\u003c");
let title = format!("topodb graph — {} view", s.view.kind);
Ok(GRAPH_HTML_TEMPLATE
.replace("__PAGE_TITLE__", &title)
.replace("__SNAPSHOT_JSON__", &json_escaped))
}
pub fn to_dot(s: &GraphSnapshot) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "digraph topodb {{");
let _ = writeln!(out, "rankdir=LR;");
let _ = writeln!(out, "node [shape=box];");
if let Some(t) = &s.truncated {
let _ = writeln!(
out,
"label=\"truncated: {} nodes, {} edges dropped\"; labelloc=t;",
t.nodes_dropped, t.edges_dropped
);
}
for node in &s.nodes {
let title_escaped = escape_dot_label(&node.title);
let label_escaped = escape_dot_label(&node.label);
let scope_escaped = escape_dot_label(&node.scope);
let label = if s.scopes.len() > 1 {
format!("{}\\n{}\\n{}", label_escaped, title_escaped, scope_escaped)
} else {
format!("{}\\n{}", label_escaped, title_escaped)
};
let style = if node.superseded {
", style=dashed"
} else {
""
};
let _ = writeln!(out, "\"{}\" [label=\"{}\"]{}", node.id, label, style);
}
for edge in &s.edges {
let ty_escaped = escape_dot_label(&edge.ty);
let _ = writeln!(
out,
"\"{}\" -> \"{}\" [label=\"{}\"]",
edge.from, edge.to, ty_escaped
);
}
let _ = writeln!(out, "}}");
out
}
pub fn to_mermaid(s: &GraphSnapshot) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "graph TD");
let mut id_to_index = std::collections::BTreeMap::new();
for (idx, node) in s.nodes.iter().enumerate() {
id_to_index.insert(node.id.clone(), idx);
}
if let Some(t) = &s.truncated {
let _ = writeln!(
out,
" %% truncated: {} nodes, {} edges dropped",
t.nodes_dropped, t.edges_dropped
);
}
let mut has_superseded = false;
for (idx, node) in s.nodes.iter().enumerate() {
let label_sanitized = sanitize_mermaid_label(&node.label);
let title_sanitized = sanitize_mermaid_label(&node.title);
let superseded_class = if node.superseded {
has_superseded = true;
":::superseded"
} else {
""
};
let _ = writeln!(
out,
" n{}[\"{}: {}\"]{}",
idx, label_sanitized, title_sanitized, superseded_class
);
}
if let Some(t) = &s.truncated {
let trunc_text = sanitize_mermaid_label(&format!(
"⚠ truncated: {} nodes, {} edges dropped",
t.nodes_dropped, t.edges_dropped
));
let _ = writeln!(out, " trunc[\"{}\"]", trunc_text);
}
for edge in &s.edges {
if let (Some(&from_idx), Some(&to_idx)) =
(id_to_index.get(&edge.from), id_to_index.get(&edge.to))
{
let ty = edge.ty.replace(['|', '"'], "");
let _ = writeln!(out, " n{} -->|{}| n{}", from_idx, ty, to_idx);
}
}
if has_superseded {
let _ = writeln!(out, "classDef superseded opacity:0.45;");
}
out
}
fn escape_dot_label(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
match c {
'\\' => result.push_str("\\\\"),
'"' => result.push_str("\\\""),
'\n' => result.push_str("\\n"),
_ => result.push(c),
}
}
result
}
fn sanitize_mermaid_label(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
match c {
'"' => result.push_str("#quot;"),
'[' => result.push('('),
']' => result.push(')'),
_ => result.push(c),
}
}
result
}
#[derive(Clone, Debug)]
pub struct EgoParams {
pub seeds: Vec<topodb::NodeId>,
pub query: Option<String>,
pub query_k: usize,
pub max_hops: u8,
pub direction: topodb::Direction,
pub edge_types: Option<Vec<SmolStr>>,
pub as_of: Option<i64>,
pub time_axis: topodb::TimeAxis,
}
fn hops_from(seeds: &[String], edges: &[GraphEdge]) -> std::collections::BTreeMap<String, u32> {
use std::collections::{BTreeMap, HashSet, VecDeque};
let mut hops: BTreeMap<String, u32> = BTreeMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, u32)> = VecDeque::new();
for seed in seeds {
hops.insert(seed.clone(), 0);
visited.insert(seed.clone());
queue.push_back((seed.clone(), 0));
}
let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();
for edge in edges {
adjacency
.entry(edge.from.clone())
.or_default()
.push(edge.to.clone());
adjacency
.entry(edge.to.clone())
.or_default()
.push(edge.from.clone());
}
while let Some((node_id, hop)) = queue.pop_front() {
if let Some(neighbors) = adjacency.get(&node_id) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
visited.insert(neighbor.clone());
let next_hop = hop + 1;
hops.insert(neighbor.clone(), next_hop);
queue.push_back((neighbor.clone(), next_hop));
}
}
}
}
hops
}
pub fn build_ego(
db: &topodb::Db,
scopes: &topodb::ScopeSet,
p: &EgoParams,
) -> Result<GraphSnapshot, String> {
use std::collections::BTreeSet;
let mut all_seeds: BTreeSet<topodb::NodeId> = p.seeds.iter().cloned().collect();
if let Some(query) = &p.query {
let hits = db
.search_text(scopes, query, p.query_k)
.map_err(|e| format!("search_text: {e}"))?;
for (hit, _score) in hits {
all_seeds.insert(hit.id);
}
}
if all_seeds.is_empty() {
return Err("no seeds: pass --seed or a --query with hits".to_string());
}
let seeds_vec: Vec<topodb::NodeId> = all_seeds.into_iter().collect();
let seeds_str_vec: Vec<String> = seeds_vec.iter().map(|s| s.to_string()).collect();
let query = topodb::TraversalQuery {
scopes: scopes.clone(),
seeds: seeds_vec.clone(),
max_hops: p.max_hops,
edge_types: p.edge_types.clone(),
direction: p.direction,
as_of: p.as_of,
time_axis: p.time_axis,
};
let subgraph = db.traverse(&query).map_err(|e| format!("traverse: {e}"))?;
let mut nodes: Vec<GraphNode> = subgraph
.nodes
.iter()
.map(|n| graph_node(n, 0)) .collect();
nodes.sort_by_key(|a| a.id.clone());
let edges_for_bfs: Vec<GraphEdge> = subgraph.edges.iter().map(graph_edge).collect();
let hops_map = hops_from(&seeds_str_vec, &edges_for_bfs);
for node in &mut nodes {
node.hop = *hops_map.get(&node.id).unwrap_or(&(p.max_hops as u32));
}
let mut edges_raw = subgraph.edges.clone();
edges_raw.sort_by_key(|a| a.id); let mut edges: Vec<GraphEdge> = edges_raw.iter().map(graph_edge).collect();
edges.sort_by(|a, b| {
a.from
.cmp(&b.from)
.then_with(|| a.to.cmp(&b.to))
.then_with(|| a.ty.cmp(&b.ty))
});
let op_seq = db.current_seq().map_err(|e| format!("current_seq: {e}"))?;
let scope_labels: Vec<String> = scopes.iter_scopes().map(|s| scope_label(&s)).collect();
let direction_str = match p.direction {
topodb::Direction::Out => "out",
topodb::Direction::In => "in",
topodb::Direction::Both => "both",
};
let time_axis_str = match p.time_axis {
topodb::TimeAxis::Valid => "valid",
topodb::TimeAxis::Recorded => "recorded",
};
Ok(GraphSnapshot {
snapshot_version: GRAPH_SNAPSHOT_VERSION,
db_path: None,
op_seq,
scopes: scope_labels,
view: GraphView {
kind: "ego".to_string(),
seeds: seeds_str_vec,
query: p.query.clone(),
hops: p.max_hops,
as_of: p.as_of,
time_axis: time_axis_str.to_string(),
direction: direction_str.to_string(),
},
truncated: None,
nodes,
edges,
})
}
pub fn build_scope(
db: &topodb::Db,
scopes: &topodb::ScopeSet,
limit: usize,
) -> Result<GraphSnapshot, String> {
use std::collections::{BTreeSet, HashSet};
let mut all_nodes = db
.nodes_by_label_unbumped(scopes, crate::ENTITY_LABEL)
.into_iter()
.chain(db.nodes_by_label_unbumped(scopes, crate::MEMORY_LABEL))
.collect::<Vec<_>>();
let nodes_dropped = if all_nodes.len() > limit {
all_nodes.len() - limit
} else {
0
};
let mut kept_ids: BTreeSet<topodb::NodeId> = BTreeSet::new();
let mut dropped_ids: HashSet<topodb::NodeId> = HashSet::new();
if all_nodes.len() > limit {
all_nodes.sort_by_key(|n| std::cmp::Reverse(n.id));
let kept = all_nodes.drain(..limit).collect::<Vec<_>>();
for n in all_nodes.iter() {
dropped_ids.insert(n.id);
}
for n in kept.iter() {
kept_ids.insert(n.id);
}
all_nodes = kept;
} else {
for n in all_nodes.iter() {
kept_ids.insert(n.id);
}
}
all_nodes.sort_by_key(|n| n.id);
let mut nodes: Vec<GraphNode> = all_nodes.iter().map(|n| graph_node(n, 0)).collect();
nodes.sort_by_key(|a| a.id.clone());
let mut edges_dropped = 0;
let mut all_edges: Vec<topodb::EdgeRecord> = Vec::new();
for node_id in kept_ids.iter() {
let edges_out = db
.edges_from(scopes, *node_id, None, None, true, topodb::TimeAxis::Valid)
.map_err(|e| format!("edges_from: {e}"))?;
all_edges.extend(edges_out);
}
for node_id in kept_ids.iter() {
let edges_in = db
.edges_to(scopes, *node_id, None, None, true, topodb::TimeAxis::Valid)
.map_err(|e| format!("edges_to: {e}"))?;
for edge in edges_in {
all_edges.push(edge);
}
}
let mut seen_edges: HashSet<topodb::EdgeId> = HashSet::new();
all_edges.retain(|e| seen_edges.insert(e.id));
let filtered_edges: Vec<topodb::EdgeRecord> = all_edges
.into_iter()
.filter(|e| {
if kept_ids.contains(&e.from) && kept_ids.contains(&e.to) {
true } else if kept_ids.contains(&e.from) && dropped_ids.contains(&e.to) {
edges_dropped += 1; false
} else if kept_ids.contains(&e.to) && dropped_ids.contains(&e.from) {
edges_dropped += 1; false
} else {
false }
})
.collect();
let mut edges_raw = filtered_edges;
edges_raw.sort_by_key(|a| a.id);
let mut edges: Vec<GraphEdge> = edges_raw.iter().map(graph_edge).collect();
edges.sort_by(|a, b| {
a.from
.cmp(&b.from)
.then_with(|| a.to.cmp(&b.to))
.then_with(|| a.ty.cmp(&b.ty))
});
let op_seq = db.current_seq().map_err(|e| format!("current_seq: {e}"))?;
let scope_labels: Vec<String> = scopes.iter_scopes().map(|s| scope_label(&s)).collect();
let truncated = if nodes_dropped > 0 || edges_dropped > 0 {
Some(GraphTruncation {
nodes_dropped,
edges_dropped,
})
} else {
None
};
Ok(GraphSnapshot {
snapshot_version: GRAPH_SNAPSHOT_VERSION,
db_path: None,
op_seq,
scopes: scope_labels,
view: GraphView {
kind: "scope".to_string(),
seeds: vec![],
query: None,
hops: 0,
as_of: None,
time_axis: "valid".to_string(),
direction: "out".to_string(),
},
truncated,
nodes,
edges,
})
}
#[cfg(test)]
mod tests {
use super::*;
use topodb::{EdgeId, NodeId, Op, PropValue, Scope};
fn node(label: &str, props: Vec<(&str, PropValue)>) -> topodb::NodeRecord {
topodb::NodeRecord {
id: NodeId::new(),
scope: Scope::Shared,
label: label.into(),
props: props.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
embedding: None,
}
}
fn seed_chain(dir: &tempfile::TempDir) -> (topodb::Db, [NodeId; 3]) {
let db = topodb::Db::open_with(dir.path().join("t.redb"), crate::default_spec()).unwrap();
let (a, b, c) = (NodeId::new(), NodeId::new(), NodeId::new());
db.submit(vec![
Op::CreateNode {
id: a,
scope: Scope::Shared,
label: "Memory".into(),
props: [("content".to_string(), PropValue::Str("alpha fact".into()))]
.into_iter()
.collect(),
},
Op::CreateNode {
id: b,
scope: Scope::Shared,
label: "Entity".into(),
props: [("name".to_string(), PropValue::Str("Beta".into()))]
.into_iter()
.collect(),
},
Op::CreateNode {
id: c,
scope: Scope::Shared,
label: "Entity".into(),
props: [("name".to_string(), PropValue::Str("Gamma".into()))]
.into_iter()
.collect(),
},
Op::CreateEdge {
id: EdgeId::new(),
scope: Scope::Shared,
ty: "ABOUT".into(),
from: a,
to: b,
props: Default::default(),
valid_from: None,
recorded_at: None,
},
Op::CreateEdge {
id: EdgeId::new(),
scope: Scope::Shared,
ty: "ABOUT".into(),
from: b,
to: c,
props: Default::default(),
valid_from: None,
recorded_at: None,
},
])
.unwrap();
(db, [a, b, c])
}
#[test]
fn ego_walks_hops_and_labels_them() {
let dir = tempfile::tempdir().unwrap();
let (db, [a, _b, c]) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let p = EgoParams {
seeds: vec![a],
query: None,
query_k: 3,
max_hops: 2,
direction: topodb::Direction::Both,
edge_types: None,
as_of: None,
time_axis: topodb::TimeAxis::Valid,
};
let snap = build_ego(&db, &scopes, &p).unwrap();
assert_eq!(snap.nodes.len(), 3);
assert_eq!(snap.edges.len(), 2);
assert_eq!(snap.view.kind, "ego");
let hop_of = |id: NodeId| {
snap.nodes
.iter()
.find(|n| n.id == id.to_string())
.unwrap()
.hop
};
assert_eq!(hop_of(a), 0);
assert_eq!(hop_of(c), 2);
let ids: Vec<_> = snap.nodes.iter().map(|n| n.id.clone()).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted);
}
#[test]
fn ego_query_seeds_from_search_hits() {
let dir = tempfile::tempdir().unwrap();
let (db, [a, ..]) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let p = EgoParams {
seeds: vec![],
query: Some("alpha".into()),
query_k: 3,
max_hops: 1,
direction: topodb::Direction::Both,
edge_types: None,
as_of: None,
time_axis: topodb::TimeAxis::Valid,
};
let snap = build_ego(&db, &scopes, &p).unwrap();
assert!(snap.nodes.iter().any(|n| n.id == a.to_string()));
assert_eq!(snap.view.query.as_deref(), Some("alpha"));
}
#[test]
fn ego_no_seeds_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let (db, _) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let p = EgoParams {
seeds: vec![],
query: Some("zzzznohit".into()),
query_k: 3,
max_hops: 1,
direction: topodb::Direction::Both,
edge_types: None,
as_of: None,
time_axis: topodb::TimeAxis::Valid,
};
assert!(build_ego(&db, &scopes, &p).is_err());
}
#[test]
fn title_prefers_name_for_entities_and_previews_memory_content() {
let e = node("Entity", vec![("name", PropValue::Str("Alice".into()))]);
assert_eq!(node_title(&e), "Alice");
let long = "x".repeat(300);
let m = node("Memory", vec![("content", PropValue::Str(long))]);
let t = node_title(&m);
assert!(t.chars().count() <= GRAPH_TITLE_MAX_CHARS + 1); assert!(t.ends_with('…'));
}
#[test]
fn title_truncates_on_char_boundary_not_bytes() {
let m = node("Memory", vec![("content", PropValue::Str("é".repeat(200)))]);
let t = node_title(&m); assert!(t.ends_with('…'));
}
#[test]
fn title_falls_back_to_label_when_no_titled_prop() {
let n = node("Widget", vec![("count", PropValue::Int(3))]);
assert_eq!(node_title(&n), "Widget");
}
#[test]
fn superseded_detects_tombstone_props() {
let live = node("Memory", vec![("content", PropValue::Str("a".into()))]);
assert!(!node_superseded(&live));
let dead = node(
"Memory",
vec![
("content", PropValue::Str("a".into())),
("superseded_at", PropValue::DateTime(42)),
],
);
assert!(node_superseded(&dead));
let forgotten = node(
"Memory",
vec![
("content", PropValue::Str("a".into())),
("forgotten_at", PropValue::DateTime(42)),
],
);
assert!(node_superseded(&forgotten));
}
#[test]
fn canonical_json_is_stable_and_round_trips() {
let snap = GraphSnapshot {
snapshot_version: GRAPH_SNAPSHOT_VERSION,
db_path: None,
op_seq: 7,
scopes: vec!["shared".into()],
view: GraphView {
kind: "ego".into(),
seeds: vec!["01X".into()],
query: None,
hops: 2,
as_of: None,
time_axis: "valid".into(),
direction: "both".into(),
},
truncated: None,
nodes: vec![],
edges: vec![],
};
let a = to_canonical_json(&snap).unwrap();
let b = to_canonical_json(&snap).unwrap();
assert_eq!(a, b);
let back: GraphSnapshot = serde_json::from_str(&a).unwrap();
assert_eq!(back, snap);
}
#[test]
fn scope_view_includes_all_nodes_and_internal_edges() {
let dir = tempfile::tempdir().unwrap();
let (db, _) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let snap = build_scope(&db, &scopes, GRAPH_DEFAULT_LIMIT).unwrap();
assert_eq!(snap.nodes.len(), 3);
assert_eq!(snap.edges.len(), 2);
assert_eq!(snap.view.kind, "scope");
assert!(snap.truncated.is_none());
}
#[test]
fn scope_view_truncates_honestly() {
let dir = tempfile::tempdir().unwrap();
let (db, [a, b, c]) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let snap = build_scope(&db, &scopes, 2).unwrap();
assert_eq!(snap.nodes.len(), 2);
let t = snap.truncated.expect("truncation must be recorded");
assert_eq!(t.nodes_dropped, 1);
let kept: std::collections::BTreeSet<String> =
snap.nodes.iter().map(|n| n.id.clone()).collect();
let dropped = [a, b, c]
.iter()
.find(|id| !kept.contains(&id.to_string()))
.unwrap()
.to_string();
let expected = [
(a.to_string(), b.to_string()),
(b.to_string(), c.to_string()),
]
.iter()
.filter(|(f, t2)| *f == dropped || *t2 == dropped)
.count();
assert_eq!(
t.edges_dropped, expected,
"each dropped-adjacent edge counted exactly once"
);
}
#[test]
fn exports_are_byte_identical_across_calls() {
let dir = tempfile::tempdir().unwrap();
let (db, [a, ..]) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let s1 = to_canonical_json(&build_scope(&db, &scopes, 500).unwrap()).unwrap();
let s2 = to_canonical_json(&build_scope(&db, &scopes, 500).unwrap()).unwrap();
assert_eq!(s1, s2);
let p = EgoParams {
seeds: vec![a],
query: None,
query_k: 3,
max_hops: 2,
direction: topodb::Direction::Both,
edge_types: None,
as_of: None,
time_axis: topodb::TimeAxis::Valid,
};
let e1 = to_canonical_json(&build_ego(&db, &scopes, &p).unwrap()).unwrap();
let e2 = to_canonical_json(&build_ego(&db, &scopes, &p).unwrap()).unwrap();
assert_eq!(e1, e2);
}
#[test]
fn scope_view_edges_are_closed_over_rendered_nodes() {
let dir = tempfile::tempdir().unwrap();
let (db, _) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let snap = build_scope(&db, &scopes, 2).unwrap();
let node_ids: std::collections::HashSet<_> =
snap.nodes.iter().map(|n| n.id.clone()).collect();
for edge in &snap.edges {
assert!(
node_ids.contains(&edge.from),
"edge from {} not in rendered nodes",
edge.from
);
assert!(
node_ids.contains(&edge.to),
"edge to {} not in rendered nodes",
edge.to
);
}
let snap = build_scope(&db, &scopes, 500).unwrap();
let node_ids: std::collections::HashSet<_> =
snap.nodes.iter().map(|n| n.id.clone()).collect();
for edge in &snap.edges {
assert!(
node_ids.contains(&edge.from),
"edge from {} not in rendered nodes",
edge.from
);
assert!(
node_ids.contains(&edge.to),
"edge to {} not in rendered nodes",
edge.to
);
}
}
fn tiny_snap(superseded: bool, truncated: bool) -> GraphSnapshot {
GraphSnapshot {
snapshot_version: GRAPH_SNAPSHOT_VERSION,
db_path: None,
op_seq: 1,
scopes: vec!["shared".into()],
view: GraphView {
kind: "scope".into(),
seeds: vec![],
query: None,
hops: 0,
as_of: None,
time_axis: "valid".into(),
direction: "out".into(),
},
truncated: truncated.then_some(GraphTruncation {
nodes_dropped: 2,
edges_dropped: 3,
}),
nodes: vec![
GraphNode {
id: "01A".into(),
label: "Memory".into(),
title: "say \"hi\"".into(),
scope: "shared".into(),
superseded,
hop: 0,
},
GraphNode {
id: "01B".into(),
label: "Entity".into(),
title: "Bob".into(),
scope: "shared".into(),
superseded: false,
hop: 0,
},
],
edges: vec![GraphEdge {
from: "01A".into(),
to: "01B".into(),
ty: "ABOUT".into(),
scope: "shared".into(),
valid_from: 1,
valid_to: None,
}],
}
}
#[test]
fn dot_escapes_and_marks_superseded_and_truncation() {
let d = to_dot(&tiny_snap(true, true));
assert!(d.starts_with("digraph topodb {"));
assert!(d.contains("say \\\"hi\\\""));
assert!(d.contains("style=dashed"));
assert!(d.contains("truncated: 2 nodes, 3 edges dropped"));
assert!(d.contains("\"01A\" -> \"01B\""));
assert!(!d.contains("\\nshared"));
let mut snap = tiny_snap(false, false);
snap.scopes = vec!["shared".into(), "other".into()];
let d = to_dot(&snap);
assert!(d.contains("\\nshared")); for line in d.lines() {
if line.contains("01A") && line.contains("[label=") {
assert!(
!line.contains("\n"),
"node label must stay on one physical line"
);
}
}
}
#[test]
fn mermaid_sanitizes_ids_and_surfaces_truncation() {
let m = to_mermaid(&tiny_snap(false, true));
assert!(m.starts_with("graph TD"));
assert!(m.contains("n0[")); assert!(m.contains("n0 -->|ABOUT| n1"));
assert!(m.contains("#quot;"));
assert!(m.contains("truncated: 2 nodes, 3 edges dropped"));
assert!(!m.contains("01A[")); assert!(m.contains("n0[\"Memory: "));
let mut snap = tiny_snap(false, false);
snap.nodes[0].label = "Memory[bad]\"label".into();
let m = to_mermaid(&snap);
assert!(
m.contains("Memory(bad)#quot;label"),
"sanitized label should appear in output"
);
assert!(
!m.contains("[\"bad\"label"),
"raw label with quotes and brackets should not appear"
);
assert!(
!m.contains("Memory[bad]\"label"),
"unsanitized label should not appear"
);
}
#[test]
fn dot_and_mermaid_escape_edge_types() {
let mut snap = tiny_snap(false, false);
snap.edges[0].ty = "he\"llo|x".into();
let d = to_dot(&snap);
assert!(
d.contains("he\\\"llo|x"),
"dot should escape quotes in edge types"
);
let m = to_mermaid(&snap);
assert!(
m.contains("-->|hellox|"),
"mermaid should strip pipes and quotes from edge types"
);
assert!(
!m.contains("-->|he\"llo|x|"),
"mermaid should not contain raw quotes or pipes in edge label"
);
}
#[test]
fn mermaid_superseded_class_only_when_needed() {
assert!(to_mermaid(&tiny_snap(true, false)).contains("classDef superseded"));
assert!(!to_mermaid(&tiny_snap(false, false)).contains("classDef"));
}
#[test]
fn html_round_trips_the_snapshot_and_is_self_contained() {
let snap = tiny_snap(false, true);
let html = to_html(&snap).unwrap();
let start = html.find("<script id=\"snapshot\"").unwrap();
let json_start = html[start..].find('>').unwrap() + start + 1;
let json_end = html[json_start..].find("</script>").unwrap() + json_start;
let back: GraphSnapshot = serde_json::from_str(&html[json_start..json_end]).unwrap();
assert_eq!(back, snap);
assert!(!html.contains("http://"));
assert!(!html.contains("https://"));
assert!(!html.contains("__SNAPSHOT_JSON__"));
assert!(!html.contains("__PAGE_TITLE__"));
assert!(html.contains("truncated"));
}
#[test]
fn html_escapes_script_breakout() {
let mut snap = tiny_snap(false, false);
snap.nodes[0].title = "</script><script>alert(1)".into();
let html = to_html(&snap).unwrap();
let body_after_snapshot = &html[html.find("id=\"snapshot\"").unwrap()..];
assert!(!body_after_snapshot.contains("</script><script>alert"));
}
#[test]
#[ignore]
fn html_smoke_writes_to_target_for_eyeballing() {
let dir = tempfile::tempdir().unwrap();
let (db, [a, ..]) = seed_chain(&dir);
let scopes = crate::scope_to_scope_set(Scope::Shared);
let p = EgoParams {
seeds: vec![a],
query: None,
query_k: 3,
max_hops: 2,
direction: topodb::Direction::Both,
edge_types: None,
as_of: None,
time_axis: topodb::TimeAxis::Valid,
};
let snap = build_ego(&db, &scopes, &p).unwrap();
let html = to_html(&snap).unwrap();
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let target = manifest_dir
.parent()
.unwrap()
.parent()
.unwrap()
.join("target");
let _ = std::fs::create_dir_all(&target);
std::fs::write(target.join("graph-smoke.html"), html).unwrap();
}
}