use crate::config::SiteConfig;
use crate::model::Span;
use super::signature::{Author, UserLink, find_user_links};
use super::timestamp::{CommentTime, FoundTime, TimestampMatcher};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct Comment {
author: Option<Author>,
time: Option<CommentTime>,
level: u8,
span: Span,
signature_span: Span,
parent: Option<usize>,
}
impl Comment {
#[must_use]
pub fn author(&self) -> Option<&Author> {
self.author.as_ref()
}
#[must_use]
pub fn time(&self) -> Option<CommentTime> {
self.time
}
#[must_use]
pub fn level(&self) -> u8 {
self.level
}
#[must_use]
pub fn span(&self) -> Span {
self.span
}
#[must_use]
pub fn signature_span(&self) -> Span {
self.signature_span
}
#[must_use]
pub fn parent(&self) -> Option<usize> {
self.parent
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Discussion {
comments: Vec<Comment>,
}
impl Discussion {
#[must_use]
pub fn from_wikitext(wikitext: &str, config: &SiteConfig) -> Discussion {
Discussion {
comments: build(wikitext, config),
}
}
#[must_use]
pub fn comments(&self) -> &[Comment] {
&self.comments
}
}
fn build(text: &str, config: &SiteConfig) -> Vec<Comment> {
let times = TimestampMatcher::new(config).find_all(text);
let links = find_user_links(text, config);
let mut comments = Vec::new();
let mut seg_start = 0;
for ft in × {
let author_link = last_link_before(&links, ft, text);
let sig_start = author_link.map_or(ft.start, |l| l.start);
let line_start = content_line_start(text, seg_start);
comments.push(Comment {
author: author_link.map(|l| l.author.clone()),
time: Some(ft.time),
level: indent_level(text, line_start),
span: Span {
start: line_start,
end: ft.end,
},
signature_span: Span {
start: sig_start,
end: ft.end,
},
parent: None,
});
seg_start = ft.end;
}
assign_parents(&mut comments);
comments
}
fn last_link_before<'l>(links: &'l [UserLink], ft: &FoundTime, text: &str) -> Option<&'l UserLink> {
links
.iter()
.filter(|l| l.end <= ft.start)
.rfind(|l| !text[l.end..ft.start].contains('\n'))
}
fn content_line_start(text: &str, from: usize) -> usize {
let bytes = text.as_bytes();
let mut i = from;
while i < bytes.len() && matches!(bytes[i], b'\n' | b'\r' | b' ' | b'\t') {
i += 1;
}
let mut ls = i.min(bytes.len());
while ls > 0 && bytes[ls - 1] != b'\n' {
ls -= 1;
}
ls
}
fn indent_level(text: &str, line_start: usize) -> u8 {
let count = text.as_bytes()[line_start..]
.iter()
.take_while(|&&b| matches!(b, b':' | b'*' | b'#'))
.count();
u8::try_from(count).unwrap_or(u8::MAX)
}
fn assign_parents(comments: &mut [Comment]) {
let mut stack: Vec<usize> = Vec::new();
for i in 0..comments.len() {
let level = comments[i].level;
while stack
.last()
.is_some_and(|&top| comments[top].level >= level)
{
stack.pop();
}
comments[i].parent = stack.last().copied();
stack.push(i);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{MonthNames, NamespaceDef};
fn config() -> SiteConfig {
let genitive = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
.map(String::from);
SiteConfig::builder("ru", "Википедия")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(2, "User", "Участник", ["У"], true))
.namespace(NamespaceDef::new(
3,
"User talk",
"Обсуждение участника",
["ОУ"],
true,
))
.months(MonthNames {
genitive: genitive.clone(),
nominative: genitive.clone(),
abbreviations: genitive,
})
.build()
.unwrap()
}
fn parse(text: &str) -> Discussion {
Discussion::from_wikitext(text, &config())
}
#[test]
fn a_single_signed_comment_carries_author_time_and_span() {
let text = "Предлагаю переименовать. [[У:Иван|Иван]] 08:26, 8 мая 2026 (UTC)";
let d = parse(text);
assert_eq!(d.comments().len(), 1);
let c = &d.comments()[0];
assert_eq!(c.author().map(Author::name), Some("Иван"));
assert_eq!(c.time().map(|t| t.day()), Some(8));
assert_eq!(c.level(), 0);
assert_eq!(c.parent(), None);
assert_eq!(&text[c.span().start..c.span().end], text);
let sig = &text[c.signature_span().start..c.signature_span().end];
assert_eq!(sig, "[[У:Иван|Иван]] 08:26, 8 мая 2026 (UTC)");
}
#[test]
fn indentation_threads_replies_to_their_parents() {
let text = concat!(
"Номинирую. [[У:A|A]] 08:00, 1 мая 2026 (UTC)\n",
": Против. [[У:B|B]] 09:00, 1 мая 2026 (UTC)\n",
":: Почему? [[У:A|A]] 10:00, 1 мая 2026 (UTC)\n",
": Ещё реплика. [[У:C|C]] 11:00, 1 мая 2026 (UTC)\n",
);
let d = parse(text);
let c = d.comments();
assert_eq!(c.len(), 4);
let levels: Vec<u8> = c.iter().map(Comment::level).collect();
assert_eq!(levels, [0, 1, 2, 1]);
assert_eq!(c[0].parent(), None);
assert_eq!(c[1].parent(), Some(0), "reply to nomination");
assert_eq!(c[2].parent(), Some(1), "reply to B");
assert_eq!(
c[3].parent(),
Some(0),
"back to top level → replies to nomination"
);
let authors: Vec<&str> = c
.iter()
.filter_map(|c| c.author().map(Author::name))
.collect();
assert_eq!(authors, ["A", "B", "A", "C"]);
}
#[test]
fn a_timestamp_without_a_same_line_user_link_has_no_author() {
let text = "[[У:X|X]] wrote something\nand later 08:00, 1 мая 2026 (UTC)";
let d = parse(text);
assert_eq!(d.comments().len(), 1);
assert_eq!(d.comments()[0].author(), None);
assert_eq!(d.comments()[0].time().map(|t| t.hour()), Some(8));
}
#[test]
fn two_signatures_on_one_line_both_start_at_that_line() {
let text = "[[У:A|A]] 08:00, 1 мая 2026 (UTC) and [[У:B|B]] 09:00, 1 мая 2026 (UTC)";
let d = parse(text);
assert_eq!(d.comments().len(), 2);
assert_eq!(d.comments()[1].span().start, 0);
assert_eq!(d.comments()[1].author().map(Author::name), Some("B"));
}
#[test]
fn text_without_signatures_yields_no_comments() {
assert!(parse("Просто текст без подписи.").comments().is_empty());
assert_eq!(Discussion::default().comments().len(), 0);
}
}