Skip to main content

kanban_core/
datetime_input.rs

1use chrono::{DateTime, NaiveDate, Utc};
2
3pub fn parse_datetime_input(input: &str) -> Result<DateTime<Utc>, String> {
4    let trimmed = input.trim();
5    if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
6        return Ok(dt.with_timezone(&Utc));
7    }
8    if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
9        // Enforce strict ISO 8601 zero-padding: chrono's %m/%d accept 1-2 digits,
10        // so re-format and require an exact match to reject inputs like "2024-1-5".
11        if date.format("%Y-%m-%d").to_string() == trimmed {
12            let naive = date
13                .and_hms_opt(0, 0, 0)
14                .expect("midnight is always a valid time");
15            return Ok(naive.and_utc());
16        }
17    }
18    Err(format!(
19        "Invalid date '{input}'. Supported formats: YYYY-MM-DD or RFC 3339 (e.g. 2024-01-15 or 2024-01-15T14:30:00Z)"
20    ))
21}
22
23#[cfg(test)]
24mod tests {
25    use super::*;
26    use chrono::TimeZone;
27
28    #[test]
29    fn test_parse_yyyy_mm_dd_returns_midnight_utc() {
30        let dt = parse_datetime_input("2024-01-15").unwrap();
31        assert_eq!(dt, Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap());
32    }
33
34    #[test]
35    fn test_parse_full_rfc3339_z_suffix_preserved() {
36        let dt = parse_datetime_input("2024-01-15T14:30:00Z").unwrap();
37        assert_eq!(dt, Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap());
38    }
39
40    #[test]
41    fn test_parse_full_rfc3339_with_offset_normalized_to_utc() {
42        let dt = parse_datetime_input("2024-01-15T16:30:00+02:00").unwrap();
43        assert_eq!(dt, Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap());
44    }
45
46    #[test]
47    fn test_parse_leading_and_trailing_whitespace_tolerated() {
48        let dt = parse_datetime_input("  2024-01-15  ").unwrap();
49        assert_eq!(dt, Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap());
50    }
51
52    #[test]
53    fn test_parse_iso8601_non_zero_padded_date_rejected() {
54        let err = parse_datetime_input("2024-1-5").unwrap_err();
55        assert!(
56            err.contains("2024-1-5"),
57            "error should quote the offending input, got: {err}"
58        );
59    }
60
61    #[test]
62    fn test_parse_garbage_input_returns_descriptive_error() {
63        let err = parse_datetime_input("yesterday").unwrap_err();
64        assert!(
65            err.contains("yesterday"),
66            "error should quote the offending input, got: {err}"
67        );
68        assert!(
69            err.contains("YYYY-MM-DD"),
70            "error should mention the supported format, got: {err}"
71        );
72    }
73
74    #[test]
75    fn test_parse_empty_string_returns_error() {
76        assert!(parse_datetime_input("").is_err());
77    }
78
79    #[test]
80    fn test_parse_whitespace_only_returns_error() {
81        assert!(parse_datetime_input("   ").is_err());
82    }
83
84    #[test]
85    fn test_parse_date_with_invalid_month_returns_error() {
86        assert!(parse_datetime_input("2024-13-01").is_err());
87    }
88
89    #[test]
90    fn test_parse_date_with_invalid_day_returns_error() {
91        assert!(parse_datetime_input("2024-02-30").is_err());
92    }
93}