Skip to main content

graph_explorer_core/
lib.rs

1use std::collections::HashMap;
2use serde_json::Value;
3
4pub type NodeId = String;
5pub type EdgeId = String;
6
7pub mod cache;
8pub use cache::*;
9
10pub mod preview;
11pub use preview::*;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Node {
15    pub id: NodeId,
16    pub label: Option<String>,
17    pub attrs: HashMap<String, Value>,
18}
19
20impl Node {
21    pub fn new(id: impl Into<NodeId>) -> Self {
22        Self { id: id.into(), label: None, attrs: HashMap::new() }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct Edge {
28    pub id: EdgeId,
29    pub source: NodeId,
30    pub target: NodeId,
31    pub attrs: HashMap<String, Value>,
32}
33
34impl Edge {
35    pub fn new(id: impl Into<EdgeId>, source: impl Into<NodeId>, target: impl Into<NodeId>) -> Self {
36        Self { id: id.into(), source: source.into(), target: target.into(), attrs: HashMap::new() }
37    }
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct Graph {
42    nodes: HashMap<NodeId, Node>,
43    edges: HashMap<EdgeId, Edge>,
44    adjacency: HashMap<NodeId, Vec<EdgeId>>,
45}
46
47impl Graph {
48    pub fn add_node(&mut self, node: Node) {
49        self.adjacency.entry(node.id.clone()).or_default();
50        self.nodes.insert(node.id.clone(), node);
51    }
52
53    pub fn add_edge(&mut self, edge: Edge) {
54        self.link_adjacency(&edge);
55        self.edges.insert(edge.id.clone(), edge);
56    }
57
58    /// Record `edge` under both endpoints. A self-loop is recorded once, so
59    /// `degree()` counts it as one incident edge rather than two.
60    fn link_adjacency(&mut self, edge: &Edge) {
61        self.adjacency.entry(edge.source.clone()).or_default().push(edge.id.clone());
62        if edge.source != edge.target {
63            self.adjacency.entry(edge.target.clone()).or_default().push(edge.id.clone());
64        }
65    }
66
67    /// Remove `id` from both endpoints' adjacency lists.
68    fn unlink_adjacency(&mut self, id: &EdgeId, source: &NodeId, target: &NodeId) {
69        for endpoint in [source, target] {
70            if let Some(list) = self.adjacency.get_mut(endpoint) {
71                list.retain(|e| e != id);
72            }
73        }
74    }
75
76    pub fn node(&self, id: &str) -> Option<&Node> { self.nodes.get(id) }
77    pub fn edge(&self, id: &str) -> Option<&Edge> { self.edges.get(id) }
78    pub fn node_count(&self) -> usize { self.nodes.len() }
79    pub fn edge_count(&self) -> usize { self.edges.len() }
80    pub fn nodes(&self) -> impl Iterator<Item = &Node> { self.nodes.values() }
81    pub fn edges(&self) -> impl Iterator<Item = &Edge> { self.edges.values() }
82
83    /// Undirected neighbors: every node reachable by one edge, either direction.
84    pub fn neighbors(&self, id: &str) -> Vec<NodeId> {
85        let mut out = Vec::new();
86        if let Some(edge_ids) = self.adjacency.get(id) {
87            for eid in edge_ids {
88                if let Some(e) = self.edges.get(eid) {
89                    let other = if e.source == id { &e.target } else { &e.source };
90                    out.push(other.clone());
91                }
92            }
93        }
94        out
95    }
96
97    pub fn degree(&self, id: &str) -> usize {
98        self.adjacency.get(id).map_or(0, Vec::len)
99    }
100
101    /// Insert or replace a node by id.
102    pub fn upsert_node(&mut self, n: Node) {
103        self.adjacency.entry(n.id.clone()).or_default();
104        self.nodes.insert(n.id.clone(), n);
105    }
106
107    /// Insert or replace an edge by id. Replacing an edge whose endpoints
108    /// changed unlinks the OLD endpoints first — otherwise the id would linger
109    /// in adjacency lists it no longer belongs to and be missing from the ones
110    /// it does, so `degree()`/`most_central()` would disagree with `edges`.
111    pub fn upsert_edge(&mut self, e: Edge) {
112        if let Some(old) = self.edges.get(&e.id) {
113            if old.source == e.source && old.target == e.target {
114                // same endpoints: adjacency is already correct, don't duplicate
115                self.edges.insert(e.id.clone(), e);
116                return;
117            }
118            let (os, ot) = (old.source.clone(), old.target.clone());
119            self.unlink_adjacency(&e.id, &os, &ot);
120        }
121        self.link_adjacency(&e);
122        self.edges.insert(e.id.clone(), e);
123    }
124
125    /// The highest-degree node, ties broken by smallest id (deterministic).
126    /// `None` for an empty graph.
127    pub fn most_central(&self) -> Option<NodeId> {
128        let mut ids: Vec<&NodeId> = self.nodes.keys().collect();
129        ids.sort();
130        let mut best: Option<(&NodeId, usize)> = None;
131        for id in ids {
132            let d = self.degree(id);
133            // strictly-greater keeps the first (smallest-id) node on ties
134            if best.is_none_or(|(_, bd)| d > bd) {
135                best = Some((id, d));
136            }
137        }
138        best.map(|(id, _)| id.clone())
139    }
140}
141
142#[derive(Debug, Clone, Default)]
143pub struct NavState {
144    pub current: Option<NodeId>,
145    pub history: Vec<NodeId>,
146}
147
148impl NavState {
149    /// Move focus to `id`, pushing the previous current node onto history.
150    pub fn focus(&mut self, id: NodeId) {
151        if let Some(prev) = self.current.take() {
152            self.history.push(prev);
153        }
154        self.current = Some(id);
155    }
156
157    /// Return to the previous node, if any.
158    pub fn back(&mut self) -> Option<NodeId> {
159        let prev = self.history.pop()?;
160        self.current = Some(prev.clone());
161        Some(prev)
162    }
163}
164
165use serde::{Deserialize, Serialize};
166
167/// Opaque paging token, meaning defined by the provider.
168///
169/// `Eq + Hash` so `CacheKey` can index a map directly — `graph-explorer-wasm` keys its
170/// in-flight `AbortController`s by cache key.
171#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub struct Cursor(pub String);
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct QueryParams {
176    pub limit: usize,
177    pub cursor: Option<Cursor>,
178}
179
180/// What dimension a group of neighbors was formed along. Mirrors the proxy's
181/// `AggStrategy`, which already ships all three.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum GroupBy {
185    Relationship,
186    Label,
187    Property(String),
188}
189
190/// A group of neighbors collapsed into one summary. Descending it is a host
191/// decision, not an engine one — see `DescendOutcome::Subsearch`.
192///
193/// Deliberately carries no pre-rendered display string: `value` and `count` are
194/// data, and `display()` composes them so a host can phrase or localize it
195/// differently (via the `label_text` style rule) without parsing anything back
196/// out.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct Aggregate {
199    /// Stable handle. The radial view renders these as `__agg:<id>`.
200    pub id: NodeId,
201    /// The node this group hangs off. Derived on receipt and never trusted from
202    /// the wire — whoever ran the query knows which node it asked about, so a
203    /// derived value cannot be wrong and no provider can forget to set it.
204    /// Same rule as `NeighborResult::pending`.
205    #[serde(default)]
206    pub parent: NodeId,
207    pub group_by: GroupBy,
208    /// The shared value along that dimension: "OF_TYPE", "Sorcery", "rare".
209    pub value: String,
210    /// Optional sub-data: distinct relationship types the members are reached
211    /// through. **Empty means UNSTATED, never "no relationships"** — a node
212    /// always reaches its neighbors through something, so an empty list can
213    /// only mean the provider declined the extra aggregation. Advisory only:
214    /// never branch on emptiness to conclude anything about the graph.
215    #[serde(default)]
216    pub relationships: Vec<String>,
217    pub count: u64,
218    /// Opaque paging token that makes the group fetchable. Without it a host
219    /// knows N things exist but cannot reach them, and cannot reconstruct the
220    /// cursor because its encoding is provider-defined.
221    pub query: QueryParams,
222}
223
224impl Aggregate {
225    /// Default display text, e.g. "32 Sorcery". Composed rather than
226    /// transmitted so presentation stays overridable in the styling layer.
227    pub fn display(&self) -> String {
228        format!("{} {}", self.count, self.value)
229    }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct NeighborResult {
234    #[serde(default)]
235    pub nodes: Vec<Node>,
236    /// Real relationships among/into the returned nodes. The radial view
237    /// synthesizes its own focus->candidate edges, but the traditional view
238    /// draws real ones, so remote results carry them. Defaults empty.
239    #[serde(default)]
240    pub edges: Vec<Edge>,
241    #[serde(default)]
242    pub aggregates: Vec<Aggregate>,
243    #[serde(default)]
244    pub next: Option<Cursor>,
245    /// True when this is a placeholder standing in for an in-flight fetch.
246    #[serde(default)]
247    pub pending: bool,
248}
249
250pub trait DataProvider {
251    fn load(&self) -> Graph;
252    fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult;
253}
254
255#[derive(Deserialize)]
256struct JsonNode {
257    id: String,
258    #[serde(default)]
259    label: Option<String>,
260    #[serde(default)]
261    attrs: HashMap<String, Value>,
262}
263
264#[derive(Deserialize)]
265struct JsonEdge {
266    #[serde(default)]
267    id: Option<String>,
268    source: String,
269    target: String,
270    #[serde(default)]
271    attrs: HashMap<String, Value>,
272}
273
274#[derive(Deserialize)]
275struct JsonGraph {
276    nodes: Vec<JsonNode>,
277    edges: Vec<JsonEdge>,
278}
279
280pub struct InMemoryProvider {
281    graph: Graph,
282}
283
284impl InMemoryProvider {
285    pub fn from_json(s: &str) -> Result<Self, String> {
286        use std::collections::HashSet;
287        let jg: JsonGraph = serde_json::from_str(s).map_err(|e| format!("parse: {e}"))?;
288        let mut g = Graph::default();
289
290        let mut node_ids: HashSet<String> = HashSet::new();
291        for n in jg.nodes {
292            if !node_ids.insert(n.id.clone()) {
293                return Err(format!("duplicate node id: {}", n.id));
294            }
295            g.add_node(Node { id: n.id, label: n.label, attrs: n.attrs });
296        }
297
298        let explicit_ids: HashSet<String> = jg.edges.iter().filter_map(|e| e.id.clone()).collect();
299        let mut seen_edge_ids: HashSet<String> = HashSet::new();
300        let mut auto = 0usize;
301        for e in jg.edges {
302            if !node_ids.contains(&e.source) {
303                return Err(format!("edge source not a declared node: {}", e.source));
304            }
305            if !node_ids.contains(&e.target) {
306                return Err(format!("edge target not a declared node: {}", e.target));
307            }
308            let id = match e.id {
309                Some(id) => {
310                    if !seen_edge_ids.insert(id.clone()) {
311                        return Err(format!("duplicate edge id: {id}"));
312                    }
313                    id
314                }
315                None => loop {
316                    let cand = format!("e{auto}");
317                    auto += 1;
318                    if !explicit_ids.contains(&cand) && !seen_edge_ids.contains(&cand) {
319                        seen_edge_ids.insert(cand.clone());
320                        break cand;
321                    }
322                },
323            };
324            g.add_edge(Edge { id, source: e.source, target: e.target, attrs: e.attrs });
325        }
326        Ok(Self { graph: g })
327    }
328
329    pub fn from_graph(graph: Graph) -> Self {
330        Self { graph }
331    }
332}
333
334impl DataProvider for InMemoryProvider {
335    fn load(&self) -> Graph {
336        self.graph.clone()
337    }
338
339    fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
340        let mut ids = self.graph.neighbors(focus);
341        ids.sort();
342        ids.dedup();
343        let offset = params
344            .cursor
345            .as_ref()
346            .and_then(|c| c.0.parse::<usize>().ok())
347            .unwrap_or(0);
348        let limit = params.limit.max(1);
349        let nodes: Vec<Node> = ids
350            .iter()
351            .skip(offset)
352            .take(limit)
353            .filter_map(|id| self.graph.node(id).cloned())
354            .collect();
355        let consumed = (offset + limit).min(ids.len());
356        let next = if consumed < ids.len() {
357            Some(Cursor(consumed.to_string()))
358        } else {
359            None
360        };
361        NeighborResult { nodes, edges: Vec::new(), aggregates: Vec::new(), next, pending: false }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn add_and_get_nodes_and_edges() {
371        let mut g = Graph::default();
372        g.add_node(Node::new("a"));
373        g.add_node(Node::new("b"));
374        g.add_edge(Edge::new("e1", "a", "b"));
375        assert_eq!(g.node("a").unwrap().id, "a");
376        assert_eq!(g.edge("e1").unwrap().source, "a");
377        assert_eq!(g.node_count(), 2);
378        assert_eq!(g.edge_count(), 1);
379    }
380
381    #[test]
382    fn neighbors_are_undirected() {
383        let mut g = Graph::default();
384        g.add_node(Node::new("a"));
385        g.add_node(Node::new("b"));
386        g.add_node(Node::new("c"));
387        g.add_edge(Edge::new("e1", "a", "b"));
388        g.add_edge(Edge::new("e2", "c", "a"));
389        let mut n = g.neighbors("a");
390        n.sort();
391        assert_eq!(n, vec!["b".to_string(), "c".to_string()]);
392    }
393
394    #[test]
395    fn focus_pushes_previous_onto_history() {
396        let mut nav = NavState::default();
397        nav.focus("a".into());
398        nav.focus("b".into());
399        assert_eq!(nav.current.as_deref(), Some("b"));
400        assert_eq!(nav.history, vec!["a".to_string()]);
401    }
402
403    #[test]
404    fn back_pops_history() {
405        let mut nav = NavState::default();
406        nav.focus("a".into());
407        nav.focus("b".into());
408        assert_eq!(nav.back(), Some("a".to_string()));
409        assert_eq!(nav.current.as_deref(), Some("a"));
410        assert!(nav.history.is_empty());
411        assert_eq!(nav.back(), None);
412    }
413
414    #[test]
415    fn parses_json_graph() {
416        let json = r#"{
417            "nodes": [{"id":"a","label":"A"},{"id":"b"}],
418            "edges": [{"source":"a","target":"b"}]
419        }"#;
420        let p = InMemoryProvider::from_json(json).unwrap();
421        let g = p.load();
422        assert_eq!(g.node_count(), 2);
423        assert_eq!(g.node("a").unwrap().label.as_deref(), Some("A"));
424        assert_eq!(g.edge_count(), 1);
425        // edge id auto-generated when absent
426        assert_eq!(g.edge("e0").unwrap().target, "b");
427    }
428
429    fn star(center: &str, leaves: &[&str]) -> Graph {
430        let mut g = Graph::default();
431        g.add_node(Node::new(center));
432        for (i, l) in leaves.iter().enumerate() {
433            g.add_node(Node::new(*l));
434            g.add_edge(Edge::new(format!("e{i}"), center, *l));
435        }
436        g
437    }
438
439    #[test]
440    fn neighbors_pages_with_cursor_no_gaps() {
441        // center connected to 5 leaves: b,c,d,e,f
442        let g = star("a", &["b", "c", "d", "e", "f"]);
443        let p = InMemoryProvider::from_graph(g);
444
445        let page1 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: None });
446        assert_eq!(page1.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["b", "c"]);
447        assert!(page1.aggregates.is_empty());
448        let c1 = page1.next.clone().expect("more remain after page1");
449
450        let page2 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: Some(c1) });
451        assert_eq!(page2.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["d", "e"]);
452        let c2 = page2.next.clone().expect("more remain after page2");
453
454        let page3 = p.neighbors(&"a".into(), &QueryParams { limit: 2, cursor: Some(c2) });
455        assert_eq!(page3.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(), vec!["f"]);
456        assert!(page3.next.is_none(), "no more after the last page");
457    }
458
459    #[test]
460    fn neighbors_all_fit_no_cursor() {
461        let g = star("a", &["b", "c"]);
462        let p = InMemoryProvider::from_graph(g);
463        let res = p.neighbors(&"a".into(), &QueryParams { limit: 10, cursor: None });
464        assert_eq!(res.nodes.len(), 2);
465        assert!(res.next.is_none());
466    }
467
468    #[test]
469    fn most_central_picks_highest_degree() {
470        // hub "h" has degree 3; others degree 1
471        let g = star("h", &["a", "b", "c"]);
472        assert_eq!(g.degree("h"), 3);
473        assert_eq!(g.most_central(), Some("h".to_string()));
474    }
475
476    #[test]
477    fn most_central_breaks_ties_by_smallest_id() {
478        // two degree-1 nodes "x","y" connected to each other -> both degree 1; smallest id wins
479        let mut g = Graph::default();
480        g.add_node(Node::new("y"));
481        g.add_node(Node::new("x"));
482        g.add_edge(Edge::new("e", "x", "y"));
483        assert_eq!(g.most_central(), Some("x".to_string()));
484    }
485
486    #[test]
487    fn most_central_empty_is_none() {
488        assert_eq!(Graph::default().most_central(), None);
489    }
490
491    #[test]
492    fn neighbors_paging_survives_dangling_edges() {
493        let mut g = Graph::default();
494        for id in ["a", "n1", "n3"] { g.add_node(Node::new(id)); }
495        g.add_edge(Edge::new("e1", "a", "n1"));
496        g.add_edge(Edge::new("e2", "a", "ghost")); // dangling: "ghost" never added
497        g.add_edge(Edge::new("e3", "a", "n3"));
498        let p = InMemoryProvider::from_graph(g);
499        let mut seen = Vec::new();
500        let mut cursor = None;
501        for _ in 0..10 { // bounded to prove no infinite loop
502            let res = p.neighbors(&"a".into(), &QueryParams { limit: 1, cursor });
503            for n in &res.nodes { seen.push(n.id.clone()); }
504            cursor = res.next.clone();
505            if cursor.is_none() { break; }
506        }
507        seen.sort();
508        assert_eq!(seen, vec!["n1".to_string(), "n3".to_string()]); // each once, no dupes
509    }
510
511    #[test]
512    fn from_json_rejects_dangling_edge_endpoints() {
513        let j = r#"{ "nodes": [{"id":"a"}], "edges": [{"source":"a","target":"ghost"}] }"#;
514        assert!(InMemoryProvider::from_json(j).is_err());
515    }
516
517    #[test]
518    fn from_json_rejects_duplicate_edge_ids() {
519        let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
520                     "edges":[{"id":"e","source":"a","target":"b"},{"id":"e","source":"b","target":"c"}] }"#;
521        assert!(InMemoryProvider::from_json(j).is_err());
522    }
523
524    #[test]
525    fn from_json_rejects_duplicate_node_ids() {
526        let j = r#"{ "nodes":[{"id":"a"},{"id":"a"}], "edges":[] }"#;
527        assert!(InMemoryProvider::from_json(j).is_err());
528    }
529
530    #[test]
531    fn auto_edge_ids_avoid_colliding_with_explicit_ids() {
532        // explicit "e0" plus one anonymous edge -> anonymous must NOT reuse "e0"
533        let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
534                     "edges":[{"id":"e0","source":"a","target":"b"},{"source":"b","target":"c"}] }"#;
535        let g = InMemoryProvider::from_json(j).unwrap().load();
536        assert_eq!(g.edge_count(), 2);
537        assert_eq!(g.edge("e0").unwrap().target, "b"); // explicit one preserved
538        // the anonymous edge exists under some other id connecting b->c
539        assert_eq!(g.neighbors("c"), vec!["b".to_string()]);
540    }
541
542    #[test]
543    fn auto_edge_ids_avoid_explicit_id_declared_later() {
544        // anonymous edge listed BEFORE the explicit "e0" it must avoid (pre-collection design)
545        let j = r#"{ "nodes":[{"id":"a"},{"id":"b"},{"id":"c"}],
546                     "edges":[{"source":"a","target":"b"},{"id":"e0","source":"b","target":"c"}] }"#;
547        let g = InMemoryProvider::from_json(j).unwrap().load();
548        assert_eq!(g.edge_count(), 2);
549        // "e0" belongs to the explicit (b->c) edge; the anonymous a->b edge must NOT have claimed it
550        assert_eq!(g.edge("e0").unwrap().source, "b");
551        assert_eq!(g.edge("e0").unwrap().target, "c");
552    }
553
554    #[test]
555    fn neighbor_result_round_trips_as_json() {
556        let r = NeighborResult {
557            nodes: vec![Node { id: "a".into(), label: Some("A".into()), attrs: HashMap::new() }],
558            edges: vec![Edge { id: "e1".into(), source: "a".into(), target: "b".into(), attrs: HashMap::new() }],
559            aggregates: vec![Aggregate {
560                id: "agg:X".into(),
561                parent: "a".into(),
562                group_by: GroupBy::Label,
563                value: "X".into(),
564                relationships: vec![],
565                count: 12,
566                query: QueryParams { limit: 8, cursor: Some(Cursor("grp:X".into())) },
567            }],
568            next: Some(Cursor("off:8".into())),
569            pending: false,
570        };
571        let s = serde_json::to_string(&r).unwrap();
572        let back: NeighborResult = serde_json::from_str(&s).unwrap();
573        assert_eq!(back.nodes.len(), 1);
574        assert_eq!(back.edges.len(), 1);
575        assert_eq!(back.aggregates[0].count, 12);
576        assert_eq!(back.aggregates[0].query.cursor, Some(Cursor("grp:X".into())));
577        assert_eq!(back.next, Some(Cursor("off:8".into())));
578        assert!(!back.pending);
579    }
580
581    #[test]
582    fn neighbor_result_defaults_edges_and_pending() {
583        // A proxy payload that omits both fields must still parse.
584        let back: NeighborResult =
585            serde_json::from_str(r#"{"nodes":[],"aggregates":[],"next":null}"#).unwrap();
586        assert!(back.edges.is_empty());
587        assert!(!back.pending);
588    }
589
590    #[test]
591    fn upsert_edge_with_new_endpoints_moves_its_adjacency() {
592        let mut g = Graph::default();
593        for id in ["a", "b", "c", "d"] { g.add_node(Node::new(id)); }
594        g.add_edge(Edge::new("e1", "a", "b"));
595        assert_eq!((g.degree("a"), g.degree("b")), (1, 1));
596
597        // same id, different endpoints
598        g.upsert_edge(Edge::new("e1", "c", "d"));
599        assert_eq!(g.edge_count(), 1);
600        assert_eq!(g.degree("a"), 0, "stale adjacency left on the old source");
601        assert_eq!(g.degree("b"), 0, "stale adjacency left on the old target");
602        assert_eq!(g.degree("c"), 1);
603        assert_eq!(g.degree("d"), 1);
604        assert_eq!(g.neighbors("a"), Vec::<String>::new());
605        assert_eq!(g.neighbors("c"), vec!["d".to_string()]);
606    }
607
608    #[test]
609    fn upsert_edge_with_the_same_endpoints_does_not_duplicate_adjacency() {
610        let mut g = Graph::default();
611        for id in ["a", "b"] { g.add_node(Node::new(id)); }
612        g.add_edge(Edge::new("e1", "a", "b"));
613        g.upsert_edge(Edge::new("e1", "a", "b"));
614        g.upsert_edge(Edge::new("e1", "a", "b"));
615        assert_eq!(g.degree("a"), 1);
616        assert_eq!(g.degree("b"), 1);
617    }
618
619    #[test]
620    fn self_loop_counts_once_toward_degree() {
621        let mut g = Graph::default();
622        g.add_node(Node::new("a"));
623        g.add_edge(Edge::new("loop", "a", "a"));
624        assert_eq!(g.degree("a"), 1, "a self-loop is one incident edge, not two");
625
626        // repeated arrivals of the same self-loop must not inflate it
627        for _ in 0..3 { g.upsert_edge(Edge::new("loop", "a", "a")); }
628        assert_eq!(g.degree("a"), 1);
629        assert_eq!(g.edge_count(), 1);
630    }
631
632    #[test]
633    fn upsert_edge_can_turn_an_edge_into_a_self_loop_and_back() {
634        let mut g = Graph::default();
635        for id in ["a", "b"] { g.add_node(Node::new(id)); }
636        g.add_edge(Edge::new("e1", "a", "b"));
637        g.upsert_edge(Edge::new("e1", "a", "a"));
638        assert_eq!(g.degree("a"), 1);
639        assert_eq!(g.degree("b"), 0);
640        g.upsert_edge(Edge::new("e1", "a", "b"));
641        assert_eq!(g.degree("a"), 1);
642        assert_eq!(g.degree("b"), 1);
643    }
644
645    #[test]
646    fn neighbors_handles_bad_and_past_end_cursors() {
647        let g = star("a", &["b", "c"]);
648        let p = InMemoryProvider::from_graph(g);
649        let garbage = p.neighbors(&"a".into(), &QueryParams { limit: 5, cursor: Some(Cursor("xyz".into())) });
650        assert_eq!(garbage.nodes.len(), 2); // treated as offset 0
651        let past = p.neighbors(&"a".into(), &QueryParams { limit: 5, cursor: Some(Cursor("99".into())) });
652        assert!(past.nodes.is_empty());
653        assert!(past.next.is_none());
654    }
655
656    #[test]
657    fn aggregate_display_reads_naturally_for_each_grouping() {
658        let agg = |gb: GroupBy, value: &str, count: u64| Aggregate {
659            id: "agg:x".into(),
660            parent: String::new(),
661            group_by: gb,
662            value: value.into(),
663            relationships: vec![],
664            count,
665            query: QueryParams { limit: 8, cursor: None },
666        };
667        assert_eq!(agg(GroupBy::Label, "Sorcery", 32).display(), "32 Sorcery");
668        assert_eq!(agg(GroupBy::Relationship, "OF_TYPE", 4).display(), "4 OF_TYPE");
669        assert_eq!(agg(GroupBy::Property("rarity".into()), "rare", 1).display(), "1 rare");
670    }
671
672    #[test]
673    fn aggregate_deserializes_without_parent_or_relationships() {
674        // Both are `#[serde(default)]`: `parent` because it is stamped
675        // client-side and never trusted from the wire, `relationships`
676        // because a provider may decline the extra aggregation.
677        let json = r#"{
678            "id": "agg:Sorcery",
679            "group_by": "label",
680            "value": "Sorcery",
681            "count": 32,
682            "query": { "limit": 8, "cursor": "grp:Sorcery:0" }
683        }"#;
684        let a: Aggregate = serde_json::from_str(json).unwrap();
685        assert_eq!(a.parent, "", "absent parent defaults empty, to be stamped on receipt");
686        assert!(a.relationships.is_empty(), "empty means UNSTATED, never 'no relationships'");
687        assert_eq!(a.group_by, GroupBy::Label);
688        assert_eq!(a.value, "Sorcery");
689        assert_eq!(a.query.cursor.as_ref().unwrap().0, "grp:Sorcery:0");
690    }
691
692    #[test]
693    fn group_by_round_trips_including_the_property_variant() {
694        for gb in [GroupBy::Relationship, GroupBy::Label, GroupBy::Property("rarity".into())] {
695            let s = serde_json::to_string(&gb).unwrap();
696            let back: GroupBy = serde_json::from_str(&s).unwrap();
697            assert_eq!(back, gb, "round-trip failed for {s}");
698        }
699        // unit variants are bare strings; the newtype is externally tagged
700        assert_eq!(serde_json::to_string(&GroupBy::Label).unwrap(), r#""label""#);
701        assert_eq!(serde_json::to_string(&GroupBy::Relationship).unwrap(), r#""relationship""#);
702        assert_eq!(
703            serde_json::to_string(&GroupBy::Property("rarity".into())).unwrap(),
704            r#"{"property":"rarity"}"#
705        );
706    }
707
708    #[test]
709    fn a_wire_aggregate_cannot_forge_its_parent() {
710        // `parent` is derived on receipt. A provider that sends one is simply
711        // overwritten later; this test pins that it deserializes into the
712        // field rather than being rejected, so stamping is the only guard.
713        let json = r#"{
714            "id": "agg:x", "parent": "lies", "group_by": "label",
715            "value": "X", "count": 1, "query": { "limit": 8, "cursor": null }
716        }"#;
717        let a: Aggregate = serde_json::from_str(json).unwrap();
718        assert_eq!(a.parent, "lies", "deserializes; callers MUST stamp over it");
719    }
720
721    #[test]
722    fn neighbor_result_tolerates_a_partial_host_payload() {
723        // A host staging nodes via `preview_nodes` should not have to spell out
724        // every field. Only `nodes` is interesting for that call; the rest are
725        // engine-side concerns.
726        let r: NeighborResult = serde_json::from_str(r#"{"nodes":[]}"#).unwrap();
727        assert!(r.nodes.is_empty());
728        assert!(r.edges.is_empty());
729        assert!(r.aggregates.is_empty());
730        assert!(r.next.is_none());
731        assert!(!r.pending, "pending is client-side and must never arrive true");
732
733        let empty: NeighborResult = serde_json::from_str("{}").unwrap();
734        assert!(empty.nodes.is_empty());
735    }
736}