Skip to main content

orbital_discussion/engine/
graph.rs

1//! Reply tree graph engine — adjacency index and visibility projection.
2//!
3//! # Performance
4//!
5//! Build the graph once per reply-list change with a Leptos [`Memo`](leptos::prelude::Memo)
6//! in [`DiscussionThread`](crate::DiscussionThread). The memoized graph is stored on
7//! [`DiscussionContext::graph`](crate::DiscussionContext::graph) for descendant lookups
8//! (composer reply banner, show-more drill-in).
9//!
10//! Avoid calling [`DiscussionReplyGraph::from_flat`] inside derived signals or per-row
11//! render paths — that rebuilds the full index on every subscription tick. Prefer
12//! `ctx.graph.get()` from [`use_discussion()`](crate::use_discussion).
13//!
14//! Reply lists use keyed [`For`](leptos::prelude::For) iteration over stable reply ids.
15//! For very large threads, prefer subscribing to individual reply records in custom
16//! layouts rather than cloning the entire `replies` vector in leaf components.
17
18use std::collections::HashMap;
19
20use crate::{DiscussionFocus, DiscussionReply, DiscussionSort, DiscussionViewMode};
21
22/// Default maximum visible nesting depth in tree mode.
23pub const DEFAULT_MAX_VISIBLE_DEPTH: u32 = 4;
24
25/// Adjacency graph built from a flat reply list.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct DiscussionReplyGraph {
28    by_id: HashMap<String, DiscussionReply>,
29    roots: Vec<String>,
30    children: HashMap<String, Vec<String>>,
31    input_order: Vec<String>,
32}
33
34impl DiscussionReplyGraph {
35    /// Build a reply graph from a flat list, preserving input order for siblings.
36    ///
37    /// Replies whose parent is missing from the list become roots (orphan tolerance).
38    /// Duplicate ids keep the last occurrence.
39    pub fn from_flat(replies: &[DiscussionReply]) -> Self {
40        let mut by_id = HashMap::new();
41        let mut input_order = Vec::with_capacity(replies.len());
42
43        for reply in replies {
44            input_order.push(reply.id.clone());
45            by_id.insert(reply.id.clone(), reply.clone());
46        }
47
48        let mut roots = Vec::new();
49        let mut children: HashMap<String, Vec<String>> = HashMap::new();
50
51        for id in &input_order {
52            let Some(reply) = by_id.get(id) else {
53                continue;
54            };
55            match &reply.parent_id {
56                None => roots.push(id.clone()),
57                Some(parent_id) if by_id.contains_key(parent_id) => {
58                    children
59                        .entry(parent_id.clone())
60                        .or_default()
61                        .push(id.clone());
62                }
63                Some(_) => roots.push(id.clone()),
64            }
65        }
66
67        Self {
68            by_id,
69            roots,
70            children,
71            input_order,
72        }
73    }
74
75    /// Lookup a reply by id.
76    pub fn get(&self, id: &str) -> Option<&DiscussionReply> {
77        self.by_id.get(id)
78    }
79
80    /// Ordered child ids for a parent reply.
81    pub fn child_ids(&self, parent_id: &str) -> &[String] {
82        self.children
83            .get(parent_id)
84            .map(|ids| ids.as_slice())
85            .unwrap_or(&[])
86    }
87
88    /// Project visible replies for the current focus, mode, and sort.
89    pub fn visible_replies(
90        &self,
91        focus: &DiscussionFocus,
92        mode: DiscussionViewMode,
93        sort: DiscussionSort,
94    ) -> Vec<&DiscussionReply> {
95        match mode {
96            DiscussionViewMode::Tree => self.visible_tree(focus, sort),
97            DiscussionViewMode::Flat | DiscussionViewMode::Compact => self.visible_flat(sort),
98        }
99    }
100
101    /// Depth of `id` relative to the current focus anchor (anchor = 0).
102    pub fn depth_in_view(&self, id: &str, focus: &DiscussionFocus) -> u32 {
103        match focus {
104            DiscussionFocus::Root => self.depth_from_roots(id),
105            DiscussionFocus::Branch { anchor_id, .. } => {
106                if id == anchor_id {
107                    return 0;
108                }
109                if !is_descendant_of(self, id, anchor_id) {
110                    return u32::MAX;
111                }
112                self.depth_between(anchor_id, id)
113            }
114        }
115    }
116
117    /// Whether `id` is `ancestor_id` or a descendant of `ancestor_id`.
118    pub fn is_descendant_of(&self, id: &str, ancestor_id: &str) -> bool {
119        is_descendant_of(self, id, ancestor_id)
120    }
121
122    fn visible_tree(&self, focus: &DiscussionFocus, sort: DiscussionSort) -> Vec<&DiscussionReply> {
123        let root_ids = match focus {
124            DiscussionFocus::Root => self.roots.clone(),
125            DiscussionFocus::Branch { anchor_id, .. } => {
126                if self.by_id.contains_key(anchor_id) {
127                    vec![anchor_id.clone()]
128                } else {
129                    return Vec::new();
130                }
131            }
132        };
133
134        self.resolve_ids(&self.apply_sort(&root_ids, sort))
135    }
136
137    fn visible_flat(&self, sort: DiscussionSort) -> Vec<&DiscussionReply> {
138        let ids: Vec<String> = match sort {
139            DiscussionSort::OldestFirst => self.input_order.clone(),
140            DiscussionSort::NewestFirst => self.input_order.iter().rev().cloned().collect(),
141        };
142        self.resolve_ids(&ids)
143    }
144
145    fn resolve_ids<'a>(&'a self, ids: &[String]) -> Vec<&'a DiscussionReply> {
146        ids.iter().filter_map(|id| self.by_id.get(id)).collect()
147    }
148
149    fn apply_sort(&self, ids: &[String], sort: DiscussionSort) -> Vec<String> {
150        match sort {
151            DiscussionSort::OldestFirst => ids.to_vec(),
152            DiscussionSort::NewestFirst => ids.iter().rev().cloned().collect(),
153        }
154    }
155
156    fn depth_from_roots(&self, id: &str) -> u32 {
157        let mut depth = 0;
158        let mut current = id;
159        while let Some(reply) = self.by_id.get(current) {
160            match &reply.parent_id {
161                None => return depth,
162                Some(parent_id) if self.by_id.contains_key(parent_id) => {
163                    depth += 1;
164                    current = parent_id;
165                }
166                Some(_) => return depth,
167            }
168        }
169        depth
170    }
171
172    fn depth_between(&self, ancestor_id: &str, descendant_id: &str) -> u32 {
173        let mut depth = 0;
174        let mut current = descendant_id;
175        while current != ancestor_id {
176            let Some(reply) = self.by_id.get(current) else {
177                return u32::MAX;
178            };
179            match &reply.parent_id {
180                Some(parent_id) if self.by_id.contains_key(parent_id) => {
181                    depth += 1;
182                    current = parent_id;
183                }
184                _ => return u32::MAX,
185            }
186        }
187        depth
188    }
189}
190
191fn is_descendant_of(graph: &DiscussionReplyGraph, id: &str, ancestor_id: &str) -> bool {
192    if id == ancestor_id {
193        return true;
194    }
195    let mut current = id;
196    while let Some(reply) = graph.get(current) {
197        match &reply.parent_id {
198            Some(parent_id) if parent_id == ancestor_id => return true,
199            Some(parent_id) if graph.get(parent_id).is_some() => current = parent_id,
200            _ => return false,
201        }
202    }
203    false
204}
205
206/// Push focus onto a reply, extending the breadcrumb trail.
207pub fn push_focus(
208    current: &DiscussionFocus,
209    reply_id: &str,
210    graph: &DiscussionReplyGraph,
211) -> DiscussionFocus {
212    if graph.get(reply_id).is_none() {
213        return current.clone();
214    }
215
216    match current {
217        DiscussionFocus::Root => DiscussionFocus::Branch {
218            anchor_id: reply_id.to_string(),
219            breadcrumb: vec![reply_id.to_string()],
220        },
221        DiscussionFocus::Branch { breadcrumb, .. } => {
222            let mut next = breadcrumb.clone();
223            next.push(reply_id.to_string());
224            DiscussionFocus::Branch {
225                anchor_id: reply_id.to_string(),
226                breadcrumb: next,
227            }
228        }
229    }
230}
231
232/// Pop one level of focus navigation, or return to root.
233pub fn pop_focus(current: &DiscussionFocus) -> DiscussionFocus {
234    match current {
235        DiscussionFocus::Root => DiscussionFocus::Root,
236        DiscussionFocus::Branch { breadcrumb, .. } => {
237            if breadcrumb.len() <= 1 {
238                DiscussionFocus::Root
239            } else {
240                let next = breadcrumb[..breadcrumb.len() - 1].to_vec();
241                let anchor_id = next.last().cloned().unwrap_or_default();
242                DiscussionFocus::Branch {
243                    anchor_id,
244                    breadcrumb: next,
245                }
246            }
247        }
248    }
249}
250
251/// Whether a reply at the depth cap should show a "more replies" affordance.
252pub fn should_show_more_children(
253    graph: &DiscussionReplyGraph,
254    reply_id: &str,
255    depth_in_view: u32,
256    max_visible_depth: u32,
257) -> bool {
258    // Not visible in the current focus, or a broken parent chain.
259    if depth_in_view == u32::MAX {
260        return false;
261    }
262    if depth_in_view.saturating_add(1) < max_visible_depth {
263        return false;
264    }
265
266    let reply = graph.get(reply_id);
267    let hidden = reply.map(|r| r.hidden_child_count).unwrap_or(0);
268    let has_children = !graph.child_ids(reply_id).is_empty();
269    hidden > 0 || has_children
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::{DiscussionAuthor, DiscussionMetadata, DiscussionPart, DiscussionReplyStatus};
276    use chrono::Utc;
277
278    fn reply(id: &str, parent_id: Option<&str>, text: &str) -> DiscussionReply {
279        DiscussionReply {
280            id: id.to_string(),
281            parent_id: parent_id.map(str::to_string),
282            author: DiscussionAuthor::new(id, id),
283            metadata: DiscussionMetadata {
284                created_at: Utc::now(),
285                edited_at: None,
286                labels: vec![],
287            },
288            parts: vec![DiscussionPart::text(text)],
289            citations: vec![],
290            status: DiscussionReplyStatus::Ready,
291            hidden_child_count: 0,
292        }
293    }
294
295    fn sample_tree() -> Vec<DiscussionReply> {
296        vec![
297            reply("r-root", None, "root"),
298            reply("r-sam", Some("r-root"), "sam"),
299            reply("r-jordan", Some("r-sam"), "jordan"),
300            reply("r-pat", Some("r-jordan"), "pat"),
301            reply("r-morgan", Some("r-root"), "morgan"),
302        ]
303    }
304
305    #[test]
306    fn from_flat_builds_roots_and_children() {
307        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
308        assert_eq!(graph.roots, vec!["r-root".to_string()]);
309        assert_eq!(
310            graph.child_ids("r-root"),
311            &["r-sam".to_string(), "r-morgan".to_string()]
312        );
313        assert_eq!(graph.child_ids("r-sam"), &["r-jordan".to_string()]);
314    }
315
316    #[test]
317    fn from_flat_orphan_becomes_root() {
318        let graph = DiscussionReplyGraph::from_flat(&[reply("orphan", Some("missing"), "orphan")]);
319        assert_eq!(graph.roots, vec!["orphan".to_string()]);
320    }
321
322    #[test]
323    fn visible_replies_tree_root() {
324        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
325        let visible = graph.visible_replies(
326            &DiscussionFocus::Root,
327            DiscussionViewMode::Tree,
328            DiscussionSort::OldestFirst,
329        );
330        assert_eq!(visible.len(), 1);
331        assert_eq!(visible[0].id, "r-root");
332    }
333
334    #[test]
335    fn visible_replies_branch_focus() {
336        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
337        let focus = DiscussionFocus::Branch {
338            anchor_id: "r-sam".to_string(),
339            breadcrumb: vec!["r-sam".to_string()],
340        };
341        let visible = graph.visible_replies(
342            &focus,
343            DiscussionViewMode::Tree,
344            DiscussionSort::OldestFirst,
345        );
346        assert_eq!(visible.len(), 1);
347        assert_eq!(visible[0].id, "r-sam");
348    }
349
350    #[test]
351    fn depth_in_view_from_root() {
352        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
353        assert_eq!(graph.depth_in_view("r-root", &DiscussionFocus::Root), 0);
354        assert_eq!(graph.depth_in_view("r-sam", &DiscussionFocus::Root), 1);
355        assert_eq!(graph.depth_in_view("r-pat", &DiscussionFocus::Root), 3);
356    }
357
358    #[test]
359    fn push_focus_pop_focus() {
360        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
361        let focus = push_focus(&DiscussionFocus::Root, "r-sam", &graph);
362        assert!(matches!(
363            focus,
364            DiscussionFocus::Branch { ref anchor_id, .. } if anchor_id == "r-sam"
365        ));
366        let focus = push_focus(&focus, "r-jordan", &graph);
367        let focus = pop_focus(&focus);
368        assert!(matches!(
369            focus,
370            DiscussionFocus::Branch { ref anchor_id, .. } if anchor_id == "r-sam"
371        ));
372        assert_eq!(pop_focus(&focus), DiscussionFocus::Root);
373    }
374
375    #[test]
376    fn push_focus_drill_in_updates_visible_root() {
377        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
378        let focus = push_focus(&DiscussionFocus::Root, "r-sam", &graph);
379        let focus = push_focus(&focus, "r-jordan", &graph);
380        let visible = graph.visible_replies(
381            &focus,
382            DiscussionViewMode::Tree,
383            DiscussionSort::OldestFirst,
384        );
385        assert_eq!(visible.len(), 1);
386        assert_eq!(visible[0].id, "r-jordan");
387    }
388
389    #[test]
390    fn should_show_more_at_depth_cap() {
391        let mut replies = sample_tree();
392        replies[3].hidden_child_count = 3;
393        let graph = DiscussionReplyGraph::from_flat(&replies);
394        let depth = graph.depth_in_view("r-pat", &DiscussionFocus::Root);
395        assert_eq!(depth, 3);
396        assert!(should_show_more_children(
397            &graph,
398            "r-pat",
399            depth,
400            DEFAULT_MAX_VISIBLE_DEPTH
401        ));
402    }
403
404    #[test]
405    fn should_show_more_returns_false_for_invalid_depth() {
406        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
407        assert!(!should_show_more_children(
408            &graph,
409            "r-morgan",
410            u32::MAX,
411            DEFAULT_MAX_VISIBLE_DEPTH
412        ));
413    }
414
415    #[test]
416    fn visible_flat_returns_all_in_order() {
417        let graph = DiscussionReplyGraph::from_flat(&sample_tree());
418        let visible = graph.visible_replies(
419            &DiscussionFocus::Root,
420            DiscussionViewMode::Flat,
421            DiscussionSort::OldestFirst,
422        );
423        assert_eq!(visible.len(), 5);
424        assert_eq!(visible[0].id, "r-root");
425        assert_eq!(visible[4].id, "r-morgan");
426    }
427}