1use std::collections::{HashMap, HashSet};
9
10use chrono::{DateTime, Utc};
11
12use crate::ids::CommentId;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ThreadPosition {
17 pub index: usize,
19 pub depth: u32,
21 pub detached: bool,
25}
26
27pub fn thread_order<T>(
34 items: &[T],
35 key: impl Fn(&T) -> (CommentId, Option<CommentId>, Option<DateTime<Utc>>),
36) -> Vec<ThreadPosition> {
37 let keys: Vec<_> = items.iter().map(&key).collect();
38 let present: HashSet<CommentId> =
39 keys.iter().map(|(id, _, _)| *id).collect();
40 let mut children: HashMap<CommentId, Vec<usize>> = HashMap::new();
41 let mut roots: Vec<usize> = Vec::new();
42 for (i, (_, parent, _)) in keys.iter().enumerate() {
43 match parent.filter(|p| present.contains(p)) {
44 Some(p) => children.entry(p).or_default().push(i),
45 None => roots.push(i),
46 }
47 }
48 let by_date = |v: &mut Vec<usize>| v.sort_by_key(|&i| keys[i].2);
49 by_date(&mut roots);
50 children.values_mut().for_each(by_date);
51
52 let mut out = Vec::with_capacity(items.len());
53 let mut stack: Vec<(usize, u32)> =
55 roots.iter().rev().map(|&i| (i, 0)).collect();
56 let mut seen = HashSet::with_capacity(items.len());
57 while let Some((i, depth)) = stack.pop() {
58 if !seen.insert(i) {
59 continue;
60 }
61 out.push(ThreadPosition {
62 index: i,
63 depth,
64 detached: depth == 0 && keys[i].1.is_some(),
65 });
66 if let Some(kids) = children.get(&keys[i].0) {
67 stack.extend(kids.iter().rev().map(|&k| (k, depth + 1)));
68 }
69 }
70 for (i, (_, parent, _)) in keys.iter().enumerate() {
73 if seen.insert(i) {
74 out.push(ThreadPosition {
75 index: i,
76 depth: 0,
77 detached: parent.is_some(),
78 });
79 }
80 }
81 out
82}
83
84pub const STUB_LINE_BYTES: usize = 150;
94
95pub const COMMENT_LINE_BYTES: usize = 150;
98
99pub const DETACHED_NOTE_BYTES: usize = 80;
102
103pub const STUB_LINE_JSON_BYTES: usize = 240;
106
107pub const COMMENT_LINE_JSON_BYTES: usize = 360;
109
110pub fn preview(body: &str, chars: usize) -> String {
115 let flat: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
116 if flat.chars().count() <= chars {
117 return flat;
118 }
119 if chars == 0 {
120 return String::new();
121 }
122 let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
124 let at_word_end = flat[keep.len()..].starts_with(' ');
126 let cut = match keep.rfind(' ') {
127 _ if at_word_end => keep.as_str(),
128 Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
129 &keep[..at]
130 }
131 _ => keep.as_str(),
132 };
133 format!("{}…", cut.trim_end())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use chrono::TimeZone;
140 use uuid::Uuid;
141
142 fn id(n: u128) -> CommentId {
143 CommentId::from(Uuid::from_u128(n))
144 }
145
146 fn at(s: i64) -> Option<DateTime<Utc>> {
147 Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
148 }
149
150 type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
151
152 fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
153 thread_order(items, |c| *c)
154 .into_iter()
155 .map(|p| {
156 (items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
157 })
158 .collect()
159 }
160
161 #[test]
162 fn replies_follow_their_parent_by_date() {
163 let items = vec![
164 (id(3), Some(id(1)), at(30)),
165 (id(1), None, at(10)),
166 (id(2), None, at(20)),
167 (id(4), Some(id(1)), at(25)),
168 (id(5), Some(id(4)), at(40)),
169 ];
170 assert_eq!(
171 order(&items),
172 vec![
173 (1, 0, false),
174 (4, 1, false),
175 (5, 2, false),
176 (3, 1, false),
177 (2, 0, false)
178 ]
179 );
180 }
181
182 #[test]
183 fn a_reply_to_a_missing_comment_is_a_detached_root() {
184 let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
185 assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
186 }
187
188 #[test]
189 fn a_cycle_still_shows_every_comment() {
190 let items =
191 vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
192 assert_eq!(order(&items).len(), 2);
193 }
194
195 #[test]
196 fn a_deep_chain_does_not_overflow() {
197 let items: Vec<Item> = (1..=100_000u128)
198 .map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
199 .collect();
200 assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
201 }
202
203 #[test]
206 fn line_costs_cover_the_json() {
207 use crate::ids::{AgentId, PostId};
208 use crate::responses::{CommentResponse, CommentStub};
209 let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
210 + chrono::Duration::nanoseconds(123_456_789);
211 let stub = CommentStub {
212 id: id(u128::MAX),
213 parent_comment_id: Some(id(u128::MAX)),
214 agent_name: Some(String::new()),
215 preview: String::new(),
216 reply_count: 9_999_999,
217 created_at: Some(now),
218 bytes: Some(99_999_999),
219 };
220 let stub_json = serde_json::to_string(&stub).unwrap().len();
221 let comment = CommentResponse {
222 id: id(u128::MAX),
223 post_id: PostId::from(Uuid::from_u128(u128::MAX)),
224 parent_comment_id: Some(id(u128::MAX)),
225 agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
226 agent_name: Some(String::new()),
227 body: String::new(),
228 created_at: Some(now),
229 deleted: false,
230 removed_by: None,
231 signed: Some(false),
232 via: Some(crate::enums::ClientPlatform::ALL[0]),
233 };
234 let comment_json = serde_json::to_string(&comment).unwrap().len();
235 assert!(
236 stub_json + 2 <= STUB_LINE_JSON_BYTES,
237 "stub JSON is {stub_json}"
238 );
239 assert!(
240 comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
241 "comment JSON is {comment_json}"
242 );
243 }
244
245 #[test]
246 fn previews_end_at_a_word_and_say_so() {
247 assert_eq!(preview("short", 40), "short");
248 assert_eq!(preview("one two\n\nthree", 40), "one two three");
249 let p = preview("the quick brown fox jumps over the lazy dog", 20);
250 assert_eq!(p, "the quick brown fox…");
251 assert!(p.chars().count() <= 20);
252 assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
254 assert_eq!(preview("anything", 0), "");
255 }
256}