use chrono::NaiveDate;
use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
use super::repeater::{parse_repeater, Repeater};
use super::weekdays::normalize_weekdays;
use crate::regex_limits::compile_bounded;
static SINGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(r"<(\d{4}-\d{2}-\d{2})[^<>]{0,80}>")
});
static REPEATER_BODY_RE: LazyLock<Regex> =
LazyLock::new(|| compile_bounded(r"([.+]+\d+(?:wd|[dwmyh]))"));
static WARNING_BODY_RE: LazyLock<Regex> =
LazyLock::new(|| compile_bounded(r"\s-(\d+)([hdwmy])(?:[\s>]|$)"));
#[derive(Debug, Clone)]
pub struct ParsedTimestamp {
pub date: NaiveDate,
pub repeater: Option<Repeater>,
pub warning_days: Option<i64>,
}
fn warning_cookie_to_days(value: i64, unit: &str) -> Option<i64> {
let factor = match unit {
"d" => 1.0,
"w" => 7.0,
"m" => 30.4,
"y" => 365.25,
"h" => 1.0 / 24.0,
_ => return None,
};
Some((value as f64 * factor).floor() as i64)
}
pub fn parse_org_timestamp(ts: &str, mappings: Option<&[(&str, &str)]>) -> Option<ParsedTimestamp> {
let ts = if let Some(m) = mappings {
normalize_weekdays(ts, m)
} else {
Cow::Borrowed(ts)
};
let caps = SINGLE_RE.captures(&ts)?;
let date = NaiveDate::parse_from_str(&caps[1], "%Y-%m-%d").ok()?;
let bracket = caps.get(0).map(|m| m.as_str()).unwrap_or("");
let repeater = REPEATER_BODY_RE
.captures(bracket)
.and_then(|c| parse_repeater(c.get(1)?.as_str()));
let warning_days = WARNING_BODY_RE.captures(bracket).and_then(|c| {
let value: i64 = c.get(1)?.as_str().parse().ok()?;
warning_cookie_to_days(value, c.get(2)?.as_str())
});
Some(ParsedTimestamp {
date,
repeater,
warning_days,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_timestamp_with_workday_repeater() {
let ts = "<2025-12-05 Thu +1wd>";
let parsed = parse_org_timestamp(ts, None).unwrap();
assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
assert!(parsed.repeater.is_some());
let repeater = parsed.repeater.unwrap();
assert_eq!(repeater.value, 1);
assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Workday);
}
#[test]
fn test_parse_timestamp_with_workday_repeater_multiple() {
let ts = "<2025-12-09 Mon +2wd>";
let parsed = parse_org_timestamp(ts, None).unwrap();
let repeater = parsed.repeater.unwrap();
assert_eq!(repeater.value, 2);
assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Workday);
}
#[test]
fn test_parse_timestamp_with_regular_repeater() {
let ts = "<2025-12-05 Thu +1d>";
let parsed = parse_org_timestamp(ts, None).unwrap();
let repeater = parsed.repeater.unwrap();
assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Day);
}
#[test]
fn range_separator_accepts_one_two_three_dashes() {
for sep in ["-", "--", "---"] {
let ts = format!("<2025-12-05 Thu>{sep}<2025-12-06 Fri>");
let parsed = parse_org_timestamp(&ts, None)
.unwrap_or_else(|| panic!("must parse range with {sep:?} as separator"));
assert_eq!(
parsed.date,
NaiveDate::from_ymd_opt(2025, 12, 5).unwrap(),
"start date must be the first bracket for separator {sep:?}"
);
}
}
#[test]
fn parse_without_warning_period_yields_none() {
let parsed = parse_org_timestamp("<2025-12-10 Wed>", None).unwrap();
assert_eq!(parsed.warning_days, None);
}
#[test]
fn parse_warning_period_days() {
let parsed = parse_org_timestamp("<2025-12-10 Wed -3d>", None).unwrap();
assert_eq!(parsed.warning_days, Some(3));
}
#[test]
fn parse_warning_period_weeks() {
let parsed = parse_org_timestamp("<2025-12-10 Wed -2w>", None).unwrap();
assert_eq!(parsed.warning_days, Some(14));
}
#[test]
fn parse_warning_period_months_floored() {
let parsed = parse_org_timestamp("<2025-12-10 Wed -1m>", None).unwrap();
assert_eq!(parsed.warning_days, Some(30));
}
#[test]
fn parse_warning_period_years_floored() {
let parsed = parse_org_timestamp("<2025-12-10 Wed -1y>", None).unwrap();
assert_eq!(parsed.warning_days, Some(365));
}
#[test]
fn parse_warning_period_hours_floored_to_zero_for_small_n() {
let parsed = parse_org_timestamp("<2025-12-10 Wed -1h>", None).unwrap();
assert_eq!(parsed.warning_days, Some(0));
}
#[test]
fn parse_warning_period_with_repeater_in_either_order() {
let with_repeater_first = parse_org_timestamp("<2025-12-10 Wed +1d -3d>", None).unwrap();
assert_eq!(with_repeater_first.warning_days, Some(3));
assert!(with_repeater_first.repeater.is_some());
let with_warning_first = parse_org_timestamp("<2025-12-10 Wed -3d +1d>", None).unwrap();
assert_eq!(with_warning_first.warning_days, Some(3));
assert!(with_warning_first.repeater.is_some());
}
}