1use regex::Regex;
2use std::sync::LazyLock;
3
4use super::weekdays::normalize_weekdays;
5use crate::regex_limits::{compile_bounded, TS_BODY_MAX};
6
7static 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
31static 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
72pub fn extract_created_normalized(text: &str) -> Option<String> {
76 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
87pub fn extract_timestamp_normalized(text: &str) -> Option<String> {
89 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 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 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#[allow(clippy::type_complexity)]
149#[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#[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 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 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
207pub 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 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 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 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 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 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 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 assert!(extract_timestamp_normalized("just some inline text").is_none());
333 assert!(extract_timestamp_normalized("`code that mentions foo bar`").is_none());
334 assert!(extract_timestamp_normalized(" <2024-12-05 Thu>").is_some());
336 }
337
338 #[test]
339 fn extract_created_normalized_short_circuits_free_text() {
340 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 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 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 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 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 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 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 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 #[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 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 let (_, _, _, _, active) = parse_timestamp_fields("not a timestamp", &[]);
509 assert_eq!(active, None);
510 }
511
512 #[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 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 assert!(extract_timestamp("DEADLINE: [2024-12-05 Thu]", &[]).is_none());
538 }
539
540 #[test]
541 fn matrix_closed_inactive_accepted() {
542 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 assert!(extract_timestamp("CLOSED: <2024-12-05 Thu 14:30>", &[]).is_none());
554 }
555
556 #[test]
557 fn matrix_created_inactive_accepted() {
558 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 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 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 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 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 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 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 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}