wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Signature-timestamp matching: recognises the `HH:MM, D month YYYY (UTC)` shape that
//! MediaWiki's `~~~~~` produces, parameterised by the wiki's localized month names. The
//! scanner is a byte-level forward parse anchored on each digit run — no regex — and
//! returns the original-source span of every timestamp together with its parsed value.

use crate::config::SiteConfig;

/// A signature timestamp in the wiki's UTC wall-clock (`08:26, 8 мая 2026 (UTC)`). Ordered
/// chronologically (field order is significant for the derived `Ord`), so comments compare
/// by recency directly.
#[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 {
    /// Four-digit year.
    #[must_use]
    pub fn year(&self) -> i32 {
        self.year
    }

    /// Month, 1–12.
    #[must_use]
    pub fn month(&self) -> u8 {
        self.month
    }

    /// Day of month, 1–31.
    #[must_use]
    pub fn day(&self) -> u8 {
        self.day
    }

    /// Hour, 0–23.
    #[must_use]
    pub fn hour(&self) -> u8 {
        self.hour
    }

    /// Minute, 0–59.
    #[must_use]
    pub fn minute(&self) -> u8 {
        self.minute
    }
}

/// One timestamp located in the source: its byte range and parsed value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FoundTime {
    /// Byte offset of the first digit (`HH`).
    pub(crate) start: usize,
    /// Byte offset just past the closing `(UTC)`.
    pub(crate) end: usize,
    pub(crate) time: CommentTime,
}

/// Locates signature timestamps in source text using a wiki's month names.
pub(crate) struct TimestampMatcher<'a> {
    /// Genitive month names (index 0 = January); the form signatures use on ru wikis, and
    /// identical to the full month name on wikis whose genitive equals the nominative.
    months: &'a [String; 12],
}

impl<'a> TimestampMatcher<'a> {
    pub(crate) fn new(config: &'a SiteConfig) -> Self {
        Self {
            months: &config.months().genitive,
        }
    }

    /// Every timestamp in `text`, in source order and non-overlapping.
    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() {
            // Anchor on a digit that does not sit inside a longer number, so `108:26`
            // cannot match `08:26`.
            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
    }

    /// Attempts to parse `HH:MM, D month YYYY (UTC)` starting at `start`.
    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,
            },
        })
    }

    /// Matches one localized month name at `p`, returning its 1-based number.
    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
    }
}

/// Reads between `min` and `max` ASCII digits at `p`, returning the value and new position.
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();
        // Wrong month name, bad ranges, missing zone, digit-glued start.
        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());
    }
}