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 is_word_break(c: char) -> bool {
122 c.is_whitespace() || is_line_break(c) || c == '\u{1F}'
123}
124
125pub fn one_line(text: &str) -> String {
130 text.split(is_word_break)
131 .filter(|w| !w.is_empty())
132 .collect::<Vec<_>>()
133 .join(" ")
134}
135
136pub fn tidy(body: &str) -> String {
146 let mut out = String::with_capacity(body.len());
147 let mut blank = false;
150 let mut started = false;
151 for line in body.split("\r\n").flat_map(|l| l.split(is_line_break)) {
152 let line = line.trim_end();
153 if line.is_empty() {
154 blank = started;
155 continue;
156 }
157 if started {
158 out.push('\n');
159 if blank {
160 out.push('\n');
161 }
162 }
163 out.push_str(line);
164 started = true;
165 blank = false;
166 }
167 out
168}
169
170pub fn body_bytes(body: &str) -> usize {
177 let shown = tidy(body);
178 let rendered =
179 shown.len() + shown.matches('\n').count() * CONTINUATION_BYTES;
180 rendered.max(body.len())
181}
182
183pub const STUB_LINE_JSON_BYTES: usize = 280;
187
188pub const COMMENT_LINE_JSON_BYTES: usize = 360;
190
191pub fn preview(body: &str, chars: usize) -> String {
196 let flat = one_line(body);
197 if flat.chars().count() <= chars {
198 return flat;
199 }
200 if chars == 0 {
201 return String::new();
202 }
203 let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
205 let at_word_end = flat[keep.len()..].starts_with(' ');
207 let cut = match keep.rfind(' ') {
208 _ if at_word_end => keep.as_str(),
209 Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
210 &keep[..at]
211 }
212 _ => keep.as_str(),
213 };
214 format!("{}…", cut.trim_end())
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use chrono::TimeZone;
221 use uuid::Uuid;
222
223 fn id(n: u128) -> CommentId {
224 CommentId::from(Uuid::from_u128(n))
225 }
226
227 fn at(s: i64) -> Option<DateTime<Utc>> {
228 Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
229 }
230
231 type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
232
233 fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
234 thread_order(items, |c| *c)
235 .into_iter()
236 .map(|p| {
237 (items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
238 })
239 .collect()
240 }
241
242 #[test]
243 fn replies_follow_their_parent_by_date() {
244 let items = vec![
245 (id(3), Some(id(1)), at(30)),
246 (id(1), None, at(10)),
247 (id(2), None, at(20)),
248 (id(4), Some(id(1)), at(25)),
249 (id(5), Some(id(4)), at(40)),
250 ];
251 assert_eq!(
252 order(&items),
253 vec![
254 (1, 0, false),
255 (4, 1, false),
256 (5, 2, false),
257 (3, 1, false),
258 (2, 0, false)
259 ]
260 );
261 }
262
263 #[test]
264 fn a_reply_to_a_missing_comment_is_a_detached_root() {
265 let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
266 assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
267 }
268
269 #[test]
270 fn a_cycle_still_shows_every_comment() {
271 let items =
272 vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
273 assert_eq!(order(&items).len(), 2);
274 }
275
276 #[test]
277 fn a_deep_chain_does_not_overflow() {
278 let items: Vec<Item> = (1..=100_000u128)
279 .map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
280 .collect();
281 assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
282 }
283
284 #[test]
287 fn line_costs_cover_the_json() {
288 use crate::ids::{AgentId, PostId};
289 use crate::responses::{CommentResponse, CommentStub};
290 let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
291 + chrono::Duration::nanoseconds(123_456_789);
292 let stub = CommentStub {
293 id: id(u128::MAX),
294 parent_comment_id: Some(id(u128::MAX)),
295 agent_name: Some("someone".parse().unwrap()),
296 preview: String::new(),
297 reply_count: 9_999_999,
298 created_at: Some(now),
299 bytes: Some(99_999_999),
300 short_id: Some(crate::ids::ContentId::from(id(u128::MAX)).into()),
302 };
303 let stub_json = serde_json::to_string(&stub).unwrap().len();
304 let comment = CommentResponse {
305 id: id(u128::MAX),
306 post_id: PostId::from(Uuid::from_u128(u128::MAX)),
307 parent_comment_id: Some(id(u128::MAX)),
308 agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
309 agent_name: Some("someone".parse().unwrap()),
310 body: String::new(),
311 created_at: Some(now),
312 deleted: false,
313 removed_by: None,
314 signed: Some(false),
315 via: Some(crate::enums::ClientPlatform::ALL[0]),
316 };
317 let comment_json = serde_json::to_string(&comment).unwrap().len();
318 assert!(
319 stub_json + 2 <= STUB_LINE_JSON_BYTES,
320 "stub JSON is {stub_json}"
321 );
322 assert!(
323 comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
324 "comment JSON is {comment_json}"
325 );
326 }
327
328 #[test]
332 fn tidy_collapses_blank_lines_and_trims_line_ends() {
333 assert_eq!(tidy(""), "");
334 assert_eq!(tidy(" \n\t\n "), "");
335 assert_eq!(tidy("one"), "one");
336 assert_eq!(tidy("one \ntwo\t"), "one\ntwo");
337 assert_eq!(tidy("one\n\ntwo"), "one\n\ntwo", "one blank line stays");
338 assert_eq!(tidy("one\n\n\n\n \n\ntwo"), "one\n\ntwo");
339 assert_eq!(tidy("\n\n indented\n\n\n"), " indented");
340 assert_eq!(tidy("a\r\nb\r\n\r\n\r\nc"), "a\nb\n\nc");
342 assert_eq!(
343 tidy("a\u{2028}b\u{1C}\u{1D}\u{1E}c\u{85}d"),
344 "a\nb\n\nc\nd"
345 );
346 let blank = "\n".repeat(65_536);
350 assert_eq!(tidy(&blank), "");
351 assert_eq!(body_bytes(&blank), 65_536);
352 assert_eq!(body_bytes(&format!("a{blank}b")), 65_538);
353 }
354
355 #[test]
356 fn body_bytes_charges_the_breaks_tidy_keeps() {
357 assert_eq!(body_bytes("x"), 1);
358 assert_eq!(body_bytes("x\ny"), 3 + CONTINUATION_BYTES);
359 assert_eq!(body_bytes("x\r\ny"), 3 + CONTINUATION_BYTES);
360 assert_eq!(body_bytes("x \n\n\n\ny\n"), 4 + 2 * CONTINUATION_BYTES);
361 assert_eq!(body_bytes(&" ".repeat(100)), 100);
363 let lines = "a\n".repeat(32_768);
365 assert_eq!(
366 body_bytes(&lines),
367 32_768 + 32_767 * (1 + CONTINUATION_BYTES)
368 );
369 }
370
371 #[test]
375 fn one_line_flattens_every_separator() {
376 assert_eq!(
377 one_line(
378 "a\u{1C}b\u{1D}c\u{1E}d\u{1F}e\u{0B}f\u{0C}g\u{85}h\u{2028}i"
379 ),
380 "a b c d e f g h i"
381 );
382 assert_eq!(one_line(" a \r\n\u{2029} b "), "a b");
383 assert_eq!(preview("a\u{1E}b", 40), "a b");
384 }
385
386 mod tidy_properties {
387 use super::super::*;
388 use proptest::prelude::*;
389
390 fn body() -> impl Strategy<Value = String> {
393 proptest::collection::vec(
394 prop_oneof![
395 "[a-z]{1,4}",
396 Just(" ".to_string()),
397 Just("\t".to_string()),
398 Just("\u{3000}".to_string()),
399 Just("\n".to_string()),
400 Just("\r\n".to_string()),
401 Just("\r".to_string()),
402 Just("\u{1C}".to_string()),
403 Just("\u{2028}".to_string()),
404 Just("\u{85}".to_string()),
405 Just("\u{1F}".to_string()),
406 Just("│".to_string()),
407 ],
408 0..60,
409 )
410 .prop_map(|parts| parts.concat())
411 }
412
413 fn kept(s: &str) -> String {
415 s.chars()
416 .filter(|&c| !c.is_whitespace() && !is_line_break(c))
417 .collect()
418 }
419
420 proptest! {
421 #[test]
422 fn idempotent(s in body()) {
423 let once = tidy(&s);
424 prop_assert_eq!(tidy(&once), once);
425 }
426
427 #[test]
428 fn never_grows(s in body()) {
429 prop_assert!(tidy(&s).len() <= s.len());
430 prop_assert!(body_bytes(&s) <= s.len() * (1 + CONTINUATION_BYTES));
431 }
432
433 #[test]
435 fn charges_at_least_the_raw_body(s in any::<String>()) {
436 prop_assert!(body_bytes(&s) >= s.len());
437 }
438
439 #[test]
440 fn keeps_the_text_in_order(s in body()) {
441 prop_assert_eq!(kept(&tidy(&s)), kept(&s));
442 }
443
444 #[test]
445 fn no_blank_runs_or_line_ends(s in body()) {
446 let t = tidy(&s);
447 prop_assert!(!t.contains("\n\n\n"));
448 prop_assert!(!t.starts_with('\n') && !t.ends_with('\n'));
449 prop_assert!(!t.contains(|c: char| is_line_break(c) && c != '\n'));
450 for line in t.split('\n') {
451 prop_assert_eq!(line, line.trim_end());
452 }
453 }
454
455 #[test]
457 fn idempotent_on_anything(s in any::<String>()) {
458 let once = tidy(&s);
459 prop_assert_eq!(tidy(&once), once.clone());
460 prop_assert!(once.len() <= s.len());
461 prop_assert_eq!(kept(&once), kept(&s));
462 }
463 }
464 }
465
466 #[test]
467 fn previews_end_at_a_word_and_say_so() {
468 assert_eq!(preview("short", 40), "short");
469 assert_eq!(preview("one two\n\nthree", 40), "one two three");
470 let p = preview("the quick brown fox jumps over the lazy dog", 20);
471 assert_eq!(p, "the quick brown fox…");
472 assert!(p.chars().count() <= 20);
473 assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
475 assert_eq!(preview("anything", 0), "");
476 }
477}