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, ensure, Context};
8use chrono::{DateTime, FixedOffset, NaiveDateTime};
9use schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
10use std::borrow::Cow;
11use std::fmt::{Debug, Display, Formatter};
12use std::str::FromStr;
13use tracing::debug;
14
15/// A date value that serializes in YYYY-MM-DD format.
16#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, sqlx::Type)]
17#[sqlx(transparent)]
18pub struct Date(String);
19
20impl JsonSchema for Date {
21    fn schema_name() -> Cow<'static, str> {
22        "Date".into()
23    }
24
25    fn json_schema(_: &mut SchemaGenerator) -> Schema {
26        json_schema!({
27            "type": "string",
28            "format": "date",
29            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
30            "description": "A date in YYYY-MM-DD format (e.g., 2025-01-23)"
31        })
32    }
33}
34
35impl Default for Date {
36    fn default() -> Self {
37        Date("1999-12-31".to_string())
38    }
39}
40
41impl Date {
42    pub fn parse(s: impl AsRef<str>) -> Res<Self> {
43        let s = s.as_ref();
44        if s.contains(':') {
45            Self::parse_with_chrono(s)
46        } else if s.contains('/') {
47            Self::parse_m_d_yyyy(s)
48        } else if s.contains('-') {
49            Self::parse_yyyy_mm_dd(s)
50        } else {
51            bail!("Expected a date eith in the format 9/30/2025 or 2025-09-31, but received {s}")
52        }
53    }
54
55    /// Convenience for deserializing from the database.
56    fn from_opt(o: Option<String>) -> Res<Option<Self>> {
57        match o {
58            None => Ok(None),
59            Some(s) => Self::from_opt_s(s),
60        }
61    }
62
63    /// For deserializing strings that may be empty.
64    fn from_opt_s(s: impl AsRef<str>) -> Res<Option<Self>> {
65        let s = s.as_ref();
66        if s.is_empty() {
67            Ok(None)
68        } else {
69            Ok(Some(Self::parse(s)?))
70        }
71    }
72
73    fn parse_m_d_yyyy(s: &str) -> Res<Self> {
74        let mut parts = s.split('/');
75        let m = parts.next().context(format!("No month found for {s}"))?;
76        let d = parts.next().context(format!("No day found for {s}"))?;
77        let y = parts.next().context(format!("No year found for {s}"))?;
78        ensure!(parts.next().is_none(), "Too many parts found for {s}");
79        Self::from_y_m_d(y, m, d, s)
80    }
81
82    fn parse_yyyy_mm_dd(s: &str) -> Res<Self> {
83        let mut parts = s.split('-');
84        let y = parts.next().context(format!("No year found for {s}"))?;
85        let m = parts.next().context(format!("No month found for {s}"))?;
86        let d = parts.next().context(format!("No day found for {s}"))?;
87        ensure!(parts.next().is_none(), "Too many parts found for {s}");
88        Self::from_y_m_d(y, m, d, s)
89    }
90
91    /// This is the format that Tiller uses for categorized date.
92    /// Handles multiple formats:
93    /// - `M/D/YYYY H:MM:SS AM/PM` (US format) -> outputs without timezone
94    /// - `YYYY-MM-DDTHH:MM:SS` (ISO without timezone) -> outputs without timezone
95    /// - `YYYY-MM-DDTHH:MM:SS-08:00` (ISO with timezone offset) -> preserves timezone
96    /// - `YYYY-MM-DDTHH:MM:SS.ffffffZ` (ISO with fractional seconds and Z) -> preserves Z
97    fn parse_with_chrono(s: &str) -> Res<Self> {
98        if s.contains('/') {
99            // US format: M/D/YYYY H:MM:SS AM/PM -> no timezone
100            let d = NaiveDateTime::parse_from_str(s, "%m/%d/%Y %I:%M:%S %p")
101                .context(format!("Unable to parse {s} as a date"))?;
102            Ok(Self(d.format("%Y-%m-%dT%H:%M:%S").to_string()))
103        } else if s.ends_with('Z') || s.contains('+') || s.rfind('-').is_some_and(|i| i > 10) {
104            // ISO format with timezone: preserve the offset
105            // The rfind('-') > 10 check distinguishes timezone offset from date separator
106            let dt = DateTime::<FixedOffset>::parse_from_rfc3339(s)
107                .or_else(|_| DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%z"))
108                .or_else(|_| DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f%z"))
109                .context(format!("Unable to parse {s} as a date with timezone"))?;
110            // Format with timezone offset preserved (RFC3339 style: -08:00)
111            Ok(Self(dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string()))
112        } else {
113            // ISO format without timezone
114            let d = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
115                .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
116                .context(format!("Unable to parse {s} as a date"))?;
117            Ok(Self(d.format("%Y-%m-%dT%H:%M:%S").to_string()))
118        }
119    }
120
121    fn from_y_m_d(y: &str, m: &str, d: &str, original: &str) -> Res<Self> {
122        let m = m
123            .parse::<i32>()
124            .context(format!("Month is a bad number for {original}, m={m}"))?;
125        let d = d
126            .parse::<i32>()
127            .context(format!("Day is a bad number for {original}, d={d}"))?;
128        let mut y = y
129            .parse::<i32>()
130            .context(format!("Year is a bad number for {original}, y={y}"))?;
131        // Yuck... but, ok, let's support two-digit dates
132        if y < 100 {
133            debug!("A two-digit year was interpreted to be in the 21st century: {original}");
134            y += 2000;
135        }
136        ensure!(
137            (1..=12).contains(&m),
138            "Bad month value of {m} in {original}"
139        );
140        ensure!((1..=31).contains(&d), "Bad day value of {d} in {original}");
141        ensure!(
142            (1000..=9999).contains(&y),
143            "Bad year value of {y} in {original}"
144        );
145        Ok(Self(format!("{y:04}-{m:02}-{d:02}")))
146    }
147}
148
149impl TryFrom<String> for Date {
150    type Error = TillerError;
151
152    fn try_from(value: String) -> Result<Self, Self::Error> {
153        Self::parse(value).pub_result(ErrorType::Internal)
154    }
155}
156
157impl TryFrom<&str> for Date {
158    type Error = TillerError;
159
160    fn try_from(value: &str) -> Result<Self, Self::Error> {
161        Self::parse(value).pub_result(ErrorType::Internal)
162    }
163}
164
165impl Display for Date {
166    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
167        Display::fmt(&self.0, f)
168    }
169}
170
171impl Debug for Date {
172    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
173        Display::fmt(&self.0, f)
174    }
175}
176
177impl FromStr for Date {
178    type Err = TillerError;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        Self::parse(s).pub_result(ErrorType::Internal)
182    }
183}
184
185pub(crate) trait DateFromOpt: Sized {
186    fn date_from_opt(self) -> Res<Option<Date>>;
187}
188
189impl<S> DateFromOpt for Option<S>
190where
191    S: AsRef<str> + Sized,
192{
193    fn date_from_opt(self) -> Res<Option<Date>> {
194        let o = self.map(|s| s.as_ref().to_string());
195        Date::from_opt(o)
196    }
197}
198
199pub(crate) trait DateFromOptStr: Sized {
200    fn date_from_opt_s(self) -> Res<Option<Date>>;
201}
202
203impl<S> DateFromOptStr for S
204where
205    S: AsRef<str> + Sized,
206{
207    fn date_from_opt_s(self) -> Res<Option<Date>> {
208        Date::from_opt_s(self)
209    }
210}
211
212pub(crate) trait DateCanBeEmptyStr {
213    fn date_to_s(&self) -> String;
214}
215
216impl DateCanBeEmptyStr for Option<Date> {
217    fn date_to_s(&self) -> String {
218        self.as_ref().map(|d| d.to_string()).unwrap_or_default()
219    }
220}
221
222impl DateCanBeEmptyStr for Option<&Date> {
223    fn date_to_s(&self) -> String {
224        self.map(|d| d.to_string()).unwrap_or_default()
225    }
226}
227
228impl DateCanBeEmptyStr for &Option<Date> {
229    fn date_to_s(&self) -> String {
230        self.as_ref().map(|d| d.to_string()).unwrap_or_default()
231    }
232}
233
234serde_plain::derive_deserialize_from_fromstr!(Date, "Valid date in M/D/YYYY or YYYY-MM-DD");
235serde_plain::derive_serialize_from_display!(Date);
236
237#[cfg(test)]
238mod test {
239    use super::*;
240
241    fn success_case(input: &str, expected_s: &str) {
242        let text = format!("Test failure parsing {input} and expecting {expected_s}");
243        let expected = Date(String::from(expected_s.to_string()));
244        let actual = Date::parse(&input).expect(&text);
245        assert_eq!(expected, actual);
246
247        let json_str = format!("[\"{input}\"]");
248        let arr: Vec<Date> = serde_json::from_str(&json_str).expect(&format!(
249            "{text}: the json '{json_str}' could not be deserialized"
250        ));
251        let serialized =
252            serde_json::to_string(&arr).expect(&format!("{text}, unable to serialize"));
253        let json_expected = format!("[\"{expected_s}\"]");
254        assert_eq!(
255            json_expected, serialized,
256            "{text}, did not get the expected serialization"
257        )
258    }
259
260    fn failure_case(input: &str) {
261        let res = Date::parse(&input);
262        assert!(
263            res.is_err(),
264            "Expected an error when parsing {input} but received Ok"
265        );
266        let msg = res.err().unwrap().to_string();
267        let contains_input = msg.contains(input);
268        assert!(
269            contains_input,
270            "Expected the error message when parsing {input} to contain the \
271             input string, but it did not"
272        );
273    }
274
275    #[test]
276    fn test_parse_good_1() {
277        success_case("9/30/2025", "2025-09-30");
278    }
279
280    #[test]
281    fn test_parse_good_2() {
282        success_case("2025-09-30", "2025-09-30");
283    }
284
285    #[test]
286    fn test_parse_good_3() {
287        success_case("1999-6-2", "1999-06-02");
288    }
289
290    #[test]
291    fn test_parse_good_4() {
292        success_case("12/000001/1932", "1932-12-01");
293    }
294
295    #[test]
296    fn test_parse_good_5() {
297        success_case("10/31/5", "2005-10-31");
298    }
299
300    #[test]
301    fn test_parse_bad_1() {
302        failure_case("99/30/2025");
303    }
304
305    #[test]
306    fn test_parse_bad_2() {
307        failure_case("9/32/2025")
308    }
309
310    #[test]
311    fn test_parse_bad_3() {
312        failure_case("foo")
313    }
314
315    // Tests for parse_with_chrono (datetime formats with colons)
316
317    #[test]
318    fn test_parse_chrono_iso_format() {
319        success_case("2025-01-23T10:30:45", "2025-01-23T10:30:45");
320    }
321
322    #[test]
323    fn test_parse_chrono_iso_midnight() {
324        success_case("2025-12-31T00:00:00", "2025-12-31T00:00:00");
325    }
326
327    #[test]
328    fn test_parse_chrono_iso_end_of_day() {
329        success_case("2025-06-15T23:59:59", "2025-06-15T23:59:59");
330    }
331
332    #[test]
333    fn test_parse_chrono_us_format_am() {
334        success_case("01/23/2025 10:30:45 AM", "2025-01-23T10:30:45");
335    }
336
337    #[test]
338    fn test_parse_chrono_us_format_pm() {
339        success_case("01/23/2025 02:30:45 PM", "2025-01-23T14:30:45");
340    }
341
342    #[test]
343    fn test_parse_chrono_us_format_noon() {
344        success_case("07/04/2025 12:00:00 PM", "2025-07-04T12:00:00");
345    }
346
347    #[test]
348    fn test_parse_chrono_us_format_midnight() {
349        success_case("12/25/2025 12:00:00 AM", "2025-12-25T00:00:00");
350    }
351
352    #[test]
353    fn test_parse_chrono_bad_iso() {
354        failure_case("2025-13-01T10:30:45");
355    }
356
357    #[test]
358    fn test_parse_chrono_bad_us_format() {
359        failure_case("13/01/2025 10:30:45 AM");
360    }
361
362    #[test]
363    fn test_parse_chrono_bad_time() {
364        failure_case("2025-01-23T25:00:00");
365    }
366
367    // Tests for timezone preservation
368
369    #[test]
370    fn test_parse_chrono_with_negative_offset() {
371        // Input has -0800 offset, output should have -08:00 (RFC3339 style)
372        success_case("2024-12-31T06:17:17-0800", "2024-12-31T06:17:17-08:00");
373    }
374
375    #[test]
376    fn test_parse_chrono_with_positive_offset() {
377        success_case("2025-01-23T15:30:00+0530", "2025-01-23T15:30:00+05:30");
378    }
379
380    #[test]
381    fn test_parse_chrono_with_rfc3339_offset() {
382        // Already in RFC3339 format with colon
383        success_case("2025-01-23T10:00:00-05:00", "2025-01-23T10:00:00-05:00");
384    }
385
386    #[test]
387    fn test_parse_chrono_with_z_suffix() {
388        success_case("2025-01-23T10:00:00Z", "2025-01-23T10:00:00+00:00");
389    }
390
391    #[test]
392    fn test_parse_chrono_with_fractional_seconds_and_z() {
393        // Fractional seconds should be dropped, Z converted to +00:00
394        success_case("2025-01-23T10:00:00.123456Z", "2025-01-23T10:00:00+00:00");
395    }
396
397    #[test]
398    fn test_parse_chrono_with_fractional_seconds_and_offset() {
399        success_case(
400            "2024-12-31T06:17:17.465339-08:00",
401            "2024-12-31T06:17:17-08:00",
402        );
403    }
404}