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