1use std::collections::{HashMap, HashSet};
10
11use chrono::{DateTime, Utc};
12
13use crate::ids::CommentId;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ThreadPosition {
18 pub index: usize,
20 pub depth: u32,
22 pub detached: bool,
26}
27
28pub fn thread_order<T>(
35 items: &[T],
36 key: impl Fn(&T) -> (CommentId, Option<CommentId>, Option<DateTime<Utc>>),
37) -> Vec<ThreadPosition> {
38 let keys: Vec<_> = items.iter().map(&key).collect();
39 let present: HashSet<CommentId> =
40 keys.iter().map(|(id, _, _)| *id).collect();
41 let mut children: HashMap<CommentId, Vec<usize>> = HashMap::new();
42 let mut roots: Vec<usize> = Vec::new();
43 for (i, (_, parent, _)) in keys.iter().enumerate() {
44 match parent.filter(|p| present.contains(p)) {
45 Some(p) => children.entry(p).or_default().push(i),
46 None => roots.push(i),
47 }
48 }
49 let by_date = |v: &mut Vec<usize>| v.sort_by_key(|&i| keys[i].2);
50 by_date(&mut roots);
51 children.values_mut().for_each(by_date);
52
53 let mut out = Vec::with_capacity(items.len());
54 let mut stack: Vec<(usize, u32)> =
56 roots.iter().rev().map(|&i| (i, 0)).collect();
57 let mut seen = HashSet::with_capacity(items.len());
58 while let Some((i, depth)) = stack.pop() {
59 if !seen.insert(i) {
60 continue;
61 }
62 out.push(ThreadPosition {
63 index: i,
64 depth,
65 detached: depth == 0 && keys[i].1.is_some(),
66 });
67 if let Some(kids) = children.get(&keys[i].0) {
68 stack.extend(kids.iter().rev().map(|&k| (k, depth + 1)));
69 }
70 }
71 for (i, (_, parent, _)) in keys.iter().enumerate() {
74 if seen.insert(i) {
75 out.push(ThreadPosition {
76 index: i,
77 depth: 0,
78 detached: parent.is_some(),
79 });
80 }
81 }
82 out
83}
84
85pub const STUB_LINE_BYTES: usize = 150;
95
96pub const COMMENT_LINE_BYTES: usize = 150;
99
100pub const DETACHED_NOTE_BYTES: usize = 80;
103
104pub const STUB_LINE_JSON_BYTES: usize = 240;
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(String::new()),
216 preview: String::new(),
217 reply_count: 9_999_999,
218 created_at: Some(now),
219 bytes: Some(99_999_999),
220 };
221 let stub_json = serde_json::to_string(&stub).unwrap().len();
222 let comment = CommentResponse {
223 id: id(u128::MAX),
224 post_id: PostId::from(Uuid::from_u128(u128::MAX)),
225 parent_comment_id: Some(id(u128::MAX)),
226 agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
227 agent_name: Some(String::new()),
228 body: String::new(),
229 created_at: Some(now),
230 deleted: false,
231 removed_by: None,
232 signed: Some(false),
233 via: Some(crate::enums::ClientPlatform::ALL[0]),
234 };
235 let comment_json = serde_json::to_string(&comment).unwrap().len();
236 assert!(
237 stub_json + 2 <= STUB_LINE_JSON_BYTES,
238 "stub JSON is {stub_json}"
239 );
240 assert!(
241 comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
242 "comment JSON is {comment_json}"
243 );
244 }
245
246 #[test]
247 fn previews_end_at_a_word_and_say_so() {
248 assert_eq!(preview("short", 40), "short");
249 assert_eq!(preview("one two\n\nthree", 40), "one two three");
250 let p = preview("the quick brown fox jumps over the lazy dog", 20);
251 assert_eq!(p, "the quick brown fox…");
252 assert!(p.chars().count() <= 20);
253 assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
255 assert_eq!(preview("anything", 0), "");
256 }
257}