1use crate::{Aggregate, Edge, Graph, NeighborResult, Node, NodeId};
21
22#[derive(Debug, Default)]
23pub struct PreviewLayer {
24 generation: u64,
25 anchor: Option<NodeId>,
26 nodes: Vec<Node>,
27 edges: Vec<Edge>,
28 aggregates: Vec<Aggregate>,
29}
30
31impl PreviewLayer {
32 pub fn new() -> Self { Self::default() }
33
34 pub fn begin(&mut self, anchor: NodeId) -> u64 {
37 self.generation += 1;
38 self.anchor = Some(anchor);
39 self.nodes.clear();
40 self.edges.clear();
41 self.aggregates.clear();
42 self.generation
43 }
44
45 pub fn anchor(&self) -> Option<&NodeId> { self.anchor.as_ref() }
46 pub fn nodes(&self) -> &[Node] { &self.nodes }
47 pub fn edges(&self) -> &[Edge] { &self.edges }
48 pub fn aggregates(&self) -> &[Aggregate] { &self.aggregates }
49
50 pub fn node_count(&self) -> usize { self.nodes.len() }
56
57 pub fn is_empty(&self) -> bool {
61 self.nodes.is_empty() && self.edges.is_empty() && self.aggregates.is_empty()
62 }
63
64 pub fn has_committable(&self) -> bool {
69 !self.nodes.is_empty() || !self.edges.is_empty()
70 }
71
72 pub fn contains(&self, id: &str) -> bool { self.nodes.iter().any(|n| n.id == id) }
75
76 pub fn fill(&mut self, gen: u64, res: &NeighborResult, committed: &Graph) -> bool {
83 let Some(anchor) = self.anchor.clone() else { return false };
84 if gen != self.generation {
85 return false;
86 }
87 let mut seen = std::collections::HashSet::new();
90 let nodes: Vec<Node> = res
91 .nodes
92 .iter()
93 .filter(|n| committed.node(&n.id).is_none())
94 .filter(|n| seen.insert(n.id.clone()))
95 .cloned()
96 .collect();
97 let retained: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
100 let known = |id: &str| committed.node(id).is_some() || retained.contains(id);
101 self.edges = res
102 .edges
103 .iter()
104 .filter(|e| committed.edge(&e.id).is_none())
107 .filter(|e| known(&e.source) && known(&e.target))
112 .cloned()
113 .collect();
114 self.nodes = nodes;
115 self.aggregates = res.aggregates.iter().cloned().map(|mut a| {
119 a.parent = anchor.clone();
120 a
121 }).collect();
122 true
123 }
124
125 pub fn promote(&mut self, id: &str, committed: &Graph) -> Option<(Node, Vec<Edge>)> {
130 let idx = self.nodes.iter().position(|n| n.id == id)?;
131 let node = self.nodes.remove(idx);
132 let mut taken = Vec::new();
133 self.edges.retain(|e| {
134 if e.source != id && e.target != id {
135 return true;
136 }
137 let other = if e.source == id { &e.target } else { &e.source };
138 if other == id || committed.node(other).is_some() {
139 taken.push(e.clone());
140 false
141 } else {
142 true
143 }
144 });
145 Some((node, taken))
146 }
147
148 pub fn take_all(&mut self) -> (Vec<Node>, Vec<Edge>) {
157 self.generation += 1;
158 (std::mem::take(&mut self.nodes), std::mem::take(&mut self.edges))
159 }
160
161 pub fn clear(&mut self) {
165 self.generation += 1;
166 self.anchor = None;
167 self.nodes.clear();
168 self.edges.clear();
169 self.aggregates.clear();
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::{Aggregate, Edge, Graph, GroupBy, NeighborResult, Node, QueryParams};
177
178 fn committed(node_ids: &[&str], edges: &[(&str, &str, &str)]) -> Graph {
179 let mut g = Graph::default();
180 for id in node_ids { g.add_node(Node::new(*id)); }
181 for (id, s, t) in edges { g.add_edge(Edge::new(*id, *s, *t)); }
182 g
183 }
184
185 fn result(nodes: &[&str], edges: &[(&str, &str, &str)]) -> NeighborResult {
186 NeighborResult {
187 nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
188 edges: edges.iter().map(|(id, s, t)| Edge::new(*id, *s, *t)).collect(),
189 aggregates: vec![],
190 next: None,
191 pending: false,
192 }
193 }
194
195 fn result_with_agg(nodes: &[&str], aggs: &[(&str, &str, u64)]) -> NeighborResult {
196 NeighborResult {
197 nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
198 edges: vec![],
199 aggregates: aggs.iter().map(|(id, value, count)| Aggregate {
200 id: (*id).into(),
201 parent: String::new(),
202 group_by: GroupBy::Label,
203 value: (*value).into(),
204 relationships: vec![],
205 count: *count,
206 query: QueryParams { limit: 8, cursor: None },
207 }).collect(),
208 next: None,
209 pending: false,
210 }
211 }
212
213 #[test]
214 fn begin_bumps_the_generation_and_clears_prior_contents() {
215 let g = committed(&["a"], &[]);
216 let mut p = PreviewLayer::new();
217 let g1 = p.begin("a".into());
218 assert!(p.fill(g1, &result(&["b"], &[]), &g));
219 assert_eq!(p.node_count(), 1);
220
221 let g2 = p.begin("b".into());
222 assert!(g2 > g1, "each preview gets a fresh generation");
223 assert!(p.is_empty(), "beginning a new preview clears the old contents");
224 assert_eq!(p.anchor(), Some(&"b".to_string()));
225 }
226
227 #[test]
228 fn fill_with_a_stale_generation_is_rejected_and_changes_nothing() {
229 let g = committed(&["a"], &[]);
230 let mut p = PreviewLayer::new();
231 let stale = p.begin("a".into());
232 p.begin("b".into());
233
234 assert!(!p.fill(stale, &result(&["x", "y"], &[]), &g), "stale fill must be refused");
235 assert!(p.is_empty(), "a refused fill must not leak nodes into the layer");
236 }
237
238 #[test]
239 fn fill_drops_nodes_already_committed() {
240 let g = committed(&["a", "known"], &[]);
241 let mut p = PreviewLayer::new();
242 let gen = p.begin("a".into());
243 p.fill(gen, &result(&["known", "fresh"], &[]), &g);
244
245 let ids: Vec<&str> = p.nodes().iter().map(|n| n.id.as_str()).collect();
246 assert_eq!(ids, vec!["fresh"]);
247 }
248
249 #[test]
250 fn fill_keeps_a_new_edge_between_two_committed_nodes() {
251 let g = committed(&["a", "b"], &[]);
254 let mut p = PreviewLayer::new();
255 let gen = p.begin("a".into());
256 p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
257
258 assert_eq!(p.edges().len(), 1);
259 assert!(!p.is_empty(), "edge-only content still counts as a preview");
260 }
261
262 #[test]
263 fn fill_drops_edges_already_committed() {
264 let g = committed(&["a", "b"], &[("e1", "a", "b")]);
265 let mut p = PreviewLayer::new();
266 let gen = p.begin("a".into());
267 p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
268 assert!(p.edges().is_empty());
269 }
270
271 #[test]
272 fn promote_takes_the_node_and_only_safely_committable_edges() {
273 let g = committed(&["a"], &[]);
277 let mut p = PreviewLayer::new();
278 let gen = p.begin("a".into());
279 p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b"), ("bc", "b", "c")]), &g);
280
281 let (node, edges) = p.promote("b", &g).expect("b is provisional");
282 assert_eq!(node.id, "b");
283 let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
284 assert_eq!(ids, vec!["ab"]);
285
286 assert!(!p.contains("b"), "a promoted node leaves the preview");
287 assert!(p.contains("c"), "its sibling stays behind");
288 assert_eq!(p.edges().len(), 1, "b–c stays until c is committed too");
289 }
290
291 #[test]
292 fn fill_drops_an_edge_into_an_unknown_node() {
293 let g = committed(&["a"], &[]);
297 let mut p = PreviewLayer::new();
298 let gen = p.begin("a".into());
299 p.fill(gen, &result(&["b"], &[("bz", "b", "z")]), &g);
300 assert!(p.edges().is_empty(), "an edge to an unknown node must not survive fill");
301 }
302
303 #[test]
304 fn fill_on_a_fresh_layer_is_rejected() {
305 let g = committed(&["a"], &[]);
308 let mut p = PreviewLayer::new();
309 assert!(!p.fill(0, &result(&["b"], &[]), &g));
310 assert!(p.is_empty());
311 }
312
313 #[test]
314 fn fill_dedupes_duplicate_node_ids() {
315 let g = committed(&["a"], &[]);
316 let mut p = PreviewLayer::new();
317 let gen = p.begin("a".into());
318 p.fill(gen, &result(&["b", "b"], &[]), &g);
319 assert_eq!(p.node_count(), 1, "duplicate ids collapse to one entry");
320 }
321
322 #[test]
323 fn promote_of_an_unknown_or_already_promoted_id_is_none() {
324 let g = committed(&["a"], &[]);
325 let mut p = PreviewLayer::new();
326 let gen = p.begin("a".into());
327 p.fill(gen, &result(&["b"], &[]), &g);
328
329 assert!(p.promote("nope", &g).is_none());
330 assert!(p.promote("b", &g).is_some());
331 assert!(p.promote("b", &g).is_none(), "promoting twice is not possible");
332 }
333
334 #[test]
335 fn take_all_drains_the_layer() {
336 let g = committed(&["a"], &[]);
337 let mut p = PreviewLayer::new();
338 let gen = p.begin("a".into());
339 p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b")]), &g);
340
341 let (nodes, edges) = p.take_all();
342 assert_eq!(nodes.len(), 2);
343 assert_eq!(edges.len(), 1);
344 assert!(p.is_empty(), "take_all leaves nothing behind");
345 assert!(p.take_all().0.is_empty(), "draining twice yields nothing");
346 }
347
348 #[test]
349 fn take_all_retains_aggregates_and_anchor_while_clearing_committables() {
350 let g = committed(&["a"], &[]);
354 let mut p = PreviewLayer::new();
355 let gen = p.begin("a".into());
356 p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
357
358 let (nodes, edges) = p.take_all();
359 assert_eq!(nodes.len(), 1, "the committable node was drained");
360 assert!(edges.is_empty());
361
362 assert!(p.nodes().is_empty(), "committable nodes are gone from the layer");
363 assert!(p.edges().is_empty(), "committable edges are gone from the layer");
364 assert_eq!(p.aggregates().len(), 1, "the aggregate offer survives the commit");
365 assert_eq!(p.anchor(), Some(&"a".to_string()), "the anchor survives too");
366 assert!(!p.has_committable(), "nothing left to commit");
367 assert!(!p.is_empty(), "the surviving aggregate still counts as something to report");
368 }
369
370 #[test]
371 fn take_all_still_retires_the_generation() {
372 let g = committed(&["a"], &[]);
375 let mut p = PreviewLayer::new();
376 let gen = p.begin("a".into());
377 p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
378 p.take_all();
379
380 assert!(!p.fill(gen, &result(&["c"], &[]), &g), "a late arrival must still be blocked");
381 }
382
383 #[test]
384 fn clear_after_take_all_drops_the_retained_aggregates() {
385 let g = committed(&["a"], &[]);
388 let mut p = PreviewLayer::new();
389 let gen = p.begin("a".into());
390 p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
391 p.take_all();
392 p.clear();
393
394 assert!(p.aggregates().is_empty());
395 assert_eq!(p.anchor(), None);
396 }
397
398 #[test]
399 fn fill_after_take_all_is_rejected() {
400 let g = committed(&["a"], &[]);
403 let mut p = PreviewLayer::new();
404 let gen = p.begin("a".into());
405 p.fill(gen, &result(&["b"], &[]), &g);
406 p.take_all();
407
408 assert!(!p.fill(gen, &result(&["c"], &[]), &g));
409 assert!(p.is_empty(), "no aggregates were ever filled, so nothing is retained here");
410 assert_eq!(
411 p.anchor(),
412 Some(&"a".to_string()),
413 "the anchor survives a commit — it only dies with the next begin/supersede/clear"
414 );
415 }
416
417 #[test]
418 fn fill_after_promote_is_still_accepted() {
419 let g = committed(&["a"], &[]);
422 let mut p = PreviewLayer::new();
423 let gen = p.begin("a".into());
424 p.fill(gen, &result(&["b"], &[]), &g);
425 p.promote("b", &g);
426
427 assert!(p.fill(gen, &result(&["c"], &[]), &g));
428 assert!(p.contains("c"));
429 }
430
431 #[test]
432 fn promote_takes_a_self_loop_on_the_promoted_node() {
433 let g = committed(&["a"], &[]);
434 let mut p = PreviewLayer::new();
435 let gen = p.begin("a".into());
436 p.fill(gen, &result(&["b"], &[("loop", "b", "b")]), &g);
437
438 let (_, edges) = p.promote("b", &g).expect("b is provisional");
439 let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
440 assert_eq!(ids, vec!["loop"], "a self-loop travels with its node");
441 }
442
443 #[test]
444 fn promote_leaves_non_incident_edges_untouched() {
445 let g = committed(&["a"], &[]);
447 let mut p = PreviewLayer::new();
448 let gen = p.begin("a".into());
449 p.fill(gen, &result(&["b", "c", "d"], &[("cd", "c", "d")]), &g);
450
451 let (_, taken) = p.promote("b", &g).expect("b is provisional");
452 assert!(taken.is_empty(), "b has no incident edges");
453 assert_eq!(p.edges().len(), 1, "c-d is untouched by promoting b");
454 }
455
456 #[test]
457 fn clear_retires_the_generation_so_a_late_arrival_cannot_refill_it() {
458 let g = committed(&["a"], &[]);
461 let mut p = PreviewLayer::new();
462 let gen = p.begin("a".into());
463 p.clear();
464
465 assert!(!p.fill(gen, &result(&["b"], &[]), &g));
466 assert!(p.is_empty());
467 assert_eq!(p.anchor(), None);
468 }
469
470 #[test]
471 fn contains_reports_membership_of_the_provisional_set() {
472 let g = committed(&["a"], &[]);
473 let mut p = PreviewLayer::new();
474 let gen = p.begin("a".into());
475 p.fill(gen, &result(&["b"], &[]), &g);
476 assert!(p.contains("b"));
477 assert!(!p.contains("a"), "the anchor is committed, not provisional");
478 }
479
480 #[test]
481 fn fill_retains_aggregates_and_stamps_their_parent() {
482 let g = committed(&["a"], &[]);
485 let mut p = PreviewLayer::new();
486 let gen = p.begin("a".into());
487 p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
488
489 assert_eq!(p.aggregates().len(), 1);
490 assert_eq!(p.aggregates()[0].parent, "a", "stamped from the anchor");
491 assert_eq!(p.aggregates()[0].count, 32);
492 }
493
494 #[test]
495 fn fill_overwrites_a_wire_supplied_parent_with_the_anchor() {
496 let g = committed(&["a"], &[]);
500 let mut p = PreviewLayer::new();
501 let gen = p.begin("a".into());
502 let res = NeighborResult {
503 nodes: vec![],
504 edges: vec![],
505 aggregates: vec![Aggregate {
506 id: "agg:Sorcery".into(),
507 parent: "somewhere-else".into(),
508 group_by: GroupBy::Label,
509 value: "Sorcery".into(),
510 relationships: vec![],
511 count: 32,
512 query: QueryParams { limit: 8, cursor: None },
513 }],
514 next: None,
515 pending: false,
516 };
517 p.fill(gen, &res, &g);
518
519 assert_eq!(p.aggregates()[0].parent, "a", "the anchor overwrites whatever the wire sent");
520 }
521
522 #[test]
523 fn fill_keeps_aggregates_whole_even_if_their_id_collides_with_a_committed_node() {
524 let g = committed(&["a", "agg:Sorcery"], &[]);
528 let mut p = PreviewLayer::new();
529 let gen = p.begin("a".into());
530 p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
531
532 assert_eq!(p.aggregates().len(), 1, "kept whole, not filtered like a node");
533 }
534
535 #[test]
536 fn an_aggregate_only_neighborhood_is_not_empty() {
537 let g = committed(&["a"], &[]);
540 let mut p = PreviewLayer::new();
541 let gen = p.begin("a".into());
542 p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
543
544 assert_eq!(p.node_count(), 0, "no individual nodes");
545 assert!(!p.is_empty(), "but the layer is NOT empty — there is something to report");
546 }
547
548 #[test]
549 fn begin_and_clear_drop_aggregates_but_take_all_does_not() {
550 let g = committed(&["a"], &[]);
551 let mut p = PreviewLayer::new();
552 let gen = p.begin("a".into());
553 p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
554 assert_eq!(p.aggregates().len(), 1);
555
556 p.begin("b".into());
557 assert!(p.aggregates().is_empty(), "a new preview starts clean");
558
559 let gen2 = p.begin("a".into());
560 p.fill(gen2, &result_with_agg(&["c"], &[("agg:X", "X", 3)]), &g);
561 p.take_all();
562 assert_eq!(
563 p.aggregates().len(),
564 1,
565 "committing the ghosts retains the aggregate offers — they die with the preview, not the commit"
566 );
567
568 let gen3 = p.begin("a".into());
569 p.fill(gen3, &result_with_agg(&["d"], &[("agg:X", "X", 3)]), &g);
570 p.clear();
571 assert!(p.aggregates().is_empty(), "discarding drops them");
572 }
573
574 #[test]
575 fn has_committable_distinguishes_reportable_aggregates_from_committable_content() {
576 let g = committed(&["a"], &[]);
577
578 let mut p = PreviewLayer::new();
580 let gen = p.begin("a".into());
581 p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
582 assert!(!p.is_empty(), "an aggregate is reportable");
583 assert!(!p.has_committable(), "but an aggregate is never committed into the Graph");
584
585 let mut p = PreviewLayer::new();
587 let gen = p.begin("a".into());
588 p.fill(gen, &result(&["b"], &[]), &g);
589 assert!(!p.is_empty());
590 assert!(p.has_committable(), "a provisional node is committable");
591
592 let g2 = committed(&["a", "b"], &[]);
594 let mut p = PreviewLayer::new();
595 let gen = p.begin("a".into());
596 p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g2);
597 assert!(!p.is_empty());
598 assert!(p.has_committable(), "a provisional edge is committable");
599 }
600
601 #[test]
602 fn a_stale_fill_does_not_leak_aggregates() {
603 let g = committed(&["a"], &[]);
604 let mut p = PreviewLayer::new();
605 let stale = p.begin("a".into());
606 p.begin("b".into());
607 assert!(!p.fill(stale, &result_with_agg(&[], &[("agg:X", "X", 9)]), &g));
608 assert!(p.aggregates().is_empty());
609 }
610}