use crate::config::SiteConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct CommentTime {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
}
impl CommentTime {
#[must_use]
pub fn year(&self) -> i32 {
self.year
}
#[must_use]
pub fn month(&self) -> u8 {
self.month
}
#[must_use]
pub fn day(&self) -> u8 {
self.day
}
#[must_use]
pub fn hour(&self) -> u8 {
self.hour
}
#[must_use]
pub fn minute(&self) -> u8 {
self.minute
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FoundTime {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) time: CommentTime,
}
pub(crate) struct TimestampMatcher<'a> {
months: &'a [String; 12],
}
impl<'a> TimestampMatcher<'a> {
pub(crate) fn new(config: &'a SiteConfig) -> Self {
Self {
months: &config.months().genitive,
}
}
pub(crate) fn find_all(&self, text: &str) -> Vec<FoundTime> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_digit() && (i == 0 || !bytes[i - 1].is_ascii_digit()) {
if let Some(found) = self.try_parse(bytes, i) {
i = found.end;
out.push(found);
continue;
}
}
i += 1;
}
out
}
fn try_parse(&self, bytes: &[u8], start: usize) -> Option<FoundTime> {
let (hour, p) = take_digits(bytes, start, 2, 2)?;
let p = literal(bytes, p, b":")?;
let (minute, p) = take_digits(bytes, p, 2, 2)?;
let p = literal(bytes, p, b", ")?;
let (day, p) = take_digits(bytes, p, 1, 2)?;
let p = literal(bytes, p, b" ")?;
let (month, p) = self.take_month(bytes, p)?;
let p = literal(bytes, p, b" ")?;
let (year, p) = take_digits(bytes, p, 4, 4)?;
let end = literal(bytes, p, b" (UTC)")?;
if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) || !(1..=31).contains(&day) {
return None;
}
Some(FoundTime {
start,
end,
time: CommentTime {
year: i32::try_from(year).ok()?,
month,
day: day as u8,
hour: hour as u8,
minute: minute as u8,
},
})
}
fn take_month(&self, bytes: &[u8], p: usize) -> Option<(u8, usize)> {
for (idx, name) in self.months.iter().enumerate() {
let name = name.as_bytes();
let end = p + name.len();
if bytes.get(p..end) == Some(name) {
return Some((idx as u8 + 1, end));
}
}
None
}
}
fn take_digits(bytes: &[u8], p: usize, min: usize, max: usize) -> Option<(u32, usize)> {
let mut value: u32 = 0;
let mut count = 0;
let mut i = p;
while i < bytes.len() && count < max && bytes[i].is_ascii_digit() {
value = value
.checked_mul(10)?
.checked_add(u32::from(bytes[i] - b'0'))?;
i += 1;
count += 1;
}
(count >= min).then_some((value, i))
}
fn literal(bytes: &[u8], p: usize, lit: &[u8]) -> Option<usize> {
let end = p + lit.len();
(bytes.get(p..end) == Some(lit)).then_some(end)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{MonthNames, NamespaceDef};
fn ru_config() -> SiteConfig {
let genitive = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
.map(String::from);
SiteConfig::builder("ru", "Википедия")
.namespace(NamespaceDef::main())
.months(MonthNames {
genitive: genitive.clone(),
nominative: genitive.clone(),
abbreviations: genitive,
})
.build()
.unwrap()
}
fn en_config() -> SiteConfig {
SiteConfig::builder("en", "Wiki")
.namespace(NamespaceDef::main())
.build()
.unwrap()
}
fn find(config: &SiteConfig, text: &str) -> Vec<FoundTime> {
TimestampMatcher::new(config).find_all(text)
}
#[test]
fn matches_a_russian_signature_timestamp() {
let text = "…бла бла. 08:26, 8 мая 2026 (UTC) конец";
let found = find(&ru_config(), text);
assert_eq!(found.len(), 1);
let m = found[0];
assert_eq!(&text[m.start..m.end], "08:26, 8 мая 2026 (UTC)");
assert_eq!(
m.time,
CommentTime {
year: 2026,
month: 5,
day: 8,
hour: 8,
minute: 26,
}
);
assert_eq!((m.time.year(), m.time.month(), m.time.day()), (2026, 5, 8));
assert_eq!((m.time.hour(), m.time.minute()), (8, 26));
}
#[test]
fn matches_an_english_signature_and_a_two_digit_day() {
let found = find(&en_config(), "21:00, 15 December 1999 (UTC)");
assert_eq!(found.len(), 1);
assert_eq!(found[0].time.day(), 15);
assert_eq!(found[0].time.month(), 12);
}
#[test]
fn finds_several_timestamps_in_order_without_overlap() {
let text = "a 08:26, 8 мая 2026 (UTC) b 09:00, 9 мая 2026 (UTC)";
let found = find(&ru_config(), text);
assert_eq!(found.len(), 2);
assert!(found[0].time < found[1].time, "chronological ordering");
}
#[test]
fn rejects_malformed_or_out_of_range_timestamps() {
let cfg = ru_config();
assert!(find(&cfg, "08:26, 8 Foo 2026 (UTC)").is_empty());
assert!(find(&cfg, "25:00, 1 мая 2026 (UTC)").is_empty());
assert!(find(&cfg, "08:70, 1 мая 2026 (UTC)").is_empty());
assert!(find(&cfg, "08:26, 8 мая 2026 (MSK)").is_empty());
assert!(find(&cfg, "108:26, 8 мая 2026 (UTC)").is_empty());
assert!(find(&cfg, "08:26, 0 мая 2026 (UTC)").is_empty());
assert!(find(&cfg, "no timestamp here 12345").is_empty());
}
#[test]
fn requires_a_four_digit_year() {
assert!(find(&ru_config(), "08:26, 8 мая 999 (UTC)").is_empty());
}
}