use regex::Regex;
use std::sync::LazyLock;
use super::weekdays::normalize_weekdays;
use crate::regex_limits::{compile_bounded, TS_BODY_MAX};
static KEYWORD_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*((?:SCHEDULED|DEADLINE):\s*)<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
))
});
static CLOSED_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*(CLOSED:\s*)\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
))
});
static RANGE_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>--?-?<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
))
});
static RANGE_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]--?-?\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
))
});
static SIMPLE_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
))
});
static SIMPLE_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
))
});
static CREATED_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
r"^\s*CREATED:\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
))
});
static DATE_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"\b(\d{4}-\d{2}-\d{2})"));
static TIME_RANGE_RE: LazyLock<Regex> =
LazyLock::new(|| compile_bounded(r"\b(\d{1,2}:\d{2})-(\d{1,2}:\d{2})\b"));
static TIME_SINGLE_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"\b(\d{1,2}:\d{2})\b"));
pub fn extract_created_normalized(text: &str) -> Option<String> {
if !text.trim_start().starts_with("CREATED:") {
return None;
}
CREATED_RE
.captures(text)
.map(|caps| format!("CREATED: [{}]", &caps[1]))
}
pub fn extract_timestamp_normalized(text: &str) -> Option<String> {
let trimmed = text.trim_start();
match trimmed.as_bytes().first() {
Some(b'S' | b'D' | b'C' | b'<' | b'[') => {}
_ => return None,
}
if let Some(caps) = KEYWORD_ANGLE_RE.captures(text) {
return Some(format!("{}<{}>", &caps[1], &caps[2]));
}
if let Some(caps) = CLOSED_SQUARE_RE.captures(text) {
return Some(format!("{}[{}]", &caps[1], &caps[2]));
}
if let Some(caps) = RANGE_ANGLE_RE.captures(text) {
return Some(format!("<{}>--<{}>", &caps[1], &caps[2]));
}
if let Some(caps) = RANGE_SQUARE_RE.captures(text) {
return Some(format!("[{}]--[{}]", &caps[1], &caps[2]));
}
if let Some(caps) = SIMPLE_ANGLE_RE.captures(text) {
return Some(format!("<{}>", &caps[1]));
}
if let Some(caps) = SIMPLE_SQUARE_RE.captures(text) {
return Some(format!("[{}]", &caps[1]));
}
None
}
#[allow(clippy::type_complexity)]
#[cfg_attr(not(test), allow(dead_code))]
pub fn parse_timestamp_fields(
timestamp: &str,
mappings: &[(&str, &str)],
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<bool>,
) {
let normalized = normalize_weekdays(timestamp, mappings);
parse_timestamp_fields_normalized(&normalized)
}
#[allow(clippy::type_complexity)]
pub fn parse_timestamp_fields_normalized(
timestamp: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<bool>,
) {
let ts_type = detect_ts_type(timestamp);
let active = detect_active(timestamp);
if let Some((first, second)) = split_range(timestamp) {
let date = DATE_RE.captures(first).map(|c| c[1].to_string());
let (time, end_from_first) = extract_time_pair(first);
let end_time = if end_from_first.is_some() {
end_from_first
} else {
extract_time_pair(second).0
};
return (ts_type, date, time, end_time, active);
}
let date = DATE_RE.captures(timestamp).map(|c| c[1].to_string());
let (time, end_time) = extract_time_pair(timestamp);
(ts_type, date, time, end_time, active)
}
fn detect_active(timestamp: &str) -> Option<bool> {
let lt = timestamp.find('<');
let lb = timestamp.find('[');
match (lt, lb) {
(Some(i), Some(j)) => Some(i < j),
(Some(_), None) => Some(true),
(None, Some(_)) => Some(false),
(None, None) => None,
}
}
fn detect_ts_type(timestamp: &str) -> Option<String> {
let trimmed = timestamp.trim_start();
if trimmed.starts_with("SCHEDULED:") {
Some("SCHEDULED".to_string())
} else if trimmed.starts_with("DEADLINE:") {
Some("DEADLINE".to_string())
} else if trimmed.starts_with("CLOSED:") {
Some("CLOSED".to_string())
} else {
Some("PLAIN".to_string())
}
}
fn split_range(s: &str) -> Option<(&str, &str)> {
let lt = s.find('<');
let lb = s.find('[');
let (start, open, close) = match (lt, lb) {
(Some(i), Some(j)) if i < j => (i, '<', '>'),
(Some(_), Some(j)) => (j, '[', ']'),
(Some(i), None) => (i, '<', '>'),
(None, Some(j)) => (j, '[', ']'),
(None, None) => return None,
};
let after_first = &s[start + 1..];
let end_first_rel = after_first.find(close)?;
let first_body = &after_first[..end_first_rel];
let rest = &after_first[end_first_rel + 1..];
let rest = rest.strip_prefix('-')?;
let rest = rest.strip_prefix('-').unwrap_or(rest);
let rest = rest.strip_prefix('-').unwrap_or(rest);
let rest = rest.strip_prefix(open)?;
let end_second_rel = rest.find(close)?;
let second_body = &rest[..end_second_rel];
Some((first_body, second_body))
}
fn extract_time_pair(s: &str) -> (Option<String>, Option<String>) {
if let Some(c) = TIME_RANGE_RE.captures(s) {
return (Some(c[1].to_string()), Some(c[2].to_string()));
}
if let Some(c) = TIME_SINGLE_RE.captures(s) {
return (Some(c[1].to_string()), None);
}
(None, None)
}
#[cfg(test)]
mod tests {
use super::*;
fn extract_timestamp(text: &str, mappings: &[(&str, &str)]) -> Option<String> {
extract_timestamp_normalized(&normalize_weekdays(text, mappings))
}
fn extract_created(text: &str, mappings: &[(&str, &str)]) -> Option<String> {
extract_created_normalized(&normalize_weekdays(text, mappings))
}
#[test]
fn extract_timestamp_normalized_short_circuits_free_text() {
assert!(extract_timestamp_normalized("just some inline text").is_none());
assert!(extract_timestamp_normalized("`code that mentions foo bar`").is_none());
assert!(extract_timestamp_normalized(" <2024-12-05 Thu>").is_some());
}
#[test]
fn extract_created_normalized_short_circuits_free_text() {
assert!(extract_created_normalized("inline code without CREATED").is_none());
assert!(extract_created_normalized("SCHEDULED: <2024-12-05>").is_none());
assert!(extract_created_normalized("CREATED: [2024-12-05 Thu]").is_some());
}
#[test]
fn extract_timestamp_simple_scheduled() {
let ts = extract_timestamp("SCHEDULED: <2024-12-05 Thu 10:00>", &[]).unwrap();
assert_eq!(ts, "SCHEDULED: <2024-12-05 Thu 10:00>");
}
#[test]
fn extract_timestamp_range() {
let ts = extract_timestamp("<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>");
}
#[test]
fn extract_timestamp_range_one_dash() {
let ts = extract_timestamp("<2024-12-05 Thu>-<2024-12-06 Fri>", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
}
#[test]
fn extract_timestamp_range_three_dashes() {
let ts = extract_timestamp("<2024-12-05 Thu>---<2024-12-06 Fri>", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
}
#[test]
fn parse_fields_range_one_dash_recovers_second_time() {
let (_, date, time, end_time, _) =
parse_timestamp_fields("<2024-12-05 Thu 10:00>-<2024-12-06 Fri 14:00>", &[]);
assert_eq!(date, Some("2024-12-05".to_string()));
assert_eq!(time, Some("10:00".to_string()));
assert_eq!(end_time, Some("14:00".to_string()));
}
#[test]
fn extract_timestamp_localized_weekday() {
let mappings = [("Чт", "Thu")];
let ts = extract_timestamp("DEADLINE: <2024-12-05 Чт>", &mappings).unwrap();
assert_eq!(ts, "DEADLINE: <2024-12-05 Thu>");
}
#[test]
fn extract_created_basic() {
let c = extract_created("CREATED: [2024-12-05 Thu]", &[]).unwrap();
assert_eq!(c, "CREATED: [2024-12-05 Thu]");
}
#[test]
fn extract_created_rejects_angle_brackets() {
assert!(extract_created("CREATED: <2024-12-05 Thu>", &[]).is_none());
}
#[test]
fn extract_created_returns_none_on_other() {
assert!(extract_created("SCHEDULED: <2024-12-05>", &[]).is_none());
}
#[test]
fn parse_fields_scheduled_with_time() {
let (ts_type, date, time, end_time, _) =
parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu 10:00>", &[]);
assert_eq!(ts_type, Some("SCHEDULED".to_string()));
assert_eq!(date, Some("2024-12-05".to_string()));
assert_eq!(time, Some("10:00".to_string()));
assert_eq!(end_time, None);
}
#[test]
fn parse_fields_inline_time_range() {
let (_, _, time, end_time, _) =
parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu 10:00-12:00>", &[]);
assert_eq!(time, Some("10:00".to_string()));
assert_eq!(end_time, Some("12:00".to_string()));
}
#[test]
fn parse_fields_range_timestamp_recovers_second_time() {
let (ts_type, date, time, end_time, _) =
parse_timestamp_fields("<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>", &[]);
assert_eq!(ts_type, Some("PLAIN".to_string()));
assert_eq!(date, Some("2024-12-05".to_string()));
assert_eq!(time, Some("10:00".to_string()));
assert_eq!(end_time, Some("14:00".to_string()));
}
#[test]
fn parse_fields_range_inline_takes_precedence() {
let (_, _, time, end_time, _) =
parse_timestamp_fields("<2024-12-05 Thu 10:00-12:00>--<2024-12-06 Fri 14:00>", &[]);
assert_eq!(time, Some("10:00".to_string()));
assert_eq!(end_time, Some("12:00".to_string()));
}
#[test]
fn detect_ts_type_does_not_match_body_substring() {
let (ts_type, _, _, _, _) =
parse_timestamp_fields("CREATED: [2024-12-05 see SCHEDULED:]", &[]);
assert_eq!(ts_type, Some("PLAIN".to_string()));
}
#[test]
fn parse_fields_no_time() {
let (_, date, time, end_time, _) =
parse_timestamp_fields("DEADLINE: <2024-12-05 Thu>", &[]);
assert_eq!(date, Some("2024-12-05".to_string()));
assert_eq!(time, None);
assert_eq!(end_time, None);
}
#[test]
fn parse_fields_marks_angle_bracket_keyword_as_active() {
let (_, _, _, _, active) = parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu>", &[]);
assert_eq!(active, Some(true));
}
#[test]
fn parse_fields_marks_square_bracket_keyword_as_inactive() {
let (_, _, _, _, active) = parse_timestamp_fields("CLOSED: [2024-12-05 Thu 14:30]", &[]);
assert_eq!(active, Some(false));
}
#[test]
fn parse_fields_marks_inline_plain_active() {
let (_, _, _, _, active) = parse_timestamp_fields("<2024-12-05 Thu>", &[]);
assert_eq!(active, Some(true));
}
#[test]
fn parse_fields_marks_inline_plain_inactive() {
let (_, _, _, _, active) = parse_timestamp_fields("[2024-12-05 Thu]", &[]);
assert_eq!(active, Some(false));
}
#[test]
fn parse_fields_no_bracket_returns_none_active() {
let (_, _, _, _, active) = parse_timestamp_fields("not a timestamp", &[]);
assert_eq!(active, None);
}
#[test]
fn matrix_scheduled_active_accepted() {
let ts = extract_timestamp("SCHEDULED: <2024-12-05 Thu>", &[]).unwrap();
assert_eq!(ts, "SCHEDULED: <2024-12-05 Thu>");
}
#[test]
fn matrix_scheduled_inactive_rejected() {
assert!(extract_timestamp("SCHEDULED: [2024-12-05 Thu]", &[]).is_none());
}
#[test]
fn matrix_deadline_active_accepted() {
let ts = extract_timestamp("DEADLINE: <2024-12-05 Thu>", &[]).unwrap();
assert_eq!(ts, "DEADLINE: <2024-12-05 Thu>");
}
#[test]
fn matrix_deadline_inactive_rejected() {
assert!(extract_timestamp("DEADLINE: [2024-12-05 Thu]", &[]).is_none());
}
#[test]
fn matrix_closed_inactive_accepted() {
let ts = extract_timestamp("CLOSED: [2024-12-05 Thu 14:30]", &[]).unwrap();
assert_eq!(ts, "CLOSED: [2024-12-05 Thu 14:30]");
}
#[test]
fn matrix_closed_active_rejected() {
assert!(extract_timestamp("CLOSED: <2024-12-05 Thu 14:30>", &[]).is_none());
}
#[test]
fn matrix_created_inactive_accepted() {
let c = extract_created("CREATED: [2024-12-05 Thu]", &[]).unwrap();
assert_eq!(c, "CREATED: [2024-12-05 Thu]");
}
#[test]
fn matrix_created_active_rejected() {
assert!(extract_created("CREATED: <2024-12-05 Thu>", &[]).is_none());
}
#[test]
fn matrix_inline_active_accepted() {
let ts = extract_timestamp("<2024-12-05 Thu>", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu>");
}
#[test]
fn matrix_inline_inactive_accepted() {
let ts = extract_timestamp("[2024-12-05 Thu]", &[]).unwrap();
assert_eq!(ts, "[2024-12-05 Thu]");
}
#[test]
fn matrix_inline_mixed_open_angle_close_square_rejected() {
assert!(extract_timestamp("<2024-12-05 Thu]", &[]).is_none());
}
#[test]
fn matrix_inline_mixed_open_square_close_angle_rejected() {
assert!(extract_timestamp("[2024-12-05 Thu>", &[]).is_none());
}
#[test]
fn matrix_inline_range_active_accepted() {
let ts = extract_timestamp("<2024-12-05 Thu>--<2024-12-06 Fri>", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
}
#[test]
fn matrix_inline_range_inactive_accepted() {
let ts = extract_timestamp("[2024-12-05 Thu]--[2024-12-06 Fri]", &[]).unwrap();
assert_eq!(ts, "[2024-12-05 Thu]--[2024-12-06 Fri]");
}
#[test]
fn matrix_inline_range_mixed_first_active_falls_back_to_single() {
let ts = extract_timestamp("<2024-12-05 Thu>--[2024-12-06 Fri]", &[]).unwrap();
assert_eq!(ts, "<2024-12-05 Thu>");
}
#[test]
fn matrix_inline_range_mixed_first_inactive_falls_back_to_single() {
let ts = extract_timestamp("[2024-12-05 Thu]--<2024-12-06 Fri>", &[]).unwrap();
assert_eq!(ts, "[2024-12-05 Thu]");
}
#[test]
fn timestamp_body_within_limit_is_accepted() {
use crate::regex_limits::TS_BODY_MAX;
let filler = " ".repeat(TS_BODY_MAX);
let input = format!("SCHEDULED: <2024-12-05{filler}>");
let ts = extract_timestamp(&input, &[]).expect("should match at exactly the cap");
assert!(ts.contains("2024-12-05"));
assert_eq!(ts.len(), "SCHEDULED: <>".len() + 10 + TS_BODY_MAX);
}
#[test]
fn timestamp_body_just_over_limit_is_rejected() {
use crate::regex_limits::TS_BODY_MAX;
let filler = " ".repeat(TS_BODY_MAX + 1);
let input = format!("SCHEDULED: <2024-12-05{filler}>");
assert!(
extract_timestamp(&input, &[]).is_none(),
"body of TS_BODY_MAX+1 chars must not match"
);
}
#[test]
fn parse_timestamp_fields_normalized_matches_full_for_already_normalised_input() {
let cases = [
"SCHEDULED: <2024-12-05 Thu>",
"DEADLINE: <2024-12-05 Thu 10:00>",
"<2024-12-05 Thu 10:00-12:00>",
"<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>",
"CLOSED: [2024-12-05 Thu]",
"[2024-12-05 Thu]",
"not a timestamp",
];
for input in cases {
let full = parse_timestamp_fields(input, &[]);
let normalised = parse_timestamp_fields_normalized(input);
assert_eq!(
full, normalised,
"_normalized fast path must equal the full variant for already-English input `{input}`"
);
}
}
}