markdown-org-extract 0.6.0

CLI utility for extracting tasks from markdown files with Emacs Org-mode support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
use regex::Regex;
use std::sync::LazyLock;

use super::weekdays::normalize_weekdays;
use crate::regex_limits::{compile_bounded, TS_BODY_MAX};

// Per-keyword bracket policy (ADR-0014):
//   SCHEDULED:, DEADLINE: → only active `<...>`
//   CLOSED:, CREATED:     → only inactive `[...]` (CREATED follows the
//                           org-expiry convention; CLOSED matches upstream
//                           Emacs `org-closed-string` / `org-closed-time-regexp`)
//   inline plain          → both `<...>` (active) and `[...]` (inactive)
//
// `[^>]{0,TS_BODY_MAX}` (or `[^\]]{0,TS_BODY_MAX}`) caps the body length of a
// single bracketed timestamp so that a hostile or malformed line cannot make
// `[^>]*` scan thousands of characters before the engine notices the missing
// closing bracket. Paired alternation (no `[<\[]...[>\]]` shortcuts) keeps
// mixed pairs `<...]` / `[...>` from matching by construction.
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}}})\]"
    ))
});

// Range-timestamp separator matches Emacs' org-tr-regexp: one, two, or three
// dashes between the two bracketed values. The output is always canonicalised
// to the two-dash form, which is the variant produced by Emacs `org-time-stamp`.
// Both endpoints must share the bracket form (no mixed pairs).
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"));

/// Extract CREATED timestamp from already-weekday-normalized text. Callers in
/// the parser pre-normalize so multiple extractors share one scan; tests pass
/// already-English input.
pub fn extract_created_normalized(text: &str) -> Option<String> {
    // Fast path: every match of CREATED_RE begins with optional whitespace
    // and then literal `CREATED:`. Bail out before paying the regex engine
    // when the leading non-space byte cannot start that keyword.
    if !text.trim_start().starts_with("CREATED:") {
        return None;
    }
    CREATED_RE
        .captures(text)
        .map(|caps| format!("CREATED: [{}]", &caps[1]))
}

/// Extract non-CREATED timestamp from already-weekday-normalized text.
pub fn extract_timestamp_normalized(text: &str) -> Option<String> {
    // Fast path: every regex below anchors to one of the keyword prefixes
    // `SCHEDULED:` / `DEADLINE:` / `CLOSED:` (KEYWORD_ANGLE_RE / CLOSED_SQUARE_RE)
    // or to the literal `<` / `[` of a bare timestamp (RANGE_* / SIMPLE_*).
    // A byte check on the first non-whitespace byte short-circuits the
    // common case where an inline-code line is unrelated free text, sparing
    // several regex compilations of input we cannot match.
    let trimmed = text.trim_start();
    match trimmed.as_bytes().first() {
        Some(b'S' | b'D' | b'C' | b'<' | b'[') => {}
        _ => return None,
    }

    // Keyword forms first: each keyword accepts only one bracket form
    // (ADR-0014). The output preserves the bracket form so consumers can
    // tell `<...>` from `[...]`.
    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]));
    }

    // Plain inline timestamps: ranges before singles (a range starts with a
    // single timestamp's prefix, so SIMPLE_* would otherwise eat the first
    // bracket and leave `--<...>` dangling). Both endpoints share a bracket
    // form by construction; mixed pairs are not matched.
    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
}

/// Parse timestamp fields for JSON output.
///
/// Returns `(timestamp_type, date, time, end_time, active)`.
///
/// `active` is `Some(true)` for an active timestamp `<...>`, `Some(false)`
/// for an inactive one `[...]`, and `None` when the input does not contain
/// a recognisable opening bracket. The bracket form is detected on the
/// first `<` / `[` after the keyword prefix; see ADR-0014 for the
/// per-keyword policy.
///
/// For range timestamps like `<2024-12-05 10:00>--<2024-12-06 14:00>` the result is
/// `(_, Some("2024-12-05"), Some("10:00"), Some("14:00"), _)` — i.e. the second bracket's
/// start time is treated as `end_time`. For inline ranges `<2024-12-05 10:00-12:00>`
/// the explicit range form is used.
// The 5-tuple is grandfathered: callers in `parser.rs` and the test suite
// already destructure it. A struct refactor is tracked separately and does
// not block the active-flag addition (ADR-0014).
#[allow(clippy::type_complexity)]
/// Convenience wrapper that runs `normalize_weekdays` before delegating to
/// [`parse_timestamp_fields_normalized`]. Production callers in
/// `parser::finalize_task` skip this hop because `info.timestamp` is already
/// weekday-normalised by `extract_timestamp_normalized`; the wrapper is kept
/// for unit tests that feed Cyrillic input directly.
#[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)
}

/// Fast-path companion to [`parse_timestamp_fields`] for callers that have
/// already weekday-normalised the input (e.g. `parser::finalize_task`, where
/// `info.timestamp` was assembled from `extract_timestamp_normalized`'s
/// regex captures over a `normalize_weekdays` output). Skipping the second
/// normalisation removes a per-task Aho-Corasick scan on the timestamp
/// substring.
#[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);

    // Handle ranges: <...>--<...>
    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);
        // If the first bracket already has a range like 10:00-12:00 — keep it.
        // Otherwise use the start time of the second bracket as end_time.
        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> {
    // The first `<` or `[` after any keyword prefix decides the form.
    // Whichever comes first wins; a string with neither yields `None`.
    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> {
    // Anchor on the SCHEDULED:/DEADLINE:/CLOSED: prefix at the very start; this
    // prevents misclassification when the body contains a literal "SCHEDULED:".
    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)> {
    // Find a "<...>(--?-?)<...>" or "[...](--?-?)[...]" pattern and return
    // the inner bodies (without brackets). The dash count matches Emacs'
    // org-tr-regexp: one, two, or three dashes; the canonical wire form is
    // two. Both endpoints must share a bracket form — mixed pairs are
    // rejected by construction (ADR-0014), since `strip_prefix(open)` fails
    // when the second bracket is the opposite kind.
    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() {
        // Free-text inline code that cannot start any of the recognised
        // prefixes (S/D/C/<) must not even reach the regex engine. The
        // assertion is observable through return value only; the perf
        // win lives in the absent regex calls. Pin the contract here so
        // a refactor that drops the prefix gate does not regress quietly.
        assert!(extract_timestamp_normalized("just some inline text").is_none());
        assert!(extract_timestamp_normalized("`code that mentions foo bar`").is_none());
        // Leading whitespace is allowed before the prefix.
        assert!(extract_timestamp_normalized("    <2024-12-05 Thu>").is_some());
    }

    #[test]
    fn extract_created_normalized_short_circuits_free_text() {
        // CREATED has its own fast path: any leading-non-whitespace that is
        // not literal `CREATED:` short-circuits before the regex.
        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() {
        // Emacs' org-tr-regexp accepts a single dash between the two bracketed
        // values (`--?-?`). The output is canonicalised back to two dashes,
        // matching the form produced by Emacs' `org-time-stamp` and the rest of
        // this project's wire format.
        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() {
        // Same regression coverage as the two-dash range, but for the single-
        // dash variant that Emacs also accepts.
        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() {
        // ADR-0014: CREATED follows the org-expiry convention and uses
        // inactive `[...]`. Angle brackets must not be accepted.
        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() {
        // ADR-0014: this used to be accepted in 0.4.x. The new policy
        // pins CREATED to inactive `[...]` to match upstream's org-expiry
        // convention. This test guards against an accidental revert.
        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() {
        // Regression: <... 10:00>--<... 14:00> used to lose 14:00. Now it must surface as end_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() {
        // If first bracket already has a 10:00-12:00 range, use it; ignore the second bracket time.
        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() {
        // Regression: previously `.contains("SCHEDULED:")` was used and would misclassify
        // a CREATED timestamp whose body mentioned SCHEDULED.
        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);
    }

    // ADR-0014: `active` reports the bracket form so consumers can branch
    // on it. SINGLE_RE accepts only `<...>` today, so production parses
    // always yield Some(true); the inactive case is constructed directly
    // from a string to pin `detect_active`'s behaviour for the future
    // regex update.

    #[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() {
        // `parse_timestamp_fields` itself does not gate on the keyword
        // policy from ADR-0014 — it only reports the form that was seen.
        // The regex layer (separate task) is what will accept or reject
        // each keyword/form combination. Pinning behaviour here keeps
        // `detect_active` honest once the regex change lands.
        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() {
        // A string with neither `<` nor `[` cannot have a bracket form.
        let (_, _, _, _, active) = parse_timestamp_fields("not a timestamp", &[]);
        assert_eq!(active, None);
    }

    // ADR-0014 matrix: each keyword accepts exactly one bracket form;
    // mixed pairs `<...]` / `[...>` are rejected; inline plain accepts both.
    // The tests below pin the policy at the extract layer (regex).

    #[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() {
        // SCHEDULED only accepts `<...>` (upstream `org-scheduled-time-regexp`).
        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() {
        // DEADLINE only accepts `<...>` (upstream `org-deadline-time-regexp`).
        assert!(extract_timestamp("DEADLINE: [2024-12-05 Thu]", &[]).is_none());
    }

    #[test]
    fn matrix_closed_inactive_accepted() {
        // CLOSED accepts only `[...]` (upstream `org-closed-time-regexp`).
        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() {
        // Breaking change in 0.5.0: CLOSED with angle brackets was accepted
        // in 0.4.x but does not match upstream Emacs semantics. ADR-0014
        // moves CLOSED to inactive only; the migration is documented in
        // CHANGELOG.
        assert!(extract_timestamp("CLOSED: <2024-12-05 Thu 14:30>", &[]).is_none());
    }

    #[test]
    fn matrix_created_inactive_accepted() {
        // CREATED follows the org-expiry convention (inactive `[...]`).
        let c = extract_created("CREATED: [2024-12-05 Thu]", &[]).unwrap();
        assert_eq!(c, "CREATED: [2024-12-05 Thu]");
    }

    #[test]
    fn matrix_created_active_rejected() {
        // Breaking change in 0.5.0 paired with the CLOSED change above.
        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() {
        // Mixed pairs must not match because each regex uses a single
        // bracket family (no `[<\[]...[>\]]` shortcut).
        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() {
        // Mixed-form ranges do not match RANGE_*_RE (each regex sticks to a
        // single bracket family). The first single timestamp is still
        // extracted because SIMPLE_ANGLE_RE does not anchor on the end of
        // the input — the trailing `--[...]` is left as outside context.
        // This is the same behaviour as `<...>foo bar` matching just `<...>`.
        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;
        // Build a timestamp whose body length after the date is exactly the cap.
        // Body chars must satisfy `[^>]`, so use ASCII spaces.
        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");
        // Body length = "2024-12-05" (10) + filler (TS_BODY_MAX).
        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;
        // One char past the cap and without a closing `>` after the cap window
        // must NOT match — proves the upper bound is enforced.
        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() {
        // `parse_timestamp_fields_normalized` is the fast-path entry point
        // used by `parser::finalize_task`, where the input was already
        // weekday-normalised at extraction time (see `process_node` and
        // `extract_timestamps_from_node`). Calling the full
        // `parse_timestamp_fields` on the same input would re-run
        // `normalize_weekdays`; pinning the equivalence here guards the
        // refactor against silent semantic drift between the two entry
        // points.
        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}`"
            );
        }
    }
}