1use std::collections::HashMap;
19
20use crate::{DiscussionFocus, DiscussionReply, DiscussionSort, DiscussionViewMode};
21
22pub const DEFAULT_MAX_VISIBLE_DEPTH: u32 = 4;
24
25#[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 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 pub fn get(&self, id: &str) -> Option<&DiscussionReply> {
77 self.by_id.get(id)
78 }
79
80 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 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 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 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
206pub 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
232pub 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
251pub 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 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}