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