Skip to main content

orbital_base_components/form/datetime/
convert.rs

1use chrono::{DateTime, NaiveDate, TimeZone, Utc};
2use orbital_data::DataValue;
3
4use super::error::DatetimeError;
5use super::orbital_datetime::OrbitalDateTime;
6use super::timezone::DatetimeTimezone;
7
8/// Parse inbound unix seconds at API / legacy boundaries.
9pub trait TryFromUnixSeconds {
10    fn try_from_unix_seconds(
11        secs: i64,
12        timezone: DatetimeTimezone,
13    ) -> Result<OrbitalDateTime, DatetimeError>;
14}
15
16/// Parse inbound ISO 8601 strings at REST / JSON boundaries.
17pub trait TryFromIso8601 {
18    fn try_from_iso8601(
19        input: &str,
20        timezone: DatetimeTimezone,
21    ) -> Result<OrbitalDateTime, DatetimeError>;
22}
23
24/// Parse inbound dataset cell values.
25pub trait TryFromDataValue {
26    fn try_from_data_value(
27        value: &DataValue,
28        timezone: DatetimeTimezone,
29    ) -> Result<OrbitalDateTime, DatetimeError>;
30}
31
32/// Serialize outbound unix seconds for APIs and legacy payloads.
33pub trait ToUnixSeconds {
34    fn to_unix_seconds(&self) -> i64;
35}
36
37/// Serialize outbound ISO 8601 for REST / JSON payloads.
38pub trait ToIso8601 {
39    fn to_iso8601(&self) -> String;
40}
41
42/// Serialize outbound dataset cell values.
43pub trait ToDataValue {
44    fn to_data_value(&self) -> DataValue;
45}
46
47impl TryFromUnixSeconds for OrbitalDateTime {
48    fn try_from_unix_seconds(secs: i64, timezone: DatetimeTimezone) -> Result<Self, DatetimeError> {
49        let instant = Utc
50            .timestamp_opt(secs, 0)
51            .single()
52            .ok_or(DatetimeError::OutOfRange)?;
53        Ok(Self::from_instant(instant, timezone))
54    }
55}
56
57impl TryFromIso8601 for OrbitalDateTime {
58    fn try_from_iso8601(input: &str, timezone: DatetimeTimezone) -> Result<Self, DatetimeError> {
59        let trimmed = input.trim();
60        if trimmed.is_empty() {
61            return Err(DatetimeError::InvalidInput);
62        }
63
64        if let Ok(parsed) = DateTime::parse_from_rfc3339(trimmed) {
65            return Ok(Self::from_instant(parsed.with_timezone(&Utc), timezone));
66        }
67
68        if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
69            return Self::from_naive_date(date, timezone).ok_or(DatetimeError::OutOfRange);
70        }
71
72        Err(DatetimeError::InvalidInput)
73    }
74}
75
76impl TryFromDataValue for OrbitalDateTime {
77    fn try_from_data_value(
78        value: &DataValue,
79        timezone: DatetimeTimezone,
80    ) -> Result<Self, DatetimeError> {
81        match value {
82            DataValue::Date(date) => {
83                Self::from_naive_date(*date, timezone).ok_or(DatetimeError::OutOfRange)
84            }
85            _ => Err(DatetimeError::InvalidInput),
86        }
87    }
88}
89
90impl ToUnixSeconds for OrbitalDateTime {
91    fn to_unix_seconds(&self) -> i64 {
92        self.instant().timestamp()
93    }
94}
95
96impl ToIso8601 for OrbitalDateTime {
97    fn to_iso8601(&self) -> String {
98        self.instant().to_rfc3339()
99    }
100}
101
102impl ToDataValue for OrbitalDateTime {
103    fn to_data_value(&self) -> DataValue {
104        let date = self
105            .wall_date()
106            .unwrap_or_else(|| self.instant().date_naive());
107        DataValue::Date(date)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use chrono::{NaiveDate, TimeZone, Utc};
115
116    #[test]
117    fn unix_round_trip() {
118        let secs = 1_735_689_600_i64;
119        let dt =
120            OrbitalDateTime::try_from_unix_seconds(secs, DatetimeTimezone::Utc).expect("valid");
121        assert_eq!(dt.to_unix_seconds(), secs);
122    }
123
124    #[test]
125    fn iso8601_round_trip() {
126        let dt = OrbitalDateTime::try_from_iso8601("2025-01-01T00:00:00Z", DatetimeTimezone::Utc)
127            .expect("valid");
128        assert!(dt.to_iso8601().starts_with("2025-01-01"));
129    }
130
131    #[test]
132    fn iso8601_naive_date_uses_timezone() {
133        let dt =
134            OrbitalDateTime::try_from_iso8601("2025-06-15", DatetimeTimezone::Utc).expect("valid");
135        assert_eq!(dt.hour_minute_second(), Some((0, 0, 0)));
136    }
137
138    #[test]
139    fn data_value_date_to_start_of_day() {
140        let date = NaiveDate::from_ymd_opt(2025, 4, 10).expect("valid");
141        let dt =
142            OrbitalDateTime::try_from_data_value(&DataValue::Date(date), DatetimeTimezone::Utc)
143                .expect("valid");
144        assert_eq!(dt.hour_minute_second(), Some((0, 0, 0)));
145    }
146
147    #[test]
148    fn to_data_value_returns_wall_date() {
149        let instant = Utc
150            .with_ymd_and_hms(2025, 7, 4, 15, 30, 0)
151            .single()
152            .expect("valid");
153        let dt = OrbitalDateTime::from_instant(instant, DatetimeTimezone::Utc);
154        match dt.to_data_value() {
155            DataValue::Date(d) => assert_eq!(d, NaiveDate::from_ymd_opt(2025, 7, 4).unwrap()),
156            _ => panic!("expected date"),
157        }
158    }
159
160    #[test]
161    fn invalid_data_value_variant() {
162        let err = OrbitalDateTime::try_from_data_value(
163            &DataValue::Text("nope".into()),
164            DatetimeTimezone::Utc,
165        );
166        assert_eq!(err, Err(DatetimeError::InvalidInput));
167    }
168}