Skip to main content

markdown_org_extract/timestamp/
extract.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use super::weekdays::normalize_weekdays;
5use crate::regex_limits::{compile_bounded, TS_BODY_MAX};
6
7// Per-keyword bracket policy (ADR-0014):
8//   SCHEDULED:, DEADLINE: → only active `<...>`
9//   CLOSED:, CREATED:     → only inactive `[...]` (CREATED follows the
10//                           org-expiry convention; CLOSED matches upstream
11//                           Emacs `org-closed-string` / `org-closed-time-regexp`)
12//   inline plain          → both `<...>` (active) and `[...]` (inactive)
13//
14// `[^>]{0,TS_BODY_MAX}` (or `[^\]]{0,TS_BODY_MAX}`) caps the body length of a
15// single bracketed timestamp so that a hostile or malformed line cannot make
16// `[^>]*` scan thousands of characters before the engine notices the missing
17// closing bracket. Paired alternation (no `[<\[]...[>\]]` shortcuts) keeps
18// mixed pairs `<...]` / `[...>` from matching by construction.
19static KEYWORD_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
20    compile_bounded(&format!(
21        r"^\s*((?:SCHEDULED|DEADLINE):\s*)<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
22    ))
23});
24
25static CLOSED_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
26    compile_bounded(&format!(
27        r"^\s*(CLOSED:\s*)\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
28    ))
29});
30
31// Range-timestamp separator matches Emacs' org-tr-regexp: one, two, or three
32// dashes between the two bracketed values. The output is always canonicalised
33// to the two-dash form, which is the variant produced by Emacs `org-time-stamp`.
34// Both endpoints must share the bracket form (no mixed pairs).
35static RANGE_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
36    compile_bounded(&format!(
37        r"^\s*<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>--?-?<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
38    ))
39});
40
41static RANGE_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
42    compile_bounded(&format!(
43        r"^\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]--?-?\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
44    ))
45});
46
47static SIMPLE_ANGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
48    compile_bounded(&format!(
49        r"^\s*<(\d{{4}}-\d{{2}}-\d{{2}}[^>]{{0,{TS_BODY_MAX}}})>"
50    ))
51});
52
53static SIMPLE_SQUARE_RE: LazyLock<Regex> = LazyLock::new(|| {
54    compile_bounded(&format!(
55        r"^\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
56    ))
57});
58
59static CREATED_RE: LazyLock<Regex> = LazyLock::new(|| {
60    compile_bounded(&format!(
61        r"^\s*CREATED:\s*\[(\d{{4}}-\d{{2}}-\d{{2}}[^\]]{{0,{TS_BODY_MAX}}})\]"
62    ))
63});
64
65static DATE_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"\b(\d{4}-\d{2}-\d{2})"));
66
67static TIME_RANGE_RE: LazyLock<Regex> =
68    LazyLock::new(|| compile_bounded(r"\b(\d{1,2}:\d{2})-(\d{1,2}:\d{2})\b"));
69
70static TIME_SINGLE_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"\b(\d{1,2}:\d{2})\b"));
71
72/// Extract CREATED timestamp from already-weekday-normalized text. Callers in
73/// the parser pre-normalize so multiple extractors share one scan; tests pass
74/// already-English input.
75pub fn extract_created_normalized(text: &str) -> Option<String> {
76    // Fast path: every match of CREATED_RE begins with optional whitespace
77    // and then literal `CREATED:`. Bail out before paying the regex engine
78    // when the leading non-space byte cannot start that keyword.
79    if !text.trim_start().starts_with("CREATED:") {
80        return None;
81    }
82    CREATED_RE
83        .captures(text)
84        .map(|caps| format!("CREATED: [{}]", &caps[1]))
85}
86
87/// Extract non-CREATED timestamp from already-weekday-normalized text.
88pub fn extract_timestamp_normalized(text: &str) -> Option<String> {
89    // Fast path: every regex below anchors to one of the keyword prefixes
90    // `SCHEDULED:` / `DEADLINE:` / `CLOSED:` (KEYWORD_ANGLE_RE / CLOSED_SQUARE_RE)
91    // or to the literal `<` / `[` of a bare timestamp (RANGE_* / SIMPLE_*).
92    // A byte check on the first non-whitespace byte short-circuits the
93    // common case where an inline-code line is unrelated free text, sparing
94    // several regex compilations of input we cannot match.
95    let trimmed = text.trim_start();
96    match trimmed.as_bytes().first() {
97        Some(b'S' | b'D' | b'C' | b'<' | b'[') => {}
98        _ => return None,
99    }
100
101    // Keyword forms first: each keyword accepts only one bracket form
102    // (ADR-0014). The output preserves the bracket form so consumers can
103    // tell `<...>` from `[...]`.
104    if let Some(caps) = KEYWORD_ANGLE_RE.captures(text) {
105        return Some(format!("{}<{}>", &caps[1], &caps[2]));
106    }
107    if let Some(caps) = CLOSED_SQUARE_RE.captures(text) {
108        return Some(format!("{}[{}]", &caps[1], &caps[2]));
109    }
110
111    // Plain inline timestamps: ranges before singles (a range starts with a
112    // single timestamp's prefix, so SIMPLE_* would otherwise eat the first
113    // bracket and leave `--<...>` dangling). Both endpoints share a bracket
114    // form by construction; mixed pairs are not matched.
115    if let Some(caps) = RANGE_ANGLE_RE.captures(text) {
116        return Some(format!("<{}>--<{}>", &caps[1], &caps[2]));
117    }
118    if let Some(caps) = RANGE_SQUARE_RE.captures(text) {
119        return Some(format!("[{}]--[{}]", &caps[1], &caps[2]));
120    }
121    if let Some(caps) = SIMPLE_ANGLE_RE.captures(text) {
122        return Some(format!("<{}>", &caps[1]));
123    }
124    if let Some(caps) = SIMPLE_SQUARE_RE.captures(text) {
125        return Some(format!("[{}]", &caps[1]));
126    }
127
128    None
129}
130
131/// Parse timestamp fields for JSON output.
132///
133/// Returns `(timestamp_type, date, time, end_time, active)`.
134///
135/// `active` is `Some(true)` for an active timestamp `<...>`, `Some(false)`
136/// for an inactive one `[...]`, and `None` when the input does not contain
137/// a recognisable opening bracket. The bracket form is detected on the
138/// first `<` / `[` after the keyword prefix; see ADR-0014 for the
139/// per-keyword policy.
140///
141/// For range timestamps like `<2024-12-05 10:00>--<2024-12-06 14:00>` the result is
142/// `(_, Some("2024-12-05"), Some("10:00"), Some("14:00"), _)` — i.e. the second bracket's
143/// start time is treated as `end_time`. For inline ranges `<2024-12-05 10:00-12:00>`
144/// the explicit range form is used.
145// The 5-tuple is grandfathered: callers in `parser.rs` and the test suite
146// already destructure it. A struct refactor is tracked separately and does
147// not block the active-flag addition (ADR-0014).
148#[allow(clippy::type_complexity)]
149/// Convenience wrapper that runs `normalize_weekdays` before delegating to
150/// [`parse_timestamp_fields_normalized`]. Production callers in
151/// `parser::finalize_task` skip this hop because `info.timestamp` is already
152/// weekday-normalised by `extract_timestamp_normalized`; the wrapper is kept
153/// for unit tests that feed Cyrillic input directly.
154#[cfg_attr(not(test), allow(dead_code))]
155pub fn parse_timestamp_fields(
156    timestamp: &str,
157    mappings: &[(&str, &str)],
158) -> (
159    Option<String>,
160    Option<String>,
161    Option<String>,
162    Option<String>,
163    Option<bool>,
164) {
165    let normalized = normalize_weekdays(timestamp, mappings);
166    parse_timestamp_fields_normalized(&normalized)
167}
168
169/// Fast-path companion to `parse_timestamp_fields` for callers that have
170/// already weekday-normalised the input (e.g. `parser::finalize_task`, where
171/// `info.timestamp` was assembled from `extract_timestamp_normalized`'s
172/// regex captures over a `normalize_weekdays` output). Skipping the second
173/// normalisation removes a per-task Aho-Corasick scan on the timestamp
174/// substring.
175#[allow(clippy::type_complexity)]
176pub fn parse_timestamp_fields_normalized(
177    timestamp: &str,
178) -> (
179    Option<String>,
180    Option<String>,
181    Option<String>,
182    Option<String>,
183    Option<bool>,
184) {
185    let ts_type = detect_ts_type(timestamp);
186    let active = detect_active(timestamp);
187
188    // Handle ranges: <...>--<...>
189    if let Some((first, second)) = split_range(timestamp) {
190        let date = DATE_RE.captures(first).map(|c| c[1].to_string());
191        let (time, end_from_first) = extract_time_pair(first);
192        // If the first bracket already has a range like 10:00-12:00 — keep it.
193        // Otherwise use the start time of the second bracket as end_time.
194        let end_time = if end_from_first.is_some() {
195            end_from_first
196        } else {
197            extract_time_pair(second).0
198        };
199        return (ts_type, date, time, end_time, active);
200    }
201
202    let date = DATE_RE.captures(timestamp).map(|c| c[1].to_string());
203    let (time, end_time) = extract_time_pair(timestamp);
204    (ts_type, date, time, end_time, active)
205}
206
207/// Extract the timestamp's repeater as its canonical org string
208/// (`++7d`, `.+1m`, `+1wd`), or `None` when the timestamp carries no
209/// repeater. Input is expected already weekday-normalised (as assembled
210/// by `extract_timestamp_normalized`); a timestamp whose date does not
211/// parse yields `None` (an invalid timestamp is not synced downstream).
212pub fn extract_repeater_normalized(timestamp: &str) -> Option<String> {
213    super::parser::parse_org_timestamp(timestamp, None)?
214        .repeater
215        .map(|r| r.canonical())
216}
217
218fn detect_active(timestamp: &str) -> Option<bool> {
219    // The first `<` or `[` after any keyword prefix decides the form.
220    // Whichever comes first wins; a string with neither yields `None`.
221    let lt = timestamp.find('<');
222    let lb = timestamp.find('[');
223    match (lt, lb) {
224        (Some(i), Some(j)) => Some(i < j),
225        (Some(_), None) => Some(true),
226        (None, Some(_)) => Some(false),
227        (None, None) => None,
228    }
229}
230
231fn detect_ts_type(timestamp: &str) -> Option<String> {
232    // Anchor on the SCHEDULED:/DEADLINE:/CLOSED: prefix at the very start; this
233    // prevents misclassification when the body contains a literal "SCHEDULED:".
234    let trimmed = timestamp.trim_start();
235    if trimmed.starts_with("SCHEDULED:") {
236        Some("SCHEDULED".to_string())
237    } else if trimmed.starts_with("DEADLINE:") {
238        Some("DEADLINE".to_string())
239    } else if trimmed.starts_with("CLOSED:") {
240        Some("CLOSED".to_string())
241    } else {
242        Some("PLAIN".to_string())
243    }
244}
245
246fn split_range(s: &str) -> Option<(&str, &str)> {
247    // Find a "<...>(--?-?)<...>" or "[...](--?-?)[...]" pattern and return
248    // the inner bodies (without brackets). The dash count matches Emacs'
249    // org-tr-regexp: one, two, or three dashes; the canonical wire form is
250    // two. Both endpoints must share a bracket form — mixed pairs are
251    // rejected by construction (ADR-0014), since `strip_prefix(open)` fails
252    // when the second bracket is the opposite kind.
253    let lt = s.find('<');
254    let lb = s.find('[');
255    let (start, open, close) = match (lt, lb) {
256        (Some(i), Some(j)) if i < j => (i, '<', '>'),
257        (Some(_), Some(j)) => (j, '[', ']'),
258        (Some(i), None) => (i, '<', '>'),
259        (None, Some(j)) => (j, '[', ']'),
260        (None, None) => return None,
261    };
262    let after_first = &s[start + 1..];
263    let end_first_rel = after_first.find(close)?;
264    let first_body = &after_first[..end_first_rel];
265    let rest = &after_first[end_first_rel + 1..];
266    let rest = rest.strip_prefix('-')?;
267    let rest = rest.strip_prefix('-').unwrap_or(rest);
268    let rest = rest.strip_prefix('-').unwrap_or(rest);
269    let rest = rest.strip_prefix(open)?;
270    let end_second_rel = rest.find(close)?;
271    let second_body = &rest[..end_second_rel];
272    Some((first_body, second_body))
273}
274
275fn extract_time_pair(s: &str) -> (Option<String>, Option<String>) {
276    if let Some(c) = TIME_RANGE_RE.captures(s) {
277        return (Some(c[1].to_string()), Some(c[2].to_string()));
278    }
279    if let Some(c) = TIME_SINGLE_RE.captures(s) {
280        return (Some(c[1].to_string()), None);
281    }
282    (None, None)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn extract_timestamp(text: &str, mappings: &[(&str, &str)]) -> Option<String> {
290        extract_timestamp_normalized(&normalize_weekdays(text, mappings))
291    }
292    fn extract_created(text: &str, mappings: &[(&str, &str)]) -> Option<String> {
293        extract_created_normalized(&normalize_weekdays(text, mappings))
294    }
295
296    #[test]
297    fn extract_repeater_normalized_covers_flavours_units_and_absence() {
298        // All three prefix flavours round-trip to their canonical form.
299        assert_eq!(
300            extract_repeater_normalized("SCHEDULED: <2026-07-03 Fri 14:00 ++7d>").as_deref(),
301            Some("++7d")
302        );
303        assert_eq!(
304            extract_repeater_normalized("<2026-07-03 Fri +1w>").as_deref(),
305            Some("+1w")
306        );
307        assert_eq!(
308            extract_repeater_normalized("DEADLINE: <2026-07-03 Fri .+1m>").as_deref(),
309            Some(".+1m")
310        );
311        // Workday and hour units keep their suffixes.
312        assert_eq!(
313            extract_repeater_normalized("<2026-07-03 Fri +1wd>").as_deref(),
314            Some("+1wd")
315        );
316        assert_eq!(
317            extract_repeater_normalized("<2026-07-03 Fri 14:00 +2h>").as_deref(),
318            Some("+2h")
319        );
320        // No repeater, and an unparseable date, both yield None.
321        assert_eq!(extract_repeater_normalized("<2026-07-03 Fri 14:00>"), None);
322        assert_eq!(extract_repeater_normalized("not a timestamp"), None);
323    }
324
325    #[test]
326    fn extract_timestamp_normalized_short_circuits_free_text() {
327        // Free-text inline code that cannot start any of the recognised
328        // prefixes (S/D/C/<) must not even reach the regex engine. The
329        // assertion is observable through return value only; the perf
330        // win lives in the absent regex calls. Pin the contract here so
331        // a refactor that drops the prefix gate does not regress quietly.
332        assert!(extract_timestamp_normalized("just some inline text").is_none());
333        assert!(extract_timestamp_normalized("`code that mentions foo bar`").is_none());
334        // Leading whitespace is allowed before the prefix.
335        assert!(extract_timestamp_normalized("    <2024-12-05 Thu>").is_some());
336    }
337
338    #[test]
339    fn extract_created_normalized_short_circuits_free_text() {
340        // CREATED has its own fast path: any leading-non-whitespace that is
341        // not literal `CREATED:` short-circuits before the regex.
342        assert!(extract_created_normalized("inline code without CREATED").is_none());
343        assert!(extract_created_normalized("SCHEDULED: <2024-12-05>").is_none());
344        assert!(extract_created_normalized("CREATED: [2024-12-05 Thu]").is_some());
345    }
346
347    #[test]
348    fn extract_timestamp_simple_scheduled() {
349        let ts = extract_timestamp("SCHEDULED: <2024-12-05 Thu 10:00>", &[]).unwrap();
350        assert_eq!(ts, "SCHEDULED: <2024-12-05 Thu 10:00>");
351    }
352
353    #[test]
354    fn extract_timestamp_range() {
355        let ts = extract_timestamp("<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>", &[]).unwrap();
356        assert_eq!(ts, "<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>");
357    }
358
359    #[test]
360    fn extract_timestamp_range_one_dash() {
361        // Emacs' org-tr-regexp accepts a single dash between the two bracketed
362        // values (`--?-?`). The output is canonicalised back to two dashes,
363        // matching the form produced by Emacs' `org-time-stamp` and the rest of
364        // this project's wire format.
365        let ts = extract_timestamp("<2024-12-05 Thu>-<2024-12-06 Fri>", &[]).unwrap();
366        assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
367    }
368
369    #[test]
370    fn extract_timestamp_range_three_dashes() {
371        let ts = extract_timestamp("<2024-12-05 Thu>---<2024-12-06 Fri>", &[]).unwrap();
372        assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
373    }
374
375    #[test]
376    fn parse_fields_range_one_dash_recovers_second_time() {
377        // Same regression coverage as the two-dash range, but for the single-
378        // dash variant that Emacs also accepts.
379        let (_, date, time, end_time, _) =
380            parse_timestamp_fields("<2024-12-05 Thu 10:00>-<2024-12-06 Fri 14:00>", &[]);
381        assert_eq!(date, Some("2024-12-05".to_string()));
382        assert_eq!(time, Some("10:00".to_string()));
383        assert_eq!(end_time, Some("14:00".to_string()));
384    }
385
386    #[test]
387    fn extract_timestamp_localized_weekday() {
388        let mappings = [("Чт", "Thu")];
389        let ts = extract_timestamp("DEADLINE: <2024-12-05 Чт>", &mappings).unwrap();
390        assert_eq!(ts, "DEADLINE: <2024-12-05 Thu>");
391    }
392
393    #[test]
394    fn extract_created_basic() {
395        // ADR-0014: CREATED follows the org-expiry convention and uses
396        // inactive `[...]`. Angle brackets must not be accepted.
397        let c = extract_created("CREATED: [2024-12-05 Thu]", &[]).unwrap();
398        assert_eq!(c, "CREATED: [2024-12-05 Thu]");
399    }
400
401    #[test]
402    fn extract_created_rejects_angle_brackets() {
403        // ADR-0014: this used to be accepted in 0.4.x. The new policy
404        // pins CREATED to inactive `[...]` to match upstream's org-expiry
405        // convention. This test guards against an accidental revert.
406        assert!(extract_created("CREATED: <2024-12-05 Thu>", &[]).is_none());
407    }
408
409    #[test]
410    fn extract_created_returns_none_on_other() {
411        assert!(extract_created("SCHEDULED: <2024-12-05>", &[]).is_none());
412    }
413
414    #[test]
415    fn parse_fields_scheduled_with_time() {
416        let (ts_type, date, time, end_time, _) =
417            parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu 10:00>", &[]);
418        assert_eq!(ts_type, Some("SCHEDULED".to_string()));
419        assert_eq!(date, Some("2024-12-05".to_string()));
420        assert_eq!(time, Some("10:00".to_string()));
421        assert_eq!(end_time, None);
422    }
423
424    #[test]
425    fn parse_fields_inline_time_range() {
426        let (_, _, time, end_time, _) =
427            parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu 10:00-12:00>", &[]);
428        assert_eq!(time, Some("10:00".to_string()));
429        assert_eq!(end_time, Some("12:00".to_string()));
430    }
431
432    #[test]
433    fn parse_fields_range_timestamp_recovers_second_time() {
434        // Regression: <... 10:00>--<... 14:00> used to lose 14:00. Now it must surface as end_time.
435        let (ts_type, date, time, end_time, _) =
436            parse_timestamp_fields("<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>", &[]);
437        assert_eq!(ts_type, Some("PLAIN".to_string()));
438        assert_eq!(date, Some("2024-12-05".to_string()));
439        assert_eq!(time, Some("10:00".to_string()));
440        assert_eq!(end_time, Some("14:00".to_string()));
441    }
442
443    #[test]
444    fn parse_fields_range_inline_takes_precedence() {
445        // If first bracket already has a 10:00-12:00 range, use it; ignore the second bracket time.
446        let (_, _, time, end_time, _) =
447            parse_timestamp_fields("<2024-12-05 Thu 10:00-12:00>--<2024-12-06 Fri 14:00>", &[]);
448        assert_eq!(time, Some("10:00".to_string()));
449        assert_eq!(end_time, Some("12:00".to_string()));
450    }
451
452    #[test]
453    fn detect_ts_type_does_not_match_body_substring() {
454        // Regression: previously `.contains("SCHEDULED:")` was used and would misclassify
455        // a CREATED timestamp whose body mentioned SCHEDULED.
456        let (ts_type, _, _, _, _) =
457            parse_timestamp_fields("CREATED: [2024-12-05 see SCHEDULED:]", &[]);
458        assert_eq!(ts_type, Some("PLAIN".to_string()));
459    }
460
461    #[test]
462    fn parse_fields_no_time() {
463        let (_, date, time, end_time, _) =
464            parse_timestamp_fields("DEADLINE: <2024-12-05 Thu>", &[]);
465        assert_eq!(date, Some("2024-12-05".to_string()));
466        assert_eq!(time, None);
467        assert_eq!(end_time, None);
468    }
469
470    // ADR-0014: `active` reports the bracket form so consumers can branch
471    // on it. SINGLE_RE accepts only `<...>` today, so production parses
472    // always yield Some(true); the inactive case is constructed directly
473    // from a string to pin `detect_active`'s behaviour for the future
474    // regex update.
475
476    #[test]
477    fn parse_fields_marks_angle_bracket_keyword_as_active() {
478        let (_, _, _, _, active) = parse_timestamp_fields("SCHEDULED: <2024-12-05 Thu>", &[]);
479        assert_eq!(active, Some(true));
480    }
481
482    #[test]
483    fn parse_fields_marks_square_bracket_keyword_as_inactive() {
484        // `parse_timestamp_fields` itself does not gate on the keyword
485        // policy from ADR-0014 — it only reports the form that was seen.
486        // The regex layer (separate task) is what will accept or reject
487        // each keyword/form combination. Pinning behaviour here keeps
488        // `detect_active` honest once the regex change lands.
489        let (_, _, _, _, active) = parse_timestamp_fields("CLOSED: [2024-12-05 Thu 14:30]", &[]);
490        assert_eq!(active, Some(false));
491    }
492
493    #[test]
494    fn parse_fields_marks_inline_plain_active() {
495        let (_, _, _, _, active) = parse_timestamp_fields("<2024-12-05 Thu>", &[]);
496        assert_eq!(active, Some(true));
497    }
498
499    #[test]
500    fn parse_fields_marks_inline_plain_inactive() {
501        let (_, _, _, _, active) = parse_timestamp_fields("[2024-12-05 Thu]", &[]);
502        assert_eq!(active, Some(false));
503    }
504
505    #[test]
506    fn parse_fields_no_bracket_returns_none_active() {
507        // A string with neither `<` nor `[` cannot have a bracket form.
508        let (_, _, _, _, active) = parse_timestamp_fields("not a timestamp", &[]);
509        assert_eq!(active, None);
510    }
511
512    // ADR-0014 matrix: each keyword accepts exactly one bracket form;
513    // mixed pairs `<...]` / `[...>` are rejected; inline plain accepts both.
514    // The tests below pin the policy at the extract layer (regex).
515
516    #[test]
517    fn matrix_scheduled_active_accepted() {
518        let ts = extract_timestamp("SCHEDULED: <2024-12-05 Thu>", &[]).unwrap();
519        assert_eq!(ts, "SCHEDULED: <2024-12-05 Thu>");
520    }
521
522    #[test]
523    fn matrix_scheduled_inactive_rejected() {
524        // SCHEDULED only accepts `<...>` (upstream `org-scheduled-time-regexp`).
525        assert!(extract_timestamp("SCHEDULED: [2024-12-05 Thu]", &[]).is_none());
526    }
527
528    #[test]
529    fn matrix_deadline_active_accepted() {
530        let ts = extract_timestamp("DEADLINE: <2024-12-05 Thu>", &[]).unwrap();
531        assert_eq!(ts, "DEADLINE: <2024-12-05 Thu>");
532    }
533
534    #[test]
535    fn matrix_deadline_inactive_rejected() {
536        // DEADLINE only accepts `<...>` (upstream `org-deadline-time-regexp`).
537        assert!(extract_timestamp("DEADLINE: [2024-12-05 Thu]", &[]).is_none());
538    }
539
540    #[test]
541    fn matrix_closed_inactive_accepted() {
542        // CLOSED accepts only `[...]` (upstream `org-closed-time-regexp`).
543        let ts = extract_timestamp("CLOSED: [2024-12-05 Thu 14:30]", &[]).unwrap();
544        assert_eq!(ts, "CLOSED: [2024-12-05 Thu 14:30]");
545    }
546
547    #[test]
548    fn matrix_closed_active_rejected() {
549        // Breaking change in 0.5.0: CLOSED with angle brackets was accepted
550        // in 0.4.x but does not match upstream Emacs semantics. ADR-0014
551        // moves CLOSED to inactive only; the migration is documented in
552        // CHANGELOG.
553        assert!(extract_timestamp("CLOSED: <2024-12-05 Thu 14:30>", &[]).is_none());
554    }
555
556    #[test]
557    fn matrix_created_inactive_accepted() {
558        // CREATED follows the org-expiry convention (inactive `[...]`).
559        let c = extract_created("CREATED: [2024-12-05 Thu]", &[]).unwrap();
560        assert_eq!(c, "CREATED: [2024-12-05 Thu]");
561    }
562
563    #[test]
564    fn matrix_created_active_rejected() {
565        // Breaking change in 0.5.0 paired with the CLOSED change above.
566        assert!(extract_created("CREATED: <2024-12-05 Thu>", &[]).is_none());
567    }
568
569    #[test]
570    fn matrix_inline_active_accepted() {
571        let ts = extract_timestamp("<2024-12-05 Thu>", &[]).unwrap();
572        assert_eq!(ts, "<2024-12-05 Thu>");
573    }
574
575    #[test]
576    fn matrix_inline_inactive_accepted() {
577        let ts = extract_timestamp("[2024-12-05 Thu]", &[]).unwrap();
578        assert_eq!(ts, "[2024-12-05 Thu]");
579    }
580
581    #[test]
582    fn matrix_inline_mixed_open_angle_close_square_rejected() {
583        // Mixed pairs must not match because each regex uses a single
584        // bracket family (no `[<\[]...[>\]]` shortcut).
585        assert!(extract_timestamp("<2024-12-05 Thu]", &[]).is_none());
586    }
587
588    #[test]
589    fn matrix_inline_mixed_open_square_close_angle_rejected() {
590        assert!(extract_timestamp("[2024-12-05 Thu>", &[]).is_none());
591    }
592
593    #[test]
594    fn matrix_inline_range_active_accepted() {
595        let ts = extract_timestamp("<2024-12-05 Thu>--<2024-12-06 Fri>", &[]).unwrap();
596        assert_eq!(ts, "<2024-12-05 Thu>--<2024-12-06 Fri>");
597    }
598
599    #[test]
600    fn matrix_inline_range_inactive_accepted() {
601        let ts = extract_timestamp("[2024-12-05 Thu]--[2024-12-06 Fri]", &[]).unwrap();
602        assert_eq!(ts, "[2024-12-05 Thu]--[2024-12-06 Fri]");
603    }
604
605    #[test]
606    fn matrix_inline_range_mixed_first_active_falls_back_to_single() {
607        // Mixed-form ranges do not match RANGE_*_RE (each regex sticks to a
608        // single bracket family). The first single timestamp is still
609        // extracted because SIMPLE_ANGLE_RE does not anchor on the end of
610        // the input — the trailing `--[...]` is left as outside context.
611        // This is the same behaviour as `<...>foo bar` matching just `<...>`.
612        let ts = extract_timestamp("<2024-12-05 Thu>--[2024-12-06 Fri]", &[]).unwrap();
613        assert_eq!(ts, "<2024-12-05 Thu>");
614    }
615
616    #[test]
617    fn matrix_inline_range_mixed_first_inactive_falls_back_to_single() {
618        let ts = extract_timestamp("[2024-12-05 Thu]--<2024-12-06 Fri>", &[]).unwrap();
619        assert_eq!(ts, "[2024-12-05 Thu]");
620    }
621
622    #[test]
623    fn timestamp_body_within_limit_is_accepted() {
624        use crate::regex_limits::TS_BODY_MAX;
625        // Build a timestamp whose body length after the date is exactly the cap.
626        // Body chars must satisfy `[^>]`, so use ASCII spaces.
627        let filler = " ".repeat(TS_BODY_MAX);
628        let input = format!("SCHEDULED: <2024-12-05{filler}>");
629        let ts = extract_timestamp(&input, &[]).expect("should match at exactly the cap");
630        // Body length = "2024-12-05" (10) + filler (TS_BODY_MAX).
631        assert!(ts.contains("2024-12-05"));
632        assert_eq!(ts.len(), "SCHEDULED: <>".len() + 10 + TS_BODY_MAX);
633    }
634
635    #[test]
636    fn timestamp_body_just_over_limit_is_rejected() {
637        use crate::regex_limits::TS_BODY_MAX;
638        // One char past the cap and without a closing `>` after the cap window
639        // must NOT match — proves the upper bound is enforced.
640        let filler = " ".repeat(TS_BODY_MAX + 1);
641        let input = format!("SCHEDULED: <2024-12-05{filler}>");
642        assert!(
643            extract_timestamp(&input, &[]).is_none(),
644            "body of TS_BODY_MAX+1 chars must not match"
645        );
646    }
647
648    #[test]
649    fn parse_timestamp_fields_normalized_matches_full_for_already_normalised_input() {
650        // `parse_timestamp_fields_normalized` is the fast-path entry point
651        // used by `parser::finalize_task`, where the input was already
652        // weekday-normalised at extraction time (see `process_node` and
653        // `extract_timestamps_from_node`). Calling the full
654        // `parse_timestamp_fields` on the same input would re-run
655        // `normalize_weekdays`; pinning the equivalence here guards the
656        // refactor against silent semantic drift between the two entry
657        // points.
658        let cases = [
659            "SCHEDULED: <2024-12-05 Thu>",
660            "DEADLINE: <2024-12-05 Thu 10:00>",
661            "<2024-12-05 Thu 10:00-12:00>",
662            "<2024-12-05 Thu 10:00>--<2024-12-06 Fri 14:00>",
663            "CLOSED: [2024-12-05 Thu]",
664            "[2024-12-05 Thu]",
665            "not a timestamp",
666        ];
667        for input in cases {
668            let full = parse_timestamp_fields(input, &[]);
669            let normalised = parse_timestamp_fields_normalized(input);
670            assert_eq!(
671                full, normalised,
672                "_normalized fast path must equal the full variant for already-English input `{input}`"
673            );
674        }
675    }
676}