tiller_sync/model/
date.rs1use 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#[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 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 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 fn parse_with_chrono(s: &str) -> Res<Self> {
98 if s.contains('/') {
99 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 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 Ok(Self(dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string()))
112 } else {
113 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 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 #[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 #[test]
370 fn test_parse_chrono_with_negative_offset() {
371 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 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 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}