proof_of_sql_parser/posql_time/
timestamp.rs

1use super::{PoSQLTimeUnit, PoSQLTimeZone, PoSQLTimestampError};
2use alloc::{format, string::ToString};
3use chrono::{offset::LocalResult, DateTime, TimeZone, Utc};
4use core::hash::Hash;
5use serde::{Deserialize, Serialize};
6
7/// Represents a fully parsed timestamp with detailed time unit and timezone information
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
9pub struct PoSQLTimestamp {
10    /// The datetime representation in UTC.
11    timestamp: DateTime<Utc>,
12
13    /// The precision of the datetime value, e.g., seconds, milliseconds.
14    timeunit: PoSQLTimeUnit,
15
16    /// The timezone of the datetime, either UTC or a fixed offset from UTC.
17    timezone: PoSQLTimeZone,
18}
19
20impl PoSQLTimestamp {
21    /// Returns the combined date and time with time zone.
22    #[must_use]
23    pub fn timestamp(&self) -> DateTime<Utc> {
24        self.timestamp
25    }
26
27    /// Returns the [`PoSQLTimeUnit`] for this timestamp
28    #[must_use]
29    pub fn timeunit(&self) -> PoSQLTimeUnit {
30        self.timeunit
31    }
32
33    /// Returns the [`PoSQLTimeZone`] for this timestamp
34    #[must_use]
35    pub fn timezone(&self) -> PoSQLTimeZone {
36        self.timezone
37    }
38
39    /// Attempts to parse a timestamp string into an [`PoSQLTimestamp`] structure.
40    /// This function supports two primary formats:
41    ///
42    /// 1. **RFC 3339 Parsing**:
43    ///    - Parses the timestamp along with its timezone.
44    ///    - If parsing succeeds, it extracts the timezone offset using `dt.offset().local_minus_utc()`
45    ///      and then uses this to construct the appropriate `PoSQLTimeZone`.
46    ///
47    /// 2. **Timezone Parsing and Conversion**:
48    ///    - The `from_offset` method is used to determine whether the timezone should be represented
49    ///      as `Utc` or `FixedOffset`. This function simplifies the decision based on the offset value.
50    ///
51    /// # Errors
52    /// This function returns a `PoSQLTimestampError` in the following cases:
53    ///
54    /// - **Parsing Error**: Returns `PoSQLTimestampError::ParsingError` if the input string does not conform
55    ///   to the RFC 3339 format or if the timestamp cannot be parsed due to invalid formatting.
56    ///   This error includes the original parsing error message for further details.
57    ///
58    /// # Examples
59    /// ```
60    /// use chrono::{DateTime, Utc};
61    /// use proof_of_sql_parser::posql_time::{PoSQLTimestamp, PoSQLTimeZone};
62    ///
63    /// // Parsing an RFC 3339 timestamp without a timezone:
64    /// let timestamp_str = "2009-01-03T18:15:05Z";
65    /// let intermediate_timestamp = PoSQLTimestamp::try_from(timestamp_str).unwrap();
66    /// assert_eq!(intermediate_timestamp.timezone(), PoSQLTimeZone::utc());
67    ///
68    /// // Parsing an RFC 3339 timestamp with a positive timezone offset:
69    /// let timestamp_str_with_tz = "2009-01-03T18:15:05+03:00";
70    /// let intermediate_timestamp = PoSQLTimestamp::try_from(timestamp_str_with_tz).unwrap();
71    /// assert_eq!(intermediate_timestamp.timezone(), PoSQLTimeZone::new(10800)); // 3 hours in seconds
72    /// ```
73    pub fn try_from(timestamp_str: &str) -> Result<Self, PoSQLTimestampError> {
74        let dt = DateTime::parse_from_rfc3339(timestamp_str).map_err(|e| {
75            PoSQLTimestampError::ParsingError {
76                error: e.to_string(),
77            }
78        })?;
79
80        let offset_seconds = dt.offset().local_minus_utc();
81        let timezone = PoSQLTimeZone::new(offset_seconds);
82        let nanoseconds = dt.timestamp_subsec_nanos();
83        let timeunit = if nanoseconds % 1_000 != 0 {
84            PoSQLTimeUnit::Nanosecond
85        } else if nanoseconds % 1_000_000 != 0 {
86            PoSQLTimeUnit::Microsecond
87        } else if nanoseconds % 1_000_000_000 != 0 {
88            PoSQLTimeUnit::Millisecond
89        } else {
90            PoSQLTimeUnit::Second
91        };
92
93        Ok(PoSQLTimestamp {
94            timestamp: dt.with_timezone(&Utc),
95            timeunit,
96            timezone,
97        })
98    }
99
100    /// Attempts to parse a timestamp string into an `PoSQLTimestamp` structure.
101    /// This function supports two primary formats:
102    ///
103    /// **Unix Epoch Time Parsing**:
104    ///    - Since Unix epoch timestamps don't inherently carry timezone information,
105    ///      any Unix time parsed directly from an integer is assumed to be in UTC.
106    ///
107    /// # Errors
108    /// This function returns a `PoSQLTimestampError` in the following cases:
109    ///
110    /// - **Ambiguous Time**: Returns `PoSQLTimestampError::Ambiguous` if the provided epoch time
111    ///   corresponds to a time that is ambiguous (e.g., during a daylight saving time change where
112    ///   the local time could correspond to two different UTC times).
113    ///
114    /// - **Non-Existent Local Time**: Returns `PoSQLTimestampError::LocalTimeDoesNotExist` if the
115    ///   provided epoch time corresponds to a time that does not exist in the local time zone (e.g.,
116    ///   during a daylight saving time change where a certain local time is skipped).
117    ///
118    /// # Examples
119    /// ```
120    /// use chrono::{DateTime, Utc};
121    /// use proof_of_sql_parser::posql_time::{PoSQLTimestamp, PoSQLTimeZone};
122    ///
123    /// // Parsing a Unix epoch timestamp (assumed to be seconds and UTC):
124    /// let unix_time = 1231006505;
125    /// let intermediate_timestamp = PoSQLTimestamp::to_timestamp(unix_time).unwrap();
126    /// assert_eq!(intermediate_timestamp.timezone(), PoSQLTimeZone::utc());
127    /// ```
128    pub fn to_timestamp(epoch: i64) -> Result<Self, PoSQLTimestampError> {
129        match Utc.timestamp_opt(epoch, 0) {
130            LocalResult::Single(timestamp) => Ok(PoSQLTimestamp {
131                timestamp,
132                timeunit: PoSQLTimeUnit::Second,
133                timezone: PoSQLTimeZone::utc(),
134            }),
135            LocalResult::Ambiguous(earliest, latest) => Err(PoSQLTimestampError::Ambiguous{ error:
136                format!("The local time is ambiguous because there is a fold in the local time: earliest: {earliest} latest: {latest} "),
137        }),
138            LocalResult::None => Err(PoSQLTimestampError::LocalTimeDoesNotExist),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_unix_epoch_time_timezone() {
149        let unix_time = 1_231_006_505; // Unix time as integer
150        let expected_timezone = PoSQLTimeZone::utc(); // Unix time should always be UTC
151        let result = PoSQLTimestamp::to_timestamp(unix_time).unwrap();
152        assert_eq!(result.timezone, expected_timezone);
153    }
154
155    #[test]
156    fn test_unix_epoch_timestamp_parsing() {
157        let unix_time = 1_231_006_505; // Example Unix timestamp (seconds since epoch)
158        let expected_datetime = Utc.timestamp_opt(unix_time, 0).unwrap();
159        let expected_unit = PoSQLTimeUnit::Second; // Assuming basic second precision for Unix timestamp
160        let input = unix_time; // Simulate input as integer since Unix times are often transmitted as strings
161        let result = PoSQLTimestamp::to_timestamp(input).unwrap();
162
163        assert_eq!(result.timestamp, expected_datetime);
164        assert_eq!(result.timeunit, expected_unit);
165    }
166
167    #[test]
168    fn test_basic_rfc3339_timestamp() {
169        let input = "2023-06-26T12:34:56Z";
170        let expected = Utc.with_ymd_and_hms(2023, 6, 26, 12, 34, 56).unwrap();
171        let result = PoSQLTimestamp::try_from(input).unwrap();
172        assert_eq!(result.timestamp, expected);
173    }
174
175    #[test]
176    fn test_rfc3339_timestamp_with_positive_offset() {
177        let input = "2023-06-26T08:00:00+04:30";
178        let expected = Utc.with_ymd_and_hms(2023, 6, 26, 3, 30, 0).unwrap(); // Adjusted to UTC
179        let result = PoSQLTimestamp::try_from(input).unwrap();
180        assert_eq!(result.timestamp, expected);
181    }
182
183    #[test]
184    fn test_rfc3339_timestamp_with_negative_offset() {
185        let input = "2023-06-26T20:00:00-05:00";
186        let expected = Utc.with_ymd_and_hms(2023, 6, 27, 1, 0, 0).unwrap(); // Adjusted to UTC
187        let result = PoSQLTimestamp::try_from(input).unwrap();
188        assert_eq!(result.timestamp, expected);
189    }
190
191    #[test]
192    fn test_rfc3339_timestamp_with_utc_designator() {
193        let input = "2023-06-26T12:34:56Z";
194        let expected = Utc.with_ymd_and_hms(2023, 6, 26, 12, 34, 56).unwrap();
195        let result = PoSQLTimestamp::try_from(input).unwrap();
196        assert_eq!(result.timestamp, expected);
197    }
198
199    #[test]
200    fn test_invalid_rfc3339_timestamp() {
201        let input = "not-a-timestamp";
202        assert_eq!(
203            PoSQLTimestamp::try_from(input),
204            Err(PoSQLTimestampError::ParsingError {
205                error: "input contains invalid characters".into()
206            })
207        );
208    }
209
210    #[test]
211    fn test_timestamp_with_seconds() {
212        let input = "2023-06-26T12:34:56Z";
213        let expected_time = Utc.with_ymd_and_hms(2023, 6, 26, 12, 34, 56).unwrap();
214        let expected_unit = PoSQLTimeUnit::Second;
215        let result = PoSQLTimestamp::try_from(input).unwrap();
216        assert_eq!(result.timestamp, expected_time);
217        assert_eq!(result.timeunit, expected_unit);
218    }
219
220    #[test]
221    fn test_general_parsing_error() {
222        // This test assumes that there's a catch-all parsing error case that isn't covered by the more specific errors.
223        let malformed_input = "2009-01-03T::00Z"; // Intentionally malformed timestamp
224        let result = PoSQLTimestamp::try_from(malformed_input);
225        assert!(matches!(
226            result,
227            Err(PoSQLTimestampError::ParsingError { .. })
228        ));
229    }
230
231    #[test]
232    fn test_basic_date_time_support() {
233        let inputs = ["2009-01-03T18:15:05Z", "2009-01-03T18:15:05+02:00"];
234        for input in inputs {
235            assert!(
236                DateTime::parse_from_rfc3339(input).is_ok(),
237                "Should parse correctly: {input}"
238            );
239        }
240    }
241
242    #[test]
243    fn test_leap_seconds() {
244        let input = "1998-12-31T23:59:60Z"; // fyi the 59:-->60<-- is the leap second
245        assert!(PoSQLTimestamp::try_from(input).is_ok());
246    }
247
248    #[test]
249    fn test_leap_seconds_ranges() {
250        // Timestamp just before the leap second
251        let before_leap_second = "1998-12-31T23:59:59Z";
252        // Timestamp during the leap second
253        let leap_second = "1998-12-31T23:59:60Z";
254        // Timestamp just after the leap second
255        let after_leap_second = "1999-01-01T00:00:00Z";
256
257        // Parse timestamps
258        let before_leap_dt = PoSQLTimestamp::try_from(before_leap_second).unwrap();
259        let leap_second_dt = PoSQLTimestamp::try_from(leap_second).unwrap();
260        let after_leap_dt = PoSQLTimestamp::try_from(after_leap_second).unwrap();
261
262        // Ensure that "23:59:60Z" - 1 second is considered equivalent to "23:59:59Z"
263        assert_eq!(
264            before_leap_dt.timestamp,
265            leap_second_dt.timestamp - chrono::Duration::seconds(1)
266        );
267
268        // Ensure that "23:59:60Z" + 1 second is "1999-01-01T00:00:00Z"
269        assert_eq!(
270            after_leap_dt.timestamp,
271            leap_second_dt.timestamp + chrono::Duration::seconds(1)
272        );
273    }
274
275    #[test]
276    fn test_rejecting_incorrect_formats() {
277        let incorrect_formats = [
278            "2009-January-03",
279            "25:61:61",
280            "20090103",
281            "181505",
282            "18:15:05",
283        ];
284        for input in incorrect_formats {
285            assert!(
286                DateTime::parse_from_rfc3339(input).is_err(),
287                "Should reject incorrect format: {input}"
288            );
289        }
290    }
291}