use std::collections::HashMap;
use serde_json::Value;
pub type NodeId = String;
pub type EdgeId = String;
pub mod cache;
pub use cache::*;
pub mod preview;
pub use preview::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub label: Option<String>,
pub attrs: HashMap<String, Value>,
}
impl Node {
pub fn new(id: impl Into<NodeId>) -> Self {
Self { id: id.into(), label: None, attrs: HashMap::new() }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
pub id: EdgeId,
pub source: NodeId,
pub target: NodeId,
pub attrs: HashMap<String, Value>,
}
impl Edge {
pub fn new(id: impl Into<EdgeId>, source: impl Into<NodeId>, target: impl Into<NodeId>) -> Self {
Self { id: id.into(), source: source.into(), target: target.into(), attrs: HashMap::new() }
}
}
#[derive(Debug, Clone, Default)]
pub struct Graph {
nodes: HashMap<NodeId, Node>,
edges: HashMap<EdgeId, Edge>,
adjacency: HashMap<NodeId, Vec<EdgeId>>,
}
impl Graph {
pub fn add_node(&mut self, node: Node) {
self.adjacency.entry(node.id.clone()).or_default();
self.nodes.insert(node.id.clone(), node);
}
pub fn add_edge(&mut self, edge: Edge) {
self.link_adjacency(&edge);
self.edges.insert(edge.id.clone(), edge);
}
fn link_adjacency(&mut self, edge: &Edge) {
self.adjacency.entry(edge.source.clone()).or_default().push(edge.id.clone());
if edge.source != edge.target {
self.adjacency.entry(edge.target.clone()).or_default().push(edge.id.clone());
}
}
fn unlink_adjacency(&mut self, id: &EdgeId, source: &NodeId, target: &NodeId) {
for endpoint in [source, target] {
if let Some(list) = self.adjacency.get_mut(endpoint) {
list.retain(|e| e != id);
}
}
}
pub fn node(&self, id: &str) -> Option<&Node> { self.nodes.get(id) }
pub fn edge(&self, id: &str) -> Option<&Edge> { self.edges.get(id) }
pub fn node_count(&self) -> usize { self.nodes.len() }
pub fn edge_count(&self) -> usize { self.edges.len() }
pub fn nodes(&self) -> impl Iterator<Item = &Node> { self.nodes.values() }
pub fn edges(&self) -> impl Iterator<Item = &Edge> { self.edges.values() }
pub fn neighbors(&self, id: &str) -> Vec<NodeId> {
let mut out = Vec::new();
if let Some(edge_ids) = self.adjacency.get(id) {
for eid in edge_ids {
if let Some(e) = self.edges.get(eid) {
let other = if e.source == id { &e.target } else { &e.source };
out.push(other.clone());
}
}
}
out
}
pub fn degree(&self, id: &str) -> usize {
self.adjacency.get(id).map_or(0, Vec::len)
}
pub fn upsert_node(&mut self, n: Node) {
self.adjacency.entry(n.id.clone()).or_default();
self.nodes.insert(n.id.clone(), n);
}
pub fn upsert_edge(&mut self, e: Edge) {
if let Some(old) = self.edges.get(&e.id) {
if old.source == e.source && old.target == e.target {
self.edges.insert(e.id.clone(), e);
return;
}
let (os, ot) = (old.source.clone(), old.target.clone());
self.unlink_adjacency(&e.id, &os, &ot);
}
self.link_adjacency(&e);
self.edges.insert(e.id.clone(), e);
}
pub fn most_central(&self) -> Option<NodeId> {
let mut ids: Vec<&NodeId> = self.nodes.keys().collect();
ids.sort();
let mut best: Option<(&NodeId, usize)> = None;
for id in ids {
let d = self.degree(id);
if best.is_none_or(|(_, bd)| d > bd) {
best = Some((id, d));
}
}
best.map(|(id, _)| id.clone())
}
}
#[derive(Debug, Clone, Default)]
pub struct NavState {
pub current: Option<NodeId>,
pub history: Vec<NodeId>,
}
impl NavState {
pub fn focus(&mut self, id: NodeId) {
if let Some(prev) = self.current.take() {
self.history.push(prev);
}
self.current = Some(id);
}
pub fn back(&mut self) -> Option<NodeId> {
let prev = self.history.pop()?;
self.current = Some(prev.clone());
Some(prev)
}
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Cursor(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueryParams {
pub limit: usize,
pub cursor: Option<Cursor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupBy {
Relationship,
Label,
Property(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Aggregate {
pub id: NodeId,
#[serde(default)]
pub parent: NodeId,
pub group_by: GroupBy,
pub value: String,
#[serde(default)]
pub relationships: Vec<String>,
pub count: u64,
pub query: QueryParams,
}
impl Aggregate {
pub fn display(&self) -> String {
format!("{} {}", self.count, self.value)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NeighborResult {
#[serde(default)]
pub nodes: Vec<Node>,
#[serde(default)]
pub edges: Vec<Edge>,
#[serde(default)]
pub aggregates: Vec<Aggregate>,
#[serde(default)]
pub next: Option<Cursor>,
#[serde(default)]
pub pending: bool,
}
pub trait DataProvider {
fn load(&self) -> Graph;
fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult;
}
#[derive(Deserialize)]
struct JsonNode {
id: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
attrs: HashMap<String, Value>,
}
#[derive(Deserialize)]
struct JsonEdge {
#[serde(default)]
id: Option<String>,
source: String,
target: String,
#[serde(default)]
attrs: HashMap<String, Value>,
}
#[derive(Deserialize)]
struct JsonGraph {
nodes: Vec<JsonNode>,
edges: Vec<JsonEdge>,
}
pub struct InMemoryProvider {
graph: Graph,
}
impl InMemoryProvider {
pub fn from_json(s: &str) -> Result<Self, String> {
use std::collections::HashSet;
let jg: JsonGraph = serde_json::from_str(s).map_err(|e| format!("parse: {e}"))?;
let mut g = Graph::default();
let mut node_ids: HashSet<String> = HashSet::new();
for n in jg.nodes {
if !node_ids.insert(n.id.clone()) {
return Err(format!("duplicate node id: {}", n.id));
}
g.add_node(Node { id: n.id, label: n.label, attrs: n.attrs });
}
let explicit_ids: HashSet<String> = jg.edges.iter().filter_map(|e| e.id.clone()).collect();
let mut seen_edge_ids: HashSet<String> = HashSet::new();
let mut auto = 0usize;
for e in jg.edges {
if !node_ids.contains(&e.source) {
return Err(format!("edge source not a declared node: {}", e.source));
}
if !node_ids.contains(&e.target) {
return Err(format!("edge target not a declared node: {}", e.target));
}
let id = match e.id {
Some(id) => {
if !seen_edge_ids.insert(id.clone()) {
return Err(format!("duplicate edge id: {id}"));
}
id
}
None => loop {
let cand = format!("e{auto}");
auto += 1;
if !explicit_ids.contains(&cand) && !seen_edge_ids.contains(&cand) {
seen_edge_ids.insert(cand.clone());
break cand;
}
},
};
g.add_edge(Edge { id, source: e.source, target: e.target, attrs: e.attrs });
}
Ok(Self { graph: g })
}
pub fn from_graph(graph: Graph) -> Self {
Self { graph }
}
}
impl DataProvider for InMemoryProvider {
fn load(&self) -> Graph {
self.graph.clone()
}
fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
let mut ids = self.graph.neighbors(focus);
ids.sort();
ids.dedup();
let offset = params
.cursor
.as_ref()
.and_then(|c| c.0.parse::<usize>().ok())
.unwrap_or(0);
let limit = params.limit.max(1);
let nodes: Vec<Node> = ids
.iter()
.skip(offset)
.take(limit)
.filter_map(|id| self.graph.node(id).cloned())
.collect();
let consumed = (offset + limit).min(ids.len());
let next = if consumed < ids.len() {
Some(Cursor(consumed.to_string()))
} else {
None
};
NeighborResult { nodes, edges: Vec::new(), aggregates: Vec::new(), next, pending: false }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_get_nodes_and_edges() {
let mut g = Graph::default();
g.add_node(Node::new("a"));
g.add_node(Node::new("b"));
g.add_edge(Edge::new("e1", "a", "b"));
assert_eq!(g.node("a").unwrap().id, "a");
assert_eq!(g.edge("e1").unwrap().source, "a");
assert_eq!(g.node_count(), 2);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn neighbors_are_undirected() {
let mut g = Graph::default();
g.add_node(Node::new("a"));
g.add_node(Node::new("b"));
g.add_node(Node::new("c"));
g.add_edge(Edge::new("e1", "a", "b"));
g.add_edge(Edge::new("e2", "c", "a"));
let mut n = g.neighbors("a");
n.sort();
assert_eq!(n, vec!["b".to_string(), "c".to_string()]);
}
#[test]
fn focus_pushes_previous_onto_history() {
let mut nav = NavState::default();
nav.focus("a".into());
nav.focus("b".into());
assert_eq!(nav.current.as_deref(), Some("b"));
assert_eq!(nav.history, vec!["a".to_string()]);
}
#[test]
fn back_pops_history() {
let mut nav = NavState::default();
nav.focus("a".into());
nav.focus("b".into());
assert_eq!(nav.back(), Some("a".to_string()));
assert_eq!(nav.current.as_deref(), Some("a"));
assert!(nav.history.is_empty());
assert_eq!(nav.back(), None);
}
#[test]
fn parses_json_graph() {
let json = r#"{
"nodes": [{"id":"a","label":"A"},{"id":"b"}],
"edges": [{"source":"a","target":"b"}]
}"#;
let p = InMemoryProvider::from_json(json).unwrap();
let g = p.load();
assert_eq!(g.node_count(), 2);
assert_eq!(g.node("a").unwrap().label.as_deref(), Some("A"));
assert_eq!(g.edge_count(), 1);
assert_eq!(g.edge("e0").unwrap().target, "b");
}
fn star(center: &str, leaves: &[&str]) -> Graph {
let mut g = Graph::default();
g.add_node(Node::new(center));
for (i, l) in leaves.iter().enumerate() {
g.add_node(Node::new(*l));
g.add_edge(Edge::new(format!("e{i}"), center, *l));
}
g
}
#[test]
fn neighbors_pages_with_cursor_no_gaps() {
let g = star("a", &["b", "c", "d", "e", "f"]);
let p = InMemoryProvider::from_graph(g);
let page1 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: None });
assert_eq!(page1.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["b", "c"]);
assert!(page1.aggregates.is_empty());
let c1 = page1.next.clone().expect("more remain after page1");
let page2 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: Some(c1) });
assert_eq!(page2.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["d", "e"]);
let c2 = page2.next.clone().expect("more remain after page2");
let page3 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: Some(c2) });
assert_eq!(page3.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["f"]);
assert!(page3.next.is_none(), "no more after the last page");
}
#[test]
fn neighbors_all_fit_no_cursor() {
let g = star("a", &["b", "c"]);
let p = InMemoryProvider::from_graph(g);
let res = p.neighbors(&"a".into(), &QueryParams { limit: 10, cursor: None });
assert_eq!(res.nodes.len(), 2);
assert!(res.next.is_none());
}
#[test]
fn most_central_picks_highest_degree() {
let g = star("h", &["a", "b", "c"]);
assert_eq!(g.degree("h"), 3);
assert_eq!(g.most_central(), Some("h".to_string()));
}
#[test]
fn most_central_breaks_ties_by_smallest_id() {
let mut g = Graph::default();
g.add_node(Node::new("y"));
g.add_node(Node::new("x"));
g.add_edge(Edge::new("e", "x", "y"));
assert_eq!(g.most_central(), Some("x".to_string()));
}
#[test]
fn most_central_empty_is_none() {
assert_eq!(Graph::default().most_central(), None);
}
#[test]
fn neighbors_paging_survives_dangling_edges() {
let mut g = Graph::default();
for id in ["a", "n1", "n3"] { g.add_node(Node::new(id)); }
g.add_edge(Edge::new("e1", "a", "n1"));
g.add_edge(Edge::new("e2", "a", "ghost")); g.add_edge(Edge::new("e3", "a", "n3"));
let p = InMemoryProvider::from_graph(g);
let mut seen = Vec::new();
let mut cursor = None;
for _ in 0..10 { let res = p.neighbors(&"a".into(), &QueryParams { limit: 1, cursor });
for n in &res.nodes { seen.push(n.id.clone()); }
cursor = res.next.clone();
if cursor.is_none() { break; }
}
seen.sort();
assert_eq!(seen, vec!["n1".to_string(), "n3".to_string()]); }
#[test]
fn from_json_rejects_dangling_edge_endpoints() {
let j = r#"{ "nodes": [{"id":"a"}], "edges": [{"source":"a","target":"ghost"}] }"#;
assert!(InMemoryProvider::from_json(j).is_err());
}
#[test]
fn from_json_rejects_duplicate_edge_ids() {
let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
"edges":[{"id":"e","source":"a","target":"b"},{"id":"e","source":"b","target":"c"}] }"#;
assert!(InMemoryProvider::from_json(j).is_err());
}
#[test]
fn from_json_rejects_duplicate_node_ids() {
let j = r#"{ "nodes":[{"id":"a"},{"id":"a"}], "edges":[] }"#;
assert!(InMemoryProvider::from_json(j).is_err());
}
#[test]
fn auto_edge_ids_avoid_colliding_with_explicit_ids() {
let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
"edges":[{"id":"e0","source":"a","target":"b"},{"source":"b","target":"c"}] }"#;
let g = InMemoryProvider::from_json(j).unwrap().load();
assert_eq!(g.edge_count(), 2);
assert_eq!(g.edge("e0").unwrap().target, "b"); assert_eq!(g.neighbors("c"), vec!["b".to_string()]);
}
#[test]
fn auto_edge_ids_avoid_explicit_id_declared_later() {
let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
"edges":[{"source":"a","target":"b"},{"id":"e0","source":"b","target":"c"}] }"#;
let g = InMemoryProvider::from_json(j).unwrap().load();
assert_eq!(g.edge_count(), 2);
assert_eq!(g.edge("e0").unwrap().source, "b");
assert_eq!(g.edge("e0").unwrap().target, "c");
}
#[test]
fn neighbor_result_round_trips_as_json() {
let r = NeighborResult {
nodes: vec![Node { id: "a".into(), label: Some("A".into()), attrs: HashMap::new() }],
edges: vec![Edge { id: "e1".into(), source: "a".into(), target: "b".into(), attrs: HashMap::new() }],
aggregates: vec![Aggregate {
id: "agg:X".into(),
parent: "a".into(),
group_by: GroupBy::Label,
value: "X".into(),
relationships: vec![],
count: 12,
query: QueryParams { limit: 8, cursor: Some(Cursor("grp:X".into())) },
}],
next: Some(Cursor("off:8".into())),
pending: false,
};
let s = serde_json::to_string(&r).unwrap();
let back: NeighborResult = serde_json::from_str(&s).unwrap();
assert_eq!(back.nodes.len(), 1);
assert_eq!(back.edges.len(), 1);
assert_eq!(back.aggregates[0].count, 12);
assert_eq!(back.aggregates[0].query.cursor, Some(Cursor("grp:X".into())));
assert_eq!(back.next, Some(Cursor("off:8".into())));
assert!(!back.pending);
}
#[test]
fn neighbor_result_defaults_edges_and_pending() {
let back: NeighborResult =
serde_json::from_str(r#"{"nodes":[],"aggregates":[],"next":null}"#).unwrap();
assert!(back.edges.is_empty());
assert!(!back.pending);
}
#[test]
fn upsert_edge_with_new_endpoints_moves_its_adjacency() {
let mut g = Graph::default();
for id in ["a", "b", "c", "d"] { g.add_node(Node::new(id)); }
g.add_edge(Edge::new("e1", "a", "b"));
assert_eq!((g.degree("a"), g.degree("b")), (1, 1));
g.upsert_edge(Edge::new("e1", "c", "d"));
assert_eq!(g.edge_count(), 1);
assert_eq!(g.degree("a"), 0, "stale adjacency left on the old source");
assert_eq!(g.degree("b"), 0, "stale adjacency left on the old target");
assert_eq!(g.degree("c"), 1);
assert_eq!(g.degree("d"), 1);
assert_eq!(g.neighbors("a"), Vec::<String>::new());
assert_eq!(g.neighbors("c"), vec!["d".to_string()]);
}
#[test]
fn upsert_edge_with_the_same_endpoints_does_not_duplicate_adjacency() {
let mut g = Graph::default();
for id in ["a", "b"] { g.add_node(Node::new(id)); }
g.add_edge(Edge::new("e1", "a", "b"));
g.upsert_edge(Edge::new("e1", "a", "b"));
g.upsert_edge(Edge::new("e1", "a", "b"));
assert_eq!(g.degree("a"), 1);
assert_eq!(g.degree("b"), 1);
}
#[test]
fn self_loop_counts_once_toward_degree() {
let mut g = Graph::default();
g.add_node(Node::new("a"));
g.add_edge(Edge::new("loop", "a", "a"));
assert_eq!(g.degree("a"), 1, "a self-loop is one incident edge, not two");
for _ in 0..3 { g.upsert_edge(Edge::new("loop", "a", "a")); }
assert_eq!(g.degree("a"), 1);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn upsert_edge_can_turn_an_edge_into_a_self_loop_and_back() {
let mut g = Graph::default();
for id in ["a", "b"] { g.add_node(Node::new(id)); }
g.add_edge(Edge::new("e1", "a", "b"));
g.upsert_edge(Edge::new("e1", "a", "a"));
assert_eq!(g.degree("a"), 1);
assert_eq!(g.degree("b"), 0);
g.upsert_edge(Edge::new("e1", "a", "b"));
assert_eq!(g.degree("a"), 1);
assert_eq!(g.degree("b"), 1);
}
#[test]
fn neighbors_handles_bad_and_past_end_cursors() {
let g = star("a", &["b", "c"]);
let p = InMemoryProvider::from_graph(g);
let garbage = p.neighbors(&"a".into(), &QueryParams { limit: 5, cursor: Some(Cursor("xyz".into())) });
assert_eq!(garbage.nodes.len(), 2); let past = p.neighbors(&"a".into(), &QueryParams { limit: 5, cursor: Some(Cursor("99".into())) });
assert!(past.nodes.is_empty());
assert!(past.next.is_none());
}
#[test]
fn aggregate_display_reads_naturally_for_each_grouping() {
let agg = |gb: GroupBy, value: &str, count: u64| Aggregate {
id: "agg:x".into(),
parent: String::new(),
group_by: gb,
value: value.into(),
relationships: vec![],
count,
query: QueryParams { limit: 8, cursor: None },
};
assert_eq!(agg(GroupBy::Label, "Sorcery", 32).display(), "32 Sorcery");
assert_eq!(agg(GroupBy::Relationship, "OF_TYPE", 4).display(), "4 OF_TYPE");
assert_eq!(agg(GroupBy::Property("rarity".into()), "rare", 1).display(), "1 rare");
}
#[test]
fn aggregate_deserializes_without_parent_or_relationships() {
let json = r#"{
"id": "agg:Sorcery",
"group_by": "label",
"value": "Sorcery",
"count": 32,
"query": { "limit": 8, "cursor": "grp:Sorcery:0" }
}"#;
let a: Aggregate = serde_json::from_str(json).unwrap();
assert_eq!(a.parent, "", "absent parent defaults empty, to be stamped on receipt");
assert!(a.relationships.is_empty(), "empty means UNSTATED, never 'no relationships'");
assert_eq!(a.group_by, GroupBy::Label);
assert_eq!(a.value, "Sorcery");
assert_eq!(a.query.cursor.as_ref().unwrap().0, "grp:Sorcery:0");
}
#[test]
fn group_by_round_trips_including_the_property_variant() {
for gb in [GroupBy::Relationship, GroupBy::Label, GroupBy::Property("rarity".into())] {
let s = serde_json::to_string(&gb).unwrap();
let back: GroupBy = serde_json::from_str(&s).unwrap();
assert_eq!(back, gb, "round-trip failed for {s}");
}
assert_eq!(serde_json::to_string(&GroupBy::Label).unwrap(), r#""label""#);
assert_eq!(serde_json::to_string(&GroupBy::Relationship).unwrap(), r#""relationship""#);
assert_eq!(
serde_json::to_string(&GroupBy::Property("rarity".into())).unwrap(),
r#"{"property":"rarity"}"#
);
}
#[test]
fn a_wire_aggregate_cannot_forge_its_parent() {
let json = r#"{
"id": "agg:x", "parent": "lies", "group_by": "label",
"value": "X", "count": 1, "query": { "limit": 8, "cursor": null }
}"#;
let a: Aggregate = serde_json::from_str(json).unwrap();
assert_eq!(a.parent, "lies", "deserializes; callers MUST stamp over it");
}
#[test]
fn neighbor_result_tolerates_a_partial_host_payload() {
let r: NeighborResult = serde_json::from_str(r#"{"nodes":[]}"#).unwrap();
assert!(r.nodes.is_empty());
assert!(r.edges.is_empty());
assert!(r.aggregates.is_empty());
assert!(r.next.is_none());
assert!(!r.pending, "pending is client-side and must never arrive true");
let empty: NeighborResult = serde_json::from_str("{}").unwrap();
assert!(empty.nodes.is_empty());
}
}