1use std::sync::OnceLock;
7
8use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
9use regex::Regex;
10
11fn patterns() -> &'static [Regex] {
12 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
13 PATTERNS.get_or_init(|| {
14 [
17 r"\[(\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2})\]\s*$",
18 r"\[(\d{4}-\d{1,2}-\d{1,2})\]\s*$",
19 r"\[(\d{1,2}-\d{1,2} \d{2}:\d{2})\]\s*$",
20 r"\[(\d{1,2}-\d{1,2})\]\s*$",
21 r"\[(\d{2}:\d{2})\]\s*$",
22 ]
23 .iter()
24 .map(|p| Regex::new(p).expect("valid due-date pattern"))
25 .collect()
26 })
27}
28
29pub fn parse(description: &str) -> (String, String) {
31 for re in patterns() {
32 if let Some(captures) = re.captures(description) {
33 let matched = captures
34 .get(0)
35 .expect("due-date pattern always has a full match");
36 let due = captures
37 .get(1)
38 .expect("due-date pattern always captures the value")
39 .as_str()
40 .to_string();
41 let mut rest = String::with_capacity(description.len());
42 rest.push_str(&description[..matched.start()]);
43 rest.push_str(&description[matched.end()..]);
44 return (due, rest.trim().to_string());
45 }
46 }
47 (String::new(), description.trim().to_string())
48}
49
50pub fn is_valid(text: &str) -> bool {
53 normalize_for_write(text).is_ok()
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct InvalidDue {
58 value: String,
59}
60
61impl std::fmt::Display for InvalidDue {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(
64 f,
65 "invalid due {:?}; use YYYY-MM-DD, MM-DD, or HH:MM, optionally with a time",
66 self.value
67 )
68 }
69}
70
71impl std::error::Error for InvalidDue {}
72
73pub fn normalize_for_write(text: &str) -> Result<String, InvalidDue> {
80 normalize_for_write_at(text, Local::now().naive_local())
81}
82
83pub fn normalize_for_write_at(text: &str, now: NaiveDateTime) -> Result<String, InvalidDue> {
84 normalize_at(text, now, true)
85}
86
87pub fn normalize_legacy_at(text: &str, now: NaiveDateTime) -> Result<String, InvalidDue> {
91 normalize_at(text, now, false)
92}
93
94fn normalize_at(
95 text: &str,
96 now: NaiveDateTime,
97 roll_shorthand_forward: bool,
98) -> Result<String, InvalidDue> {
99 let original = text.trim();
100 if original.is_empty() {
101 return Ok(String::new());
102 }
103 let input = original.replace('T', " ");
104 let (matched, rest) = parse(&format!("[{input}]"));
105 if matched != input || !rest.is_empty() {
106 return Err(InvalidDue {
107 value: original.to_string(),
108 });
109 }
110
111 let (date_part, time_part) = match input.split_once(' ') {
112 Some((date, time)) if !date.is_empty() && !time.is_empty() => (date, Some(time)),
113 None if input.contains(':') => ("", Some(input.as_str())),
114 None => (input.as_str(), None),
115 _ => {
116 return Err(InvalidDue {
117 value: original.to_string(),
118 });
119 }
120 };
121 let time = match time_part {
122 Some(value) => Some(parse_time(value).ok_or_else(|| InvalidDue {
123 value: original.to_string(),
124 })?),
125 None => None,
126 };
127
128 let pieces: Vec<&str> = date_part.split('-').collect();
129 let date = match pieces.as_slice() {
130 [year, month, day] => {
131 let year = year.parse::<i32>().ok();
132 let month = month.parse::<u32>().ok();
133 let day = day.parse::<u32>().ok();
134 match (year, month, day) {
135 (Some(year), Some(month), Some(day)) => NaiveDate::from_ymd_opt(year, month, day),
136 _ => None,
137 }
138 .ok_or_else(|| InvalidDue {
139 value: original.to_string(),
140 })?
141 }
142 [month, day] => {
143 let month = month.parse::<u32>().ok();
144 let day = day.parse::<u32>().ok();
145 let (Some(month), Some(day)) = (month, day) else {
146 return Err(InvalidDue {
147 value: original.to_string(),
148 });
149 };
150 if roll_shorthand_forward {
151 next_month_day(month, day, time, now)
152 } else {
153 month_day_in_migration_year(month, day, now)
154 }
155 .ok_or_else(|| InvalidDue {
156 value: original.to_string(),
157 })?
158 }
159 [""] => {
160 let Some(clock) = time else {
161 return Err(InvalidDue {
162 value: original.to_string(),
163 });
164 };
165 let today = now.date();
166 let candidate = today.and_time(clock);
167 if !roll_shorthand_forward || candidate >= now {
168 today
169 } else {
170 today.succ_opt().ok_or_else(|| InvalidDue {
171 value: original.to_string(),
172 })?
173 }
174 }
175 _ => {
176 return Err(InvalidDue {
177 value: original.to_string(),
178 });
179 }
180 };
181
182 Ok(match time {
183 Some(time) => format!("{} {}", date.format("%Y-%m-%d"), time.format("%H:%M")),
184 None => date.format("%Y-%m-%d").to_string(),
185 })
186}
187
188fn month_day_in_migration_year(month: u32, day: u32, now: NaiveDateTime) -> Option<NaiveDate> {
189 NaiveDate::from_ymd_opt(now.year(), month, day)
190}
191
192pub(crate) fn parse_time(value: &str) -> Option<NaiveTime> {
193 if value.len() != 5 || value.as_bytes().get(2) != Some(&b':') {
194 return None;
195 }
196 let hour = value.get(..2)?.parse().ok()?;
197 let minute = value.get(3..)?.parse().ok()?;
198 NaiveTime::from_hms_opt(hour, minute, 0)
199}
200
201fn next_month_day(
202 month: u32,
203 day: u32,
204 time: Option<NaiveTime>,
205 now: NaiveDateTime,
206) -> Option<NaiveDate> {
207 for offset in 0..=8 {
210 let year = now.year().checked_add(offset)?;
211 let Some(candidate) = NaiveDate::from_ymd_opt(year, month, day) else {
212 continue;
213 };
214 let is_future = match time {
215 Some(clock) => candidate.and_time(clock) >= now,
216 None => candidate >= now.date(),
217 };
218 if is_future {
219 return Some(candidate);
220 }
221 }
222 None
223}
224
225pub fn is_today(due: &str) -> bool {
226 relative_day(due) == Some(0)
227}
228
229fn relative_day(due: &str) -> Option<i64> {
231 if due.is_empty() {
232 return None;
233 }
234 if due.len() == 5 && due.contains(':') {
236 return Some(0);
237 }
238 let (y, mo, d, _, _) = sort_key(due)?;
239 relative_date(y, mo, d)
240}
241
242fn relative_date(year: i32, month: u32, day: u32) -> Option<i64> {
243 relative_date_from(year, month, day, Local::now().date_naive())
244}
245
246fn relative_date_from(year: i32, month: u32, day: u32, today: NaiveDate) -> Option<i64> {
247 let date = NaiveDate::from_ymd_opt(year, month, day)?;
248 Some((date - today).num_days())
249}
250
251pub fn display(due: &str, date_format: &str) -> String {
255 if due.is_empty() {
256 return String::new();
257 }
258 let Some((y, mo, d, h, mi)) = sort_key(due) else {
259 return format!("[{due}]");
260 };
261 let relative = if due.len() == 5 && due.contains(':') {
262 Some(0)
263 } else {
264 relative_date(y, mo, d)
265 };
266 let label = match relative {
267 Some(0) => "Today".to_string(),
268 Some(1) => "Tomorrow".to_string(),
269 Some(-1) => "Yesterday".to_string(),
270 _ => format_date(y, mo, d, date_format),
271 };
272 let text = if due.contains(':') {
273 format!("{label} {h:02}:{mi:02}")
274 } else {
275 label
276 };
277 format!("[{text}]")
278}
279
280pub fn display_compact(due: &str, date_format: &str) -> String {
286 display_compact_at(due, date_format, Local::now().date_naive())
287}
288
289pub(crate) fn display_compact_at(due: &str, date_format: &str, today: NaiveDate) -> String {
290 if due.is_empty() {
291 return String::new();
292 }
293 let Some((y, mo, d, h, mi)) = sort_key(due) else {
294 return due.to_string();
295 };
296 let relative = if due.len() == 5 && due.contains(':') {
297 Some(0)
298 } else {
299 relative_date_from(y, mo, d, today)
300 };
301 let label = match relative {
302 Some(0) => "Today".to_string(),
303 Some(1) => "Tomorrow".to_string(),
304 Some(-1) => "Yesterday".to_string(),
305 _ => format_compact_date(y, mo, d, date_format, today.year()),
306 };
307 if due.contains(':') {
308 format!("{label} {h:02}:{mi:02}")
309 } else {
310 label
311 }
312}
313
314fn format_date(year: i32, month: u32, day: u32, date_format: &str) -> String {
315 match date_format {
316 "D-M-Y" => format!("{day:02}-{month:02}-{year}"),
317 "M-D-Y" => format!("{month:02}-{day:02}-{year}"),
318 _ => format!("{year}-{month:02}-{day:02}"),
319 }
320}
321
322fn format_compact_date(
323 year: i32,
324 month: u32,
325 day: u32,
326 date_format: &str,
327 current_year: i32,
328) -> String {
329 if year == current_year {
330 return match date_format {
331 "D-M-Y" => format!("{day:02}-{month:02}"),
332 _ => format!("{month:02}-{day:02}"),
333 };
334 }
335
336 let year = year.rem_euclid(100);
337 match date_format {
338 "D-M-Y" => format!("{day:02}-{month:02}-{year:02}"),
339 "M-D-Y" => format!("{month:02}-{day:02}-{year:02}"),
340 _ => format!("{year:02}-{month:02}-{day:02}"),
341 }
342}
343
344pub fn sort_key(due: &str) -> Option<(i32, u32, u32, u32, u32)> {
349 sort_key_at(due, Local::now().date_naive())
350}
351
352pub(crate) fn sort_key_at(due: &str, today: NaiveDate) -> Option<(i32, u32, u32, u32, u32)> {
353 if due.is_empty() {
354 return None;
355 }
356 let (date_part, time_part) = match due.split_once(' ') {
357 Some((date, time)) => (date, Some(time)),
358 None if due.contains(':') => ("", Some(due)),
359 None => (due, None),
360 };
361
362 let date = match date_part.split('-').collect::<Vec<_>>().as_slice() {
363 [y, m, d] => NaiveDate::from_ymd_opt(y.parse().ok()?, m.parse().ok()?, d.parse().ok()?)?,
364 [m, d] => NaiveDate::from_ymd_opt(today.year(), m.parse().ok()?, d.parse().ok()?)?,
365 [""] if time_part.is_some() => today,
366 _ => return None,
367 };
368 let time = match time_part {
369 Some(value) => parse_time(value)?,
370 None => NaiveTime::from_hms_opt(0, 0, 0)?,
371 };
372 Some((
373 date.year(),
374 date.month(),
375 date.day(),
376 time.hour(),
377 time.minute(),
378 ))
379}
380
381pub fn now_string(date_format: &str) -> String {
383 let now = Local::now();
384 let date = match date_format {
385 "D-M-Y" => now.format("%d-%m-%Y"),
386 "M-D-Y" => now.format("%m-%d-%Y"),
387 _ => now.format("%Y-%m-%d"),
388 };
389 format!("{} {}", date, now.format("%H:%M"))
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use chrono::Local;
396
397 #[test]
398 fn parses_every_supported_shape() {
399 let cases = [
400 (
401 "buy milk [2026-08-06 09:00]",
402 "2026-08-06 09:00",
403 "buy milk",
404 ),
405 ("buy milk [2026-8-6]", "2026-8-6", "buy milk"),
406 ("buy milk [08-06 09:00]", "08-06 09:00", "buy milk"),
407 ("buy milk [8-6]", "8-6", "buy milk"),
408 ("buy milk [09:00]", "09:00", "buy milk"),
409 ];
410 for (input, due, rest) in cases {
411 assert_eq!(parse(input), (due.to_string(), rest.to_string()), "{input}");
412 }
413 }
414
415 #[test]
416 fn leaves_plain_descriptions_alone() {
417 assert_eq!(
418 parse("read [the] book"),
419 (String::new(), "read [the] book".to_string())
420 );
421 assert_eq!(
422 parse("plan [2030-01-02] launch"),
423 (String::new(), "plan [2030-01-02] launch".to_string()),
424 "only a trailing date token is due syntax"
425 );
426 }
427
428 #[test]
429 fn sorts_across_the_different_shapes() {
430 let year = Local::now().year();
431 assert_eq!(sort_key(""), None);
432 assert_eq!(sort_key("2030-01-02 09:30"), Some((2030, 1, 2, 9, 30)));
433 assert_eq!(sort_key("2030-01-02"), Some((2030, 1, 2, 0, 0)));
434 assert_eq!(sort_key("12-25"), Some((year, 12, 25, 0, 0)));
435 assert!(
436 sort_key("12-25") > sort_key("01-02"),
437 "December comes after January of the same year"
438 );
439 assert!(
440 sort_key(&format!("{}-01-01", year + 1)) > sort_key("12-25"),
441 "next year comes after this December"
442 );
443 }
444
445 #[test]
446 fn invalid_due_values_do_not_masquerade_as_today() {
447 for value in ["garbage", "2030-99-99", "25:99"] {
448 assert_eq!(sort_key(value), None, "{value}");
449 assert_eq!(display(value, "Y-M-D"), format!("[{value}]"));
450 }
451 }
452
453 #[test]
454 fn validates_bare_dates() {
455 assert!(is_valid(""));
456 assert!(is_valid("09:00"));
457 assert!(is_valid("2030-01-02"));
458 assert!(is_valid("01-02 09:00"));
459 assert!(is_valid("8-6"));
461 assert!(is_valid("8-6 14:30"));
462 assert!(is_valid("2026-8-6 14:30"));
463 assert!(!is_valid("next tuesday"));
464 assert!(!is_valid("2030/01/02"));
465 assert!(!is_valid("09:00 and more"));
466 }
467
468 #[test]
469 fn rejects_digits_that_are_not_a_real_date_or_time() {
470 assert!(!is_valid("2026-99-99"), "month 99");
471 assert!(!is_valid("2026-00-00"), "month and day zero");
472 assert!(!is_valid("2026-02-30"), "February has no 30th");
473 assert!(!is_valid("25:99"), "hour and minute out of range");
474 assert!(!is_valid("2026-08-10 30:70"));
475 assert!(is_valid("2028-02-29"), "a real leap day still passes");
476 }
477
478 #[test]
479 fn normalizes_shorthand_to_the_next_absolute_moment() {
480 let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
481 .unwrap()
482 .and_hms_opt(12, 0, 0)
483 .unwrap();
484
485 assert_eq!(normalize_for_write_at("8-8", now).unwrap(), "2026-08-08");
486 assert_eq!(normalize_for_write_at("8-7", now).unwrap(), "2027-08-07");
487 assert_eq!(
488 normalize_for_write_at("8-8 13:00", now).unwrap(),
489 "2026-08-08 13:00"
490 );
491 assert_eq!(
492 normalize_for_write_at("8-8 11:00", now).unwrap(),
493 "2027-08-08 11:00"
494 );
495 assert_eq!(
496 normalize_for_write_at("13:00", now).unwrap(),
497 "2026-08-08 13:00"
498 );
499 assert_eq!(
500 normalize_for_write_at("11:00", now).unwrap(),
501 "2026-08-09 11:00"
502 );
503 }
504
505 #[test]
506 fn absolute_due_values_are_canonicalized_without_rolling_forward() {
507 let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
508 .unwrap()
509 .and_hms_opt(12, 0, 0)
510 .unwrap();
511
512 assert_eq!(
513 normalize_for_write_at("2020-1-2", now).unwrap(),
514 "2020-01-02"
515 );
516 assert_eq!(
517 normalize_for_write_at("2020-1-2 03:04", now).unwrap(),
518 "2020-01-02 03:04"
519 );
520 assert!(normalize_for_write_at("2026-02-30", now).is_err());
521 }
522
523 #[test]
524 fn legacy_shorthand_is_frozen_to_the_migration_day_and_year() {
525 let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
526 .unwrap()
527 .and_hms_opt(12, 0, 0)
528 .unwrap();
529
530 assert_eq!(normalize_legacy_at("8-7", now).unwrap(), "2026-08-07");
531 assert_eq!(
532 normalize_legacy_at("11:00", now).unwrap(),
533 "2026-08-08 11:00"
534 );
535 }
536
537 #[test]
538 fn today_is_labelled() {
539 let today = Local::now().date_naive();
540 let today_s = today.format("%Y-%m-%d").to_string();
541 assert!(is_today(&today_s));
542 assert_eq!(display(&today_s, "Y-M-D"), "[Today]");
543 assert_eq!(
544 display(&format!("{today_s} 07:30"), "Y-M-D"),
545 "[Today 07:30]"
546 );
547 assert_eq!(display("09:00", "D-M-Y"), "[Today 09:00]");
548 assert_eq!(display("2000-01-01", "Y-M-D"), "[2000-01-01]");
549 assert_eq!(display("2000-01-01", "D-M-Y"), "[01-01-2000]");
550 assert_eq!(display("2000-01-01 09:30", "M-D-Y"), "[01-01-2000 09:30]");
551 assert_eq!(display("", "Y-M-D"), "");
552
553 let tom = (today + chrono::Duration::days(1))
554 .format("%Y-%m-%d")
555 .to_string();
556 let yest = (today - chrono::Duration::days(1))
557 .format("%Y-%m-%d")
558 .to_string();
559 assert_eq!(display(&tom, "Y-M-D"), "[Tomorrow]");
560 assert_eq!(
561 display(&format!("{tom} 09:00"), "Y-M-D"),
562 "[Tomorrow 09:00]"
563 );
564 assert_eq!(display(&yest, "Y-M-D"), "[Yesterday]");
565 assert_eq!(
566 display(&format!("{yest} 18:00"), "D-M-Y"),
567 "[Yesterday 18:00]"
568 );
569 }
570
571 #[test]
572 fn compact_display_keeps_relative_dates_and_shortens_calendar_dates() {
573 let today = NaiveDate::from_ymd_opt(2026, 8, 9).unwrap();
574
575 assert_eq!(display_compact_at("2026-08-09", "Y-M-D", today), "Today");
576 assert_eq!(
577 display_compact_at("2026-08-10 09:00", "Y-M-D", today),
578 "Tomorrow 09:00"
579 );
580 assert_eq!(
581 display_compact_at("2026-08-08 18:00", "Y-M-D", today),
582 "Yesterday 18:00"
583 );
584
585 assert_eq!(
586 display_compact_at("2026-08-14 18:25", "Y-M-D", today),
587 "08-14 18:25"
588 );
589 assert_eq!(display_compact_at("2026-08-14", "D-M-Y", today), "14-08");
590 assert_eq!(display_compact_at("2027-01-02", "Y-M-D", today), "27-01-02");
591 assert_eq!(display_compact_at("2027-01-02", "D-M-Y", today), "02-01-27");
592 assert_eq!(display_compact_at("", "M-D-Y", today), "");
593 }
594}