Skip to main content

hey_sdk/
types.rs

1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{Datelike, NaiveDate, NaiveTime, TimeDelta, TimeZone, Utc, Weekday};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7use crate::error::Error;
8
9/// An instant HEY reports, always with its offset.
10pub type DateTime = chrono::DateTime<Utc>;
11
12/// A calendar date without a time zone, as HEY writes `starts_on` and `ends_on`.
13///
14/// Two dates compare and sort chronologically, so use `<`, `>` and `==` rather than
15/// looking for `before` and `after`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
17pub struct Date(pub NaiveDate);
18
19impl Date {
20    /// The date at `year`, `month` and `day`, or `None` when the calendar has no such day.
21    pub fn new(year: i32, month: u32, day: u32) -> Option<Date> {
22        NaiveDate::from_ymd_opt(year, month, day).map(Date)
23    }
24
25    /// Reads `YYYY-MM-DD`. An empty string is not a date and is rejected as one.
26    pub fn parse(source: &str) -> Result<Date, Error> {
27        source
28            .parse()
29            .map_err(|error| Error::usage(format!("invalid date {source:?}: {error}")))
30    }
31
32    /// Today where this machine is.
33    pub fn today() -> Date {
34        Date(chrono::Local::now().date_naive())
35    }
36
37    /// Today in UTC.
38    pub fn today_utc() -> Date {
39        Date(Utc::now().date_naive())
40    }
41
42    /// The date an instant falls on in UTC.
43    pub fn from_datetime(moment: &DateTime) -> Date {
44        Date(moment.date_naive())
45    }
46
47    /// The date an instant falls on in the given time zone, which is a different day from
48    /// [`Date::from_datetime`] either side of midnight.
49    pub fn from_datetime_in<Tz: TimeZone>(moment: &DateTime, zone: &Tz) -> Date {
50        Date(moment.with_timezone(zone).date_naive())
51    }
52
53    /// The calendar year.
54    pub fn year(&self) -> i32 {
55        self.0.year()
56    }
57
58    /// The month, 1 through 12.
59    pub fn month(&self) -> u32 {
60        self.0.month()
61    }
62
63    /// The day of the month, from 1.
64    pub fn day(&self) -> u32 {
65        self.0.day()
66    }
67
68    /// The day of the week.
69    pub fn weekday(&self) -> Weekday {
70        self.0.weekday()
71    }
72
73    /// Midnight UTC on this date.
74    ///
75    /// UTC is the only zone this crate turns a date into an instant in: naming another
76    /// would mean a time zone database, and the crate carries none. To land on midnight
77    /// somewhere else, build the instant with a `chrono::TimeZone` of your own —
78    /// `zone.from_local_datetime(&date.0.and_time(NaiveTime::MIN))`.
79    pub fn at_midnight_utc(&self) -> DateTime {
80        self.0.and_time(NaiveTime::MIN).and_utc()
81    }
82
83    /// The date `days` later, or earlier for a negative count; `None` past the calendar's
84    /// range.
85    pub fn add_days(&self, days: i64) -> Option<Date> {
86        self.0
87            .checked_add_signed(TimeDelta::try_days(days)?)
88            .map(Date)
89    }
90
91    /// Adds months the way Go's `time.AddDate` does: the day of the month is kept and a
92    /// month too short to hold it rolls over into the next one, so 31 January plus one
93    /// month is 3 March in a common year rather than 28 February.
94    pub fn add_months(&self, months: i32) -> Option<Date> {
95        let target = i64::from(self.0.year()) * 12 + i64::from(self.0.month0()) + i64::from(months);
96        let year = i32::try_from(target.div_euclid(12)).ok()?;
97        let month = u32::try_from(target.rem_euclid(12)).ok()? + 1;
98        Date::new(year, month, 1)?.add_days(i64::from(self.0.day() - 1))
99    }
100
101    /// Adds years with the same rollover as [`Date::add_months`]: 29 February plus one
102    /// year is 1 March.
103    pub fn add_years(&self, years: i32) -> Option<Date> {
104        self.add_months(years.checked_mul(12)?)
105    }
106
107    /// The whole days from `earlier` to this date, negative when this date came first.
108    pub fn days_since(&self, earlier: Date) -> i64 {
109        self.0.signed_duration_since(earlier.0).num_days()
110    }
111}
112
113impl From<NaiveDate> for Date {
114    fn from(date: NaiveDate) -> Date {
115        Date(date)
116    }
117}
118
119impl From<Date> for NaiveDate {
120    fn from(date: Date) -> NaiveDate {
121        date.0
122    }
123}
124
125impl fmt::Display for Date {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "{}", self.0.format("%Y-%m-%d"))
128    }
129}
130
131impl FromStr for Date {
132    type Err = chrono::ParseError;
133
134    fn from_str(source: &str) -> Result<Date, chrono::ParseError> {
135        NaiveDate::parse_from_str(source, "%Y-%m-%d").map(Date)
136    }
137}
138
139impl Serialize for Date {
140    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
141        serializer.serialize_str(&self.to_string())
142    }
143}
144
145impl<'de> Deserialize<'de> for Date {
146    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Date, D::Error> {
147        let text = String::deserialize(deserializer)?;
148        text.parse().map_err(serde::de::Error::custom)
149    }
150}
151
152/// Reads an `Option<Date>` that may arrive as `null` or as `""`, both of which mean no
153/// date. Reach for it with `#[serde(default, with = "optional_date")]` on a field HEY
154/// blanks rather than omits; a plain `Option<Date>` accepts `null` but not `""`.
155pub mod optional_date {
156    use serde::{Deserialize, Deserializer, Serialize, Serializer};
157
158    use super::Date;
159
160    /// Writes the date as `YYYY-MM-DD`, or `null` when there is none.
161    pub fn serialize<S: Serializer>(date: &Option<Date>, serializer: S) -> Result<S::Ok, S::Error> {
162        date.serialize(serializer)
163    }
164
165    /// Reads `YYYY-MM-DD` as a date, and `null` or `""` as none.
166    pub fn deserialize<'de, D: Deserializer<'de>>(
167        deserializer: D,
168    ) -> Result<Option<Date>, D::Error> {
169        match Option::<String>::deserialize(deserializer)? {
170            Some(text) if !text.is_empty() => {
171                text.parse().map(Some).map_err(serde::de::Error::custom)
172            }
173            _ => Ok(None),
174        }
175    }
176}
177
178/// Reads a required field that arrived as `null` as its type's default. Reach for it with
179/// `#[serde(default, deserialize_with = "crate::types::null_as_default::deserialize")]`,
180/// which the generator puts on every required field of a type that has a zero value.
181///
182/// HEY writes `null` where it has nothing for a field the model calls required, and Go's
183/// `encoding/json` reads that into a non-pointer as a no-op — the field keeps its zero
184/// value. `#[serde(default)]` alone only covers the field being absent, so without this a
185/// `null` fails the whole response where Go reads it as `""` or `0`.
186pub mod null_as_default {
187    use serde::{Deserialize, Deserializer};
188
189    /// Reads the value, or its type's default when HEY wrote `null`.
190    pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de> + Default>(
191        deserializer: D,
192    ) -> Result<T, D::Error> {
193        Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
194    }
195}
196
197/// A string that must not end up in logs, such as an email address. It prints as
198/// `[REDACTED]`; call [`SensitiveString::expose`] to read it.
199#[derive(Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
200#[serde(transparent)]
201pub struct SensitiveString(String);
202
203impl SensitiveString {
204    /// Wraps a value that must not be logged.
205    pub fn new(value: impl Into<String>) -> SensitiveString {
206        SensitiveString(value.into())
207    }
208
209    /// The value itself, for the one place that has to read it.
210    pub fn expose(&self) -> &str {
211        &self.0
212    }
213
214    /// The value itself, giving up the wrapper.
215    pub fn into_inner(self) -> String {
216        self.0
217    }
218
219    /// Whether there is anything inside, which a `Debug` of it does not say.
220    pub fn is_empty(&self) -> bool {
221        self.0.is_empty()
222    }
223}
224
225impl From<String> for SensitiveString {
226    fn from(value: String) -> SensitiveString {
227        SensitiveString(value)
228    }
229}
230
231impl From<&str> for SensitiveString {
232    fn from(value: &str) -> SensitiveString {
233        SensitiveString(value.to_string())
234    }
235}
236
237impl fmt::Debug for SensitiveString {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        if self.0.is_empty() {
240            f.write_str("\"\"")
241        } else {
242            f.write_str("[REDACTED]")
243        }
244    }
245}
246
247impl fmt::Display for SensitiveString {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        if self.0.is_empty() {
250            Ok(())
251        } else {
252            f.write_str("[REDACTED]")
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn dates_round_trip_through_json() {
263        let date: Date = serde_json::from_str("\"2026-03-04\"").unwrap();
264        assert_eq!(date, Date::new(2026, 3, 4).unwrap());
265        assert_eq!(serde_json::to_string(&date).unwrap(), "\"2026-03-04\"");
266    }
267
268    #[test]
269    fn sensitive_strings_hide_their_value() {
270        let secret = SensitiveString::new("jane@example.com");
271        assert_eq!(format!("{secret:?}"), "[REDACTED]");
272        assert_eq!(secret.to_string(), "[REDACTED]");
273        assert_eq!(secret.expose(), "jane@example.com");
274        assert_eq!(
275            serde_json::to_string(&secret).unwrap(),
276            "\"jane@example.com\""
277        );
278    }
279}