use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CommentReply {
pub uid: String,
pub author: String,
#[serde(default)]
pub author_initials: String,
pub date: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentComment {
pub start: u32,
pub end: u32,
pub uid: String,
pub author: String,
#[serde(default)]
pub author_initials: String,
pub date: String,
#[serde(default)]
pub resolved: bool,
pub body: String,
#[serde(default)]
pub replies: Vec<CommentReply>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentComments(BTreeMap<String, DocumentComment>);
impl DocumentComments {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, comment: DocumentComment) -> &mut Self {
self.0.insert(comment.uid.clone(), comment);
self
}
pub fn get(&self, uid: &str) -> Option<&DocumentComment> {
self.0.get(uid)
}
pub fn iter(&self) -> impl Iterator<Item = &DocumentComment> {
self.0.values()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn in_document_order(&self) -> Vec<&DocumentComment> {
let mut out: Vec<&DocumentComment> = self.0.values().collect();
out.sort_by(|a, b| {
a.start
.cmp(&b.start)
.then(a.end.cmp(&b.end))
.then(a.uid.cmp(&b.uid))
});
out
}
}
impl FromIterator<DocumentComment> for DocumentComments {
fn from_iter<I: IntoIterator<Item = DocumentComment>>(iter: I) -> Self {
let mut out = Self::default();
for c in iter {
out.insert(c);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn comment(uid: &str, start: u32, end: u32) -> DocumentComment {
DocumentComment {
start,
end,
uid: uid.to_string(),
author: "Author".to_string(),
author_initials: "AU".to_string(),
date: "2026-01-01T00:00:00Z".to_string(),
resolved: false,
body: "Body".to_string(),
replies: vec![],
}
}
#[test]
fn insert_keys_by_uid_and_replaces_on_reinsert() {
let mut comments = DocumentComments::new();
comments.insert(comment("a", 0, 5));
comments.insert(comment("a", 10, 20));
assert_eq!(comments.len(), 1);
assert_eq!(comments.get("a").unwrap().start, 10);
}
#[test]
fn document_order_sorts_by_start_then_end_then_uid() {
let comments: DocumentComments =
[comment("z", 5, 10), comment("a", 5, 10), comment("m", 0, 3)]
.into_iter()
.collect();
let ordered: Vec<&str> = comments
.in_document_order()
.into_iter()
.map(|c| c.uid.as_str())
.collect();
assert_eq!(ordered, vec!["m", "a", "z"]);
}
#[test]
fn iteration_order_is_stable_across_builds() {
let build = || -> DocumentComments {
["z", "a", "m"]
.into_iter()
.map(|uid| comment(uid, 0, 1))
.collect()
};
let first: Vec<String> = build().iter().map(|c| c.uid.clone()).collect();
let second: Vec<String> = build().iter().map(|c| c.uid.clone()).collect();
assert_eq!(first, second);
assert_eq!(first, vec!["a", "m", "z"]);
}
#[test]
fn empty_range_and_replies_round_trip_through_json() {
let mut c = comment("root", 4, 4);
c.replies.push(CommentReply {
uid: "reply-1".to_string(),
author: "Editor".to_string(),
author_initials: "ED".to_string(),
date: "2026-02-02T00:00:00Z".to_string(),
body: "*Fixed.*".to_string(),
});
let json = serde_json::to_string(&c).expect("serialize");
let back: DocumentComment = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, c);
assert_eq!(back.start, back.end);
assert_eq!(back.replies.len(), 1);
}
}