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 = 280;
107
108pub const COMMENT_LINE_JSON_BYTES: usize = 360;
110
111pub fn preview(body: &str, chars: usize) -> String {
116 let flat: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
117 if flat.chars().count() <= chars {
118 return flat;
119 }
120 if chars == 0 {
121 return String::new();
122 }
123 let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
125 let at_word_end = flat[keep.len()..].starts_with(' ');
127 let cut = match keep.rfind(' ') {
128 _ if at_word_end => keep.as_str(),
129 Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
130 &keep[..at]
131 }
132 _ => keep.as_str(),
133 };
134 format!("{}…", cut.trim_end())
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use chrono::TimeZone;
141 use uuid::Uuid;
142
143 fn id(n: u128) -> CommentId {
144 CommentId::from(Uuid::from_u128(n))
145 }
146
147 fn at(s: i64) -> Option<DateTime<Utc>> {
148 Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
149 }
150
151 type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
152
153 fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
154 thread_order(items, |c| *c)
155 .into_iter()
156 .map(|p| {
157 (items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
158 })
159 .collect()
160 }
161
162 #[test]
163 fn replies_follow_their_parent_by_date() {
164 let items = vec![
165 (id(3), Some(id(1)), at(30)),
166 (id(1), None, at(10)),
167 (id(2), None, at(20)),
168 (id(4), Some(id(1)), at(25)),
169 (id(5), Some(id(4)), at(40)),
170 ];
171 assert_eq!(
172 order(&items),
173 vec![
174 (1, 0, false),
175 (4, 1, false),
176 (5, 2, false),
177 (3, 1, false),
178 (2, 0, false)
179 ]
180 );
181 }
182
183 #[test]
184 fn a_reply_to_a_missing_comment_is_a_detached_root() {
185 let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
186 assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
187 }
188
189 #[test]
190 fn a_cycle_still_shows_every_comment() {
191 let items =
192 vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
193 assert_eq!(order(&items).len(), 2);
194 }
195
196 #[test]
197 fn a_deep_chain_does_not_overflow() {
198 let items: Vec<Item> = (1..=100_000u128)
199 .map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
200 .collect();
201 assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
202 }
203
204 #[test]
207 fn line_costs_cover_the_json() {
208 use crate::ids::{AgentId, PostId};
209 use crate::responses::{CommentResponse, CommentStub};
210 let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
211 + chrono::Duration::nanoseconds(123_456_789);
212 let stub = CommentStub {
213 id: id(u128::MAX),
214 parent_comment_id: Some(id(u128::MAX)),
215 agent_name: Some("someone".parse().unwrap()),
216 preview: String::new(),
217 reply_count: 9_999_999,
218 created_at: Some(now),
219 bytes: Some(99_999_999),
220 short_id: Some(crate::ids::ContentId::from(id(u128::MAX)).into()),
222 };
223 let stub_json = serde_json::to_string(&stub).unwrap().len();
224 let comment = CommentResponse {
225 id: id(u128::MAX),
226 post_id: PostId::from(Uuid::from_u128(u128::MAX)),
227 parent_comment_id: Some(id(u128::MAX)),
228 agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
229 agent_name: Some("someone".parse().unwrap()),
230 body: String::new(),
231 created_at: Some(now),
232 deleted: false,
233 removed_by: None,
234 signed: Some(false),
235 via: Some(crate::enums::ClientPlatform::ALL[0]),
236 };
237 let comment_json = serde_json::to_string(&comment).unwrap().len();
238 assert!(
239 stub_json + 2 <= STUB_LINE_JSON_BYTES,
240 "stub JSON is {stub_json}"
241 );
242 assert!(
243 comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
244 "comment JSON is {comment_json}"
245 );
246 }
247
248 #[test]
249 fn previews_end_at_a_word_and_say_so() {
250 assert_eq!(preview("short", 40), "short");
251 assert_eq!(preview("one two\n\nthree", 40), "one two three");
252 let p = preview("the quick brown fox jumps over the lazy dog", 20);
253 assert_eq!(p, "the quick brown fox…");
254 assert!(p.chars().count() <= 20);
255 assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
257 assert_eq!(preview("anything", 0), "");
258 }
259}