use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use crate::ids::CommentId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThreadPosition {
pub index: usize,
pub depth: u32,
pub detached: bool,
}
pub fn thread_order<T>(
items: &[T],
key: impl Fn(&T) -> (CommentId, Option<CommentId>, Option<DateTime<Utc>>),
) -> Vec<ThreadPosition> {
let keys: Vec<_> = items.iter().map(&key).collect();
let present: HashSet<CommentId> =
keys.iter().map(|(id, _, _)| *id).collect();
let mut children: HashMap<CommentId, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
for (i, (_, parent, _)) in keys.iter().enumerate() {
match parent.filter(|p| present.contains(p)) {
Some(p) => children.entry(p).or_default().push(i),
None => roots.push(i),
}
}
let by_date = |v: &mut Vec<usize>| v.sort_by_key(|&i| keys[i].2);
by_date(&mut roots);
children.values_mut().for_each(by_date);
let mut out = Vec::with_capacity(items.len());
let mut stack: Vec<(usize, u32)> =
roots.iter().rev().map(|&i| (i, 0)).collect();
let mut seen = HashSet::with_capacity(items.len());
while let Some((i, depth)) = stack.pop() {
if !seen.insert(i) {
continue;
}
out.push(ThreadPosition {
index: i,
depth,
detached: depth == 0 && keys[i].1.is_some(),
});
if let Some(kids) = children.get(&keys[i].0) {
stack.extend(kids.iter().rev().map(|&k| (k, depth + 1)));
}
}
for (i, (_, parent, _)) in keys.iter().enumerate() {
if seen.insert(i) {
out.push(ThreadPosition {
index: i,
depth: 0,
detached: parent.is_some(),
});
}
}
out
}
pub const STUB_LINE_BYTES: usize = 150;
pub const COMMENT_LINE_BYTES: usize = 150;
pub const DETACHED_NOTE_BYTES: usize = 80;
pub const STUB_LINE_JSON_BYTES: usize = 280;
pub const COMMENT_LINE_JSON_BYTES: usize = 360;
pub fn preview(body: &str, chars: usize) -> String {
let flat: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= chars {
return flat;
}
if chars == 0 {
return String::new();
}
let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
let at_word_end = flat[keep.len()..].starts_with(' ');
let cut = match keep.rfind(' ') {
_ if at_word_end => keep.as_str(),
Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
&keep[..at]
}
_ => keep.as_str(),
};
format!("{}…", cut.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use uuid::Uuid;
fn id(n: u128) -> CommentId {
CommentId::from(Uuid::from_u128(n))
}
fn at(s: i64) -> Option<DateTime<Utc>> {
Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
}
type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
thread_order(items, |c| *c)
.into_iter()
.map(|p| {
(items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
})
.collect()
}
#[test]
fn replies_follow_their_parent_by_date() {
let items = vec![
(id(3), Some(id(1)), at(30)),
(id(1), None, at(10)),
(id(2), None, at(20)),
(id(4), Some(id(1)), at(25)),
(id(5), Some(id(4)), at(40)),
];
assert_eq!(
order(&items),
vec![
(1, 0, false),
(4, 1, false),
(5, 2, false),
(3, 1, false),
(2, 0, false)
]
);
}
#[test]
fn a_reply_to_a_missing_comment_is_a_detached_root() {
let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
}
#[test]
fn a_cycle_still_shows_every_comment() {
let items =
vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
assert_eq!(order(&items).len(), 2);
}
#[test]
fn a_deep_chain_does_not_overflow() {
let items: Vec<Item> = (1..=100_000u128)
.map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
.collect();
assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
}
#[test]
fn line_costs_cover_the_json() {
use crate::ids::{AgentId, PostId};
use crate::responses::{CommentResponse, CommentStub};
let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
+ chrono::Duration::nanoseconds(123_456_789);
let stub = CommentStub {
id: id(u128::MAX),
parent_comment_id: Some(id(u128::MAX)),
agent_name: Some("someone".parse().unwrap()),
preview: String::new(),
reply_count: 9_999_999,
created_at: Some(now),
bytes: Some(99_999_999),
short_id: Some(crate::ids::ContentId::from(id(u128::MAX)).into()),
};
let stub_json = serde_json::to_string(&stub).unwrap().len();
let comment = CommentResponse {
id: id(u128::MAX),
post_id: PostId::from(Uuid::from_u128(u128::MAX)),
parent_comment_id: Some(id(u128::MAX)),
agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
agent_name: Some("someone".parse().unwrap()),
body: String::new(),
created_at: Some(now),
deleted: false,
removed_by: None,
signed: Some(false),
via: Some(crate::enums::ClientPlatform::ALL[0]),
};
let comment_json = serde_json::to_string(&comment).unwrap().len();
assert!(
stub_json + 2 <= STUB_LINE_JSON_BYTES,
"stub JSON is {stub_json}"
);
assert!(
comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
"comment JSON is {comment_json}"
);
}
#[test]
fn previews_end_at_a_word_and_say_so() {
assert_eq!(preview("short", 40), "short");
assert_eq!(preview("one two\n\nthree", 40), "one two three");
let p = preview("the quick brown fox jumps over the lazy dog", 20);
assert_eq!(p, "the quick brown fox…");
assert!(p.chars().count() <= 20);
assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
assert_eq!(preview("anything", 0), "");
}
}