Skip to main content

tiller_sync/model/
date.rs

1//! This is our `Date` object that serializes as `YYYY-MM-DD` for best results in SQLite. When we
2//! upload to the Tiller Google sheet, we need it formatted as `M/D/YYYY` per Tiller's
3//! specifications.
4
5use crate::error::{ErrorType, IntoResult, Res};
6use crate::TillerError;
7use anyhow::bail;
8use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime};
9use schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
10use sqlx::encode::IsNull;
11use sqlx::error::BoxDynError;
12use sqlx::sqlite::{SqliteArgumentValue, SqliteTypeInfo, SqliteValueRef};
13use sqlx::{Decode, Encode, Sqlite, Type};
14use std::borrow::Cow;
15use std::fmt::{Debug, Display, Formatter};
16use std::str::FromStr;
17
18/// A date value that serializes in YYYY-MM-DD format.
19#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
20pub enum Date {
21    Naive(NaiveDate),
22    NaiveTime(NaiveDateTime),
23    Timestamp(DateTime<FixedOffset>),
24}
25
26impl Type<Sqlite> for Date {
27    fn type_info() -> SqliteTypeInfo {
28        <String as Type<Sqlite>>::type_info()
29    }
30
31    fn compatible(ty: &SqliteTypeInfo) -> bool {
32        <String as Type<Sqlite>>::compatible(ty)
33    }
34}
35
36impl Encode<'_, Sqlite> for Date {
37    fn encode_by_ref(&self, buf: &mut Vec<SqliteArgumentValue<'_>>) -> Result<IsNull, BoxDynError> {
38        Encode::<Sqlite>::encode(self.to_string(), buf)
39    }
40}
41
42impl Decode<'_, Sqlite> for Date {
43    fn decode(value: SqliteValueRef<'_>) -> Result<Self, BoxDynError> {
44        let s = <String as Decode<Sqlite>>::decode(value)?;
45        Date::parse(&s).map_err(|e| e.into())
46    }
47}
48
49impl JsonSchema for Date {
50    fn schema_name() -> Cow<'static, str> {
51        "Date".into()
52    }
53
54    fn json_schema(_: &mut SchemaGenerator) -> Schema {
55        json_schema!({
56            "type": "string",
57            "format": "date",
58            "description": "A date in YYYY-MM-DD format (e.g., 2025-01-23), or ISO 8601 RFC 3339"
59        })
60    }
61}
62
63impl Default for Date {
64    fn default() -> Self {
65        Date::Naive(NaiveDate::from_ymd_opt(1999, 12, 31).unwrap_or_default())
66    }
67}
68
69impl Date {
70    pub fn parse(s: impl AsRef<str>) -> Res<Self> {
71        let s = s.as_ref();
72
73        if let Some(d) = NAIVE_DATE_FORMATS
74            .iter()
75            .find_map(|&fmt| NaiveDate::parse_from_str(s, fmt).ok())
76        {
77            return Ok(Date::Naive(d));
78        }
79
80        if let Some(d) = NAIVE_DATE_TIME_FORMATS
81            .iter()
82            .find_map(|&fmt| NaiveDateTime::parse_from_str(s, fmt).ok())
83        {
84            return Ok(Date::NaiveTime(d));
85        }
86
87        if let Some(d) = DATE_TIME_FORMATS
88            .iter()
89            .find_map(|&fmt| DateTime::parse_from_str(s, fmt).ok())
90        {
91            return Ok(Date::Timestamp(d));
92        }
93
94        // Try RFC 3339 (handles Z suffix and standard timezone offsets)
95        if let Ok(d) = DateTime::parse_from_rfc3339(s) {
96            return Ok(Date::Timestamp(d));
97        }
98
99        bail!("Unable to parse {s} as a date")
100    }
101
102    /// Print the date in the format that Tiller uses in the spreadsheet.
103    /// - `6/30/2025` (i.e. US Date)
104    /// - `01/21/2026 4:37:48 AM` (i.e. US Date plush AM/PM time without timezone)
105    ///
106    /// Note: we will lose time zone information in the Google sheet so please don't expect it or
107    /// use it for anything important!
108    pub(crate) fn to_sheet_string(&self, y: Y) -> String {
109        match y {
110            Y::Y2 => match self {
111                Date::Naive(d) => d.format("%-m/%-d/%y").to_string(),
112                Date::NaiveTime(d) => d.format("%-m/%-d/%y %-I:%M:%S %p").to_string(),
113                Date::Timestamp(d) => d.format("%-m/%-d/%y %-I:%M:%S %p").to_string(),
114            },
115            Y::Y4 => match self {
116                Date::Naive(d) => d.format("%m/%d/%Y").to_string(),
117                Date::NaiveTime(d) => d.format("%m/%d/%Y %I:%M:%S %p").to_string(),
118                Date::Timestamp(d) => d.format("%m/%d/%Y %I:%M:%S %p").to_string(),
119            },
120        }
121    }
122
123    /// Convenience for deserializing from the database.
124    fn from_opt(o: Option<String>) -> Res<Option<Self>> {
125        match o {
126            None => Ok(None),
127            Some(s) => Self::from_opt_s(s),
128        }
129    }
130
131    /// For deserializing strings that may be empty.
132    fn from_opt_s(s: impl AsRef<str>) -> Res<Option<Self>> {
133        let s = s.as_ref();
134        if s.is_empty() {
135            Ok(None)
136        } else {
137            Ok(Some(Self::parse(s)?))
138        }
139    }
140}
141
142/// Whether the date should be printed with a 2-digit or 4-digit year.
143#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
144pub(crate) enum Y {
145    /// Two digit year
146    Y2,
147    /// Four digit year
148    Y4,
149}
150
151impl TryFrom<String> for Date {
152    type Error = TillerError;
153
154    fn try_from(value: String) -> Result<Self, Self::Error> {
155        Self::parse(value).pub_result(ErrorType::Internal)
156    }
157}
158
159impl TryFrom<&str> for Date {
160    type Error = TillerError;
161
162    fn try_from(value: &str) -> Result<Self, Self::Error> {
163        Self::parse(value).pub_result(ErrorType::Internal)
164    }
165}
166
167impl Display for Date {
168    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
169        let s = match self {
170            Date::Naive(d) => d.format("%Y-%m-%d").to_string(),
171            Date::NaiveTime(d) => d.format("%Y-%m-%dT%H:%M:%S").to_string(),
172            Date::Timestamp(d) => d.to_rfc3339(),
173        };
174        Display::fmt(&s, f)
175    }
176}
177
178impl FromStr for Date {
179    type Err = TillerError;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Self::parse(s).pub_result(ErrorType::Internal)
183    }
184}
185
186/// This trait allows us to parse a date from an option conveniently.
187pub(crate) trait DateFromOpt: Sized {
188    fn date_from_opt(self) -> Res<Option<Date>>;
189}
190
191impl<S> DateFromOpt for Option<S>
192where
193    S: AsRef<str> + Sized,
194{
195    fn date_from_opt(self) -> Res<Option<Date>> {
196        let o = self.map(|s| s.as_ref().to_string());
197        Date::from_opt(o)
198    }
199}
200
201/// This allows us to conveniently parse a Date from a string that might be empty, where we want to
202/// treat the empty string as `None`.
203pub(crate) trait DateFromOptStr: Sized {
204    fn date_from_opt_s(self) -> Res<Option<Date>>;
205}
206
207impl<S> DateFromOptStr for S
208where
209    S: AsRef<str> + Sized,
210{
211    fn date_from_opt_s(self) -> Res<Option<Date>> {
212        Date::from_opt_s(self)
213    }
214}
215
216/// This is a convenient serialization function to print out the date for Google sheet upload where
217/// we want it in the US Date format (`5/30/2025`)) to match Tiller, and if the Option is `None`
218/// then we want an empty string.
219pub(crate) trait DateToSheetStr {
220    fn d_to_s(&self, y: Y) -> String;
221}
222
223impl DateToSheetStr for Date {
224    fn d_to_s(&self, y: Y) -> String {
225        self.to_sheet_string(y)
226    }
227}
228
229impl DateToSheetStr for &Date {
230    fn d_to_s(&self, y: Y) -> String {
231        self.to_sheet_string(y)
232    }
233}
234
235impl DateToSheetStr for Option<Date> {
236    fn d_to_s(&self, y: Y) -> String {
237        self.as_ref()
238            .map(|d| d.to_sheet_string(y))
239            .unwrap_or_default()
240    }
241}
242
243impl DateToSheetStr for Option<&Date> {
244    fn d_to_s(&self, y: Y) -> String {
245        self.map(|d| d.to_sheet_string(y)).unwrap_or_default()
246    }
247}
248
249impl DateToSheetStr for &Option<Date> {
250    fn d_to_s(&self, y: Y) -> String {
251        self.as_ref()
252            .map(|d| d.to_sheet_string(y))
253            .unwrap_or_default()
254    }
255}
256
257serde_plain::derive_deserialize_from_fromstr!(Date, "Valid date in M/D/YYYY or YYYY-MM-DD");
258serde_plain::derive_serialize_from_display!(Date);
259
260const NAIVE_DATE_FORMATS: [&str; 23] = [
261    // ISO 8601 / RFC 3339 variants
262    "%Y-%m-%d", // 2025-01-24
263    "%Y%m%d",   // 20250124
264    // US formats
265    "%m/%d/%y", // 01/24/25
266    "%m-%d-%y", // 01-24-25
267    "%m/%d/%Y", // 01/24/2025
268    "%m-%d-%Y", // 01-24-2025
269    "%m.%d.%Y", // 01.24.2025
270    // European formats
271    "%d/%m/%y", // 24/01/25
272    "%d-%m-%y", // 24-01-25
273    "%d.%m.%y", // 24.01.25
274    "%d/%m/%Y", // 24/01/2025
275    "%d-%m-%Y", // 24-01-2025
276    "%d.%m.%Y", // 24.01.2025
277    // Written months (English)
278    "%d-%b-%y",  // 24-Jan-25
279    "%B %d, %Y", // January 24, 2025
280    "%b %d, %Y", // Jan 24, 2025
281    "%d %B %Y",  // 24 January 2025
282    "%d %b %Y",  // 24 Jan 2025
283    "%B %d %Y",  // January 24 2025
284    "%b %d %Y",  // Jan 24 2025
285    "%d-%b-%Y",  // 24-Jan-2025
286    // Misc
287    "%Y/%m/%d", // 2025/01/24
288    "%Y.%m.%d", // 2025.01.24
289];
290
291const NAIVE_DATE_TIME_FORMATS: [&str; 12] = [
292    // ISO 8601 / RFC 3339 variants
293    "%Y-%m-%dT%H:%M:%S",    // 2025-01-24T14:30:00
294    "%Y-%m-%dT%H:%M:%S%.f", // 2025-01-24T14:30:00.123456
295    "%Y%m%dT%H%M%S",        // 20250124T143000
296    // With time (common)
297    "%Y-%m-%d %H:%M:%S",    // 2025-01-24 14:30:00
298    "%Y-%m-%d %H:%M",       // 2025-01-24 14:30
299    "%m/%d/%y %H:%M:%S",    // 01/24/25 14:30:00
300    "%m/%d/%y %I:%M:%S %p", // 01/24/25 02:30:00 PM
301    "%d/%m/%y %H:%M:%S",    // 24/01/25 14:30:00
302    "%m/%d/%Y %H:%M:%S",    // 01/24/2025 14:30:00
303    "%m/%d/%Y %I:%M:%S %p", // 01/24/2025 02:30:00 PM
304    "%d/%m/%Y %H:%M:%S",    // 24/01/2025 14:30:00
305    // Unix/log style
306    "%b %d %H:%M:%S %Y", // Jan 24 14:30:00 2025
307];
308
309const DATE_TIME_FORMATS: [&str; 12] = [
310    // ISO 8601 / RFC 3339 variants
311    "%Y-%m-%dT%H:%M:%S%Z",    // 2025-01-24T14:30:00Z
312    "%Y-%m-%dT%H:%M:%S%z",    // 2025-01-24T14:30:00+0000
313    "%Y-%m-%dT%H:%M:%S%.f%Z", // 2025-01-24T14:30:00.123456Z
314    "%Y-%m-%dT%H:%M:%S%.f%z", // 2025-01-24T14:30:00.123456+0000
315    "%Y%m%dT%H%M%S%Z",        // 20250124T143000Z
316    "%Y%m%dT%H%M%S%z",        // 20250124T143000+0000
317    "%Y-%m-%d %H:%M:%S%Z",    // 2025-01-24 14:30:00Z
318    "%Y-%m-%d %H:%M:%S%z",    // 2025-01-24 14:30:00+0000
319    "%Y-%m-%d %H:%M:%S%.f%Z", // 2025-01-24 14:30:00.123456Z
320    "%Y-%m-%d %H:%M:%S%.f%z", // 2025-01-24 14:30:00.123456+0000
321    "%Y%m%d %H%M%S%Z",        // 20250124 143000Z
322    "%Y%m%d %H%M%S%z",        // 20250124 143000+0000
323];
324
325#[cfg(test)]
326mod test {
327    use super::*;
328
329    fn success_case(input: &str, expected: &str, sheet_y2: &str, sheet_y4: &str) {
330        let text = format!("Test failure parsing {input} and expecting {expected}");
331        let actual = Date::parse(&input).expect(&text);
332        assert_eq!(expected, actual.to_string());
333
334        let json_str = format!("[\"{input}\"]");
335        let arr: Vec<Date> = serde_json::from_str(&json_str).expect(&format!(
336            "{text}: the json '{json_str}' could not be deserialized"
337        ));
338        let serialized =
339            serde_json::to_string(&arr).expect(&format!("{text}, unable to serialize"));
340        let json_expected = format!("[\"{expected}\"]");
341        assert_eq!(
342            json_expected, serialized,
343            "{text}, did not get the expected serialization"
344        );
345
346        let actual_sheet_y2 = actual.d_to_s(Y::Y2);
347        assert_eq!(
348            sheet_y2, actual_sheet_y2,
349            "{text} Sheet Y2 formatting is incorrect"
350        );
351        let actual_sheet_y4 = actual.d_to_s(Y::Y4);
352        assert_eq!(
353            sheet_y4, actual_sheet_y4,
354            "{text} Sheet Y4 formatting is incorrect"
355        );
356    }
357
358    fn failure_case(input: &str) {
359        let res = Date::parse(&input);
360        assert!(
361            res.is_err(),
362            "Expected an error when parsing {input} but received Ok"
363        );
364        let msg = res.err().unwrap().to_string();
365        let contains_input = msg.contains(input);
366        assert!(
367            contains_input,
368            "Expected the error message when parsing {input} to contain the \
369             input string, but it did not"
370        );
371    }
372
373    #[test]
374    fn test_parse_good_1() {
375        success_case("9/30/2025", "2025-09-30", "9/30/25", "09/30/2025");
376    }
377
378    #[test]
379    fn test_parse_good_2() {
380        success_case("2025-09-30", "2025-09-30", "9/30/25", "09/30/2025");
381    }
382
383    #[test]
384    fn test_parse_good_3() {
385        success_case("1999-6-2", "1999-06-02", "6/2/99", "06/02/1999");
386    }
387
388    #[test]
389    fn test_parse_bad_leading_zeros() {
390        failure_case("12/000001/1932");
391    }
392
393    #[test]
394    fn test_parse_good_5() {
395        // 2-digit years are interpreted by chrono (5 -> 2005)
396        success_case("10/31/05", "2005-10-31", "10/31/05", "10/31/2005");
397    }
398
399    #[test]
400    fn test_parse_good_6() {
401        // 2-digit years are interpreted by chrono (5 -> 2005)
402        success_case("10/1/25", "2025-10-01", "10/1/25", "10/01/2025");
403    }
404
405    #[test]
406    fn test_parse_bad_1() {
407        failure_case("99/30/2025");
408    }
409
410    #[test]
411    fn test_parse_bad_2() {
412        failure_case("9/32/2025")
413    }
414
415    #[test]
416    fn test_parse_bad_3() {
417        failure_case("foo")
418    }
419
420    // Tests for parse_with_chrono (datetime formats with colons)
421
422    #[test]
423    fn test_parse_chrono_iso_format() {
424        success_case(
425            "2025-01-23T10:30:45",
426            "2025-01-23T10:30:45",
427            "1/23/25 10:30:45 AM",
428            "01/23/2025 10:30:45 AM",
429        );
430    }
431
432    #[test]
433    fn test_parse_chrono_iso_midnight() {
434        success_case(
435            "2025-12-31T00:00:00",
436            "2025-12-31T00:00:00",
437            "12/31/25 12:00:00 AM",
438            "12/31/2025 12:00:00 AM",
439        );
440    }
441
442    #[test]
443    fn test_parse_chrono_iso_end_of_day() {
444        success_case(
445            "2025-06-15T23:59:59",
446            "2025-06-15T23:59:59",
447            "6/15/25 11:59:59 PM",
448            "06/15/2025 11:59:59 PM",
449        );
450    }
451
452    #[test]
453    fn test_parse_chrono_us_format_am() {
454        success_case(
455            "01/23/2025 10:30:45 AM",
456            "2025-01-23T10:30:45",
457            "1/23/25 10:30:45 AM",
458            "01/23/2025 10:30:45 AM",
459        );
460    }
461
462    #[test]
463    fn test_parse_chrono_us_format_pm() {
464        success_case(
465            "01/23/2025 02:30:45 PM",
466            "2025-01-23T14:30:45",
467            "1/23/25 2:30:45 PM",
468            "01/23/2025 02:30:45 PM",
469        );
470    }
471
472    #[test]
473    fn test_parse_chrono_us_format_noon() {
474        success_case(
475            "07/04/2025 12:00:00 PM",
476            "2025-07-04T12:00:00",
477            "7/4/25 12:00:00 PM",
478            "07/04/2025 12:00:00 PM",
479        );
480    }
481
482    #[test]
483    fn test_parse_chrono_us_format_midnight() {
484        success_case(
485            "12/25/2025 12:00:00 AM",
486            "2025-12-25T00:00:00",
487            "12/25/25 12:00:00 AM",
488            "12/25/2025 12:00:00 AM",
489        );
490    }
491
492    #[test]
493    fn test_parse_chrono_bad_iso() {
494        failure_case("2025-13-01T10:30:45");
495    }
496
497    #[test]
498    fn test_parse_chrono_bad_us_format() {
499        failure_case("13/01/2025 10:30:45 AM");
500    }
501
502    #[test]
503    fn test_parse_chrono_bad_time() {
504        failure_case("2025-01-23T25:00:00");
505    }
506
507    // Tests for timezone preservation
508
509    #[test]
510    fn test_parse_chrono_with_negative_offset() {
511        // Input has -0800 offset, output should have -08:00 (RFC3339 style)
512        success_case(
513            "2024-12-31T06:17:17-0800",
514            "2024-12-31T06:17:17-08:00",
515            "12/31/24 6:17:17 AM",
516            "12/31/2024 06:17:17 AM",
517        );
518    }
519
520    #[test]
521    fn test_parse_chrono_with_positive_offset() {
522        success_case(
523            "2025-01-23T15:30:00+0530",
524            "2025-01-23T15:30:00+05:30",
525            "1/23/25 3:30:00 PM",
526            "01/23/2025 03:30:00 PM",
527        );
528    }
529
530    #[test]
531    fn test_parse_chrono_with_rfc3339_offset() {
532        // Already in RFC3339 format with colon
533        success_case(
534            "2025-01-23T10:00:00-05:00",
535            "2025-01-23T10:00:00-05:00",
536            "1/23/25 10:00:00 AM",
537            "01/23/2025 10:00:00 AM",
538        );
539    }
540
541    #[test]
542    fn test_parse_chrono_with_z_suffix() {
543        success_case(
544            "2025-01-23T10:00:00Z",
545            "2025-01-23T10:00:00+00:00",
546            "1/23/25 10:00:00 AM",
547            "01/23/2025 10:00:00 AM",
548        );
549    }
550
551    #[test]
552    fn test_parse_chrono_with_fractional_seconds_and_z() {
553        // Fractional seconds are preserved in ISO, dropped in sheet format
554        success_case(
555            "2025-01-23T10:00:00.123456Z",
556            "2025-01-23T10:00:00.123456+00:00",
557            "1/23/25 10:00:00 AM",
558            "01/23/2025 10:00:00 AM",
559        );
560    }
561
562    #[test]
563    fn test_parse_chrono_with_fractional_seconds_and_offset() {
564        // Fractional seconds are preserved in ISO, dropped in sheet format
565        success_case(
566            "2024-12-31T06:17:17.465339-08:00",
567            "2024-12-31T06:17:17.465339-08:00",
568            "12/31/24 6:17:17 AM",
569            "12/31/2024 06:17:17 AM",
570        );
571    }
572}