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_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(r"<(\d{4}-\d{2}-\d{2})[^<>]{0,80}>")
});
static SINGLE_SQUARE_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>,
pub active: bool,
}
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 angle = SINGLE_ANGLE_RE.captures(&ts);
let square = SINGLE_SQUARE_RE.captures(&ts);
let caps = match (&angle, &square) {
(Some(a), Some(s)) => {
if a.get(0).unwrap().start() <= s.get(0).unwrap().start() {
angle.as_ref().unwrap()
} else {
square.as_ref().unwrap()
}
}
(Some(_), None) => angle.as_ref().unwrap(),
(None, Some(_)) => square.as_ref().unwrap(),
(None, None) => return None,
};
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())
});
let active = bracket.starts_with('<');
Some(ParsedTimestamp {
date,
repeater,
warning_days,
active,
})
}
#[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());
}
#[test]
fn parse_org_timestamp_marks_angle_bracket_as_active() {
let parsed = parse_org_timestamp("<2025-12-10 Wed>", None).unwrap();
assert!(parsed.active, "<...> timestamp must be active");
}
#[test]
fn parse_org_timestamp_marks_square_bracket_as_inactive() {
let parsed = parse_org_timestamp("[2025-12-10 Wed]", None).unwrap();
assert!(!parsed.active, "[...] timestamp must be inactive");
assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 10).unwrap());
}
#[test]
fn parse_inactive_timestamp_with_repeater() {
let parsed = parse_org_timestamp("[2025-12-05 Thu +1d]", None).unwrap();
let repeater = parsed.repeater.unwrap();
assert_eq!(repeater.value, 1);
assert_eq!(repeater.unit, super::super::repeater::RepeaterUnit::Day);
assert!(!parsed.active);
}
#[test]
fn parse_inactive_timestamp_with_warning_period() {
let parsed = parse_org_timestamp("[2025-12-10 Wed -3d]", None).unwrap();
assert_eq!(parsed.warning_days, Some(3));
assert!(!parsed.active);
}
#[test]
fn parse_inactive_timestamp_normalizes_localized_weekday() {
let mappings: &[(&str, &str)] = &[("Чт", "Thu")];
let parsed = parse_org_timestamp("[2025-12-05 Чт]", Some(mappings)).unwrap();
assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
assert!(!parsed.active);
}
#[test]
fn parse_org_timestamp_prefers_first_bracket_form() {
let parsed = parse_org_timestamp("[2025-12-05 Thu] <2025-12-06 Fri>", None).unwrap();
assert_eq!(parsed.date, NaiveDate::from_ymd_opt(2025, 12, 5).unwrap());
assert!(!parsed.active);
}
}