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