Skip to main content

portable_network_archive/cli/value/
datetime.rs

1use pna::Duration;
2use std::{
3    borrow::Cow,
4    fmt::{self, Display, Formatter},
5    str::FromStr,
6    time::{SystemTime, UNIX_EPOCH},
7};
8
9#[derive(Debug, thiserror::Error)]
10pub enum DateTimeError {
11    #[error("Failed to parse seconds since unix epoch")]
12    InvalidNumber,
13    #[error("Failed to parse seconds since unix epoch")]
14    ParseInt(#[from] std::num::ParseIntError),
15    #[error(transparent)]
16    ChronoParse(#[from] chrono::ParseError),
17    #[error(transparent)]
18    ParseDateTime(#[from] parse_datetime::ParseDateTimeError),
19}
20
21#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
22pub enum DateTime {
23    Naive(chrono::NaiveDateTime),
24    Zoned(jiff::Zoned),
25    Date(chrono::NaiveDate),
26    Epoch(i64, u32), // Unix epoch timestamp in seconds and subsec nanos
27}
28
29impl DateTime {
30    #[inline]
31    pub fn to_system_time(&self) -> SystemTime {
32        #[inline]
33        fn from_timestamp(seconds: i64, nanoseconds: u32) -> SystemTime {
34            UNIX_EPOCH + Duration::new(seconds, nanoseconds as _)
35        }
36        match self {
37            Self::Naive(naive) => {
38                let (seconds, nanos) = match naive.and_local_timezone(chrono::Local) {
39                    chrono::LocalResult::Single(local) => {
40                        (local.timestamp(), local.timestamp_subsec_nanos())
41                    }
42                    chrono::LocalResult::Ambiguous(earlier, _) => {
43                        (earlier.timestamp(), earlier.timestamp_subsec_nanos())
44                    }
45                    chrono::LocalResult::None => {
46                        // Fallback to interpreting the naive value as UTC rather than panic.
47                        let utc = naive.and_utc();
48                        (utc.timestamp(), utc.timestamp_subsec_nanos())
49                    }
50                };
51                from_timestamp(seconds, nanos)
52            }
53            Self::Zoned(zoned) => {
54                let ts = zoned.timestamp();
55                from_timestamp(ts.as_second(), zoned.subsec_nanosecond() as u32)
56            }
57            Self::Date(date) => {
58                let utc = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
59                from_timestamp(utc.timestamp(), utc.timestamp_subsec_nanos())
60            }
61            Self::Epoch(seconds, nanos) => from_timestamp(*seconds, *nanos),
62        }
63    }
64}
65
66impl Display for DateTime {
67    #[inline]
68    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Naive(naive) => Display::fmt(naive, f),
71            Self::Zoned(zoned) => Display::fmt(zoned, f),
72            Self::Date(date) => Display::fmt(date, f),
73            Self::Epoch(seconds, nanos) => write!(f, "@{seconds}.{nanos:09}"),
74        }
75    }
76}
77
78impl FromStr for DateTime {
79    type Err = DateTimeError;
80
81    #[inline]
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        if let Some(seconds) = s.strip_prefix('@') {
84            // GNU tar allows both comma and dot as decimal separators
85            let seconds_str = if seconds.contains(',') {
86                Cow::Owned(seconds.replace(',', "."))
87            } else {
88                Cow::Borrowed(seconds)
89            };
90            // split integer and fractional parts
91            let mut split = seconds_str.splitn(2, '.');
92            let int_part = split.next().expect("split always has at least one part");
93            let frac_part = split.next();
94
95            // parse seconds
96            let secs = i64::from_str(int_part)?;
97
98            // parse fractional (nanoseconds)
99            let nanos: u32 = if let Some(frac) = frac_part {
100                // allow only digits
101                if !frac.bytes().all(|c| c.is_ascii_digit()) {
102                    return Err(Self::Err::InvalidNumber);
103                }
104                // take up to 9 digits (nanoseconds); pad right with zeros
105                let digits = frac.as_bytes();
106                let mut ns: u32 = 0;
107                // pad with zeros to reach 9 digits and truncate beyond ns
108                for &b in digits.iter().chain(std::iter::repeat(&b'0')).take(9) {
109                    ns = (ns * 10) + (b - b'0') as u32;
110                }
111                ns
112            } else {
113                0
114            };
115            Ok(Self::Epoch(secs, nanos))
116        } else if let Ok(naive) = chrono::NaiveDateTime::from_str(s) {
117            Ok(Self::Naive(naive))
118        } else if let Ok(naive_date) = chrono::NaiveDate::from_str(s) {
119            Ok(Self::Date(naive_date))
120        } else {
121            Ok(Self::Zoned(parse_datetime::parse_datetime(s)?))
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_datetime_parse_valid() {
132        let valid_dt = "2024-03-20T12:34:56";
133        let datetime = DateTime::from_str(valid_dt).unwrap();
134        assert_eq!(datetime.to_string(), "2024-03-20 12:34:56");
135    }
136
137    #[test]
138    fn test_datetime_parse_with_timezone() {
139        let zoned_dt = "2024-03-20T12:34:56+09:00";
140        let datetime = DateTime::from_str(zoned_dt).unwrap();
141        assert_eq!(datetime.to_string(), "2024-03-20T12:34:56+09:00[+09:00]");
142        let zoned_dt = "2024-03-20T12:34:56Z";
143        let datetime = DateTime::from_str(zoned_dt).unwrap();
144        assert_eq!(datetime.to_string(), "2024-03-20T12:34:56+00:00[UTC]");
145    }
146
147    #[test]
148    fn test_datetime_parse_invalid() {
149        let invalid_dt = "invalid-datetime";
150        assert!(DateTime::from_str(invalid_dt).is_err());
151    }
152
153    #[test]
154    fn test_to_system_time_after_epoch() {
155        let positive_dt = "2024-03-20T12:34:56Z";
156        let datetime = DateTime::from_str(positive_dt).unwrap();
157        let system_time = datetime.to_system_time();
158        assert!(system_time > UNIX_EPOCH);
159    }
160
161    #[cfg(not(target_family = "wasm"))]
162    #[test]
163    fn test_to_system_time_before_epoch() {
164        let negative_dt = "1969-12-31T23:59:59Z";
165        let datetime = DateTime::from_str(negative_dt).unwrap();
166        let system_time = datetime.to_system_time();
167        assert!(system_time < UNIX_EPOCH);
168    }
169
170    #[test]
171    fn test_relative_time_format_positive() {
172        let datetime = DateTime::from_str("@1234567890").unwrap();
173        assert_eq!(datetime.to_string(), "@1234567890.000000000");
174    }
175
176    #[test]
177    fn test_relative_time_format_negative() {
178        let datetime = DateTime::from_str("@-1234567890").unwrap();
179        assert_eq!(datetime.to_string(), "@-1234567890.000000000");
180    }
181
182    #[test]
183    fn test_relative_time_format_tailing_decimal_dot() {
184        let datetime = DateTime::from_str("@123.").unwrap();
185        assert_eq!(datetime.to_string(), "@123.000000000");
186    }
187
188    #[test]
189    fn test_relative_time_format_decimal_dot_zeros() {
190        let datetime = DateTime::from_str("@123.0").unwrap();
191        assert_eq!(datetime.to_string(), "@123.000000000");
192    }
193
194    #[test]
195    fn test_relative_time_format_decimal_dot_zero_one() {
196        let datetime = DateTime::from_str("@123.01").unwrap();
197        assert_eq!(datetime.to_string(), "@123.010000000");
198    }
199
200    #[test]
201    fn test_relative_time_format_decimal_dot() {
202        let datetime = DateTime::from_str("@123.456").unwrap();
203        assert_eq!(datetime.to_string(), "@123.456000000");
204    }
205
206    #[test]
207    fn test_relative_time_format_decimal_comma() {
208        let datetime = DateTime::from_str("@123,456").unwrap();
209        assert_eq!(datetime.to_string(), "@123.456000000");
210    }
211
212    #[test]
213    fn test_relative_time_format_negative_decimal_dot() {
214        let datetime = DateTime::from_str("@-123.456").unwrap();
215        assert_eq!(datetime.to_string(), "@-123.456000000");
216    }
217
218    #[test]
219    fn test_relative_time_format_negative_decimal_comma() {
220        let datetime = DateTime::from_str("@-123,456").unwrap();
221        assert_eq!(datetime.to_string(), "@-123.456000000");
222    }
223
224    #[test]
225    fn test_relative_time_format_zero() {
226        let datetime = DateTime::from_str("@0").unwrap();
227        assert_eq!(datetime.to_string(), "@0.000000000");
228    }
229
230    #[test]
231    fn test_relative_time_format_negative_one() {
232        let datetime = DateTime::from_str("@-1").unwrap();
233        assert_eq!(datetime.to_string(), "@-1.000000000");
234    }
235
236    #[test]
237    fn test_datetime_parse_and_display_date() {
238        let datetime = DateTime::from_str("2024-04-01").unwrap();
239        assert_eq!(datetime.to_string(), "2024-04-01");
240    }
241
242    #[test]
243    fn test_to_system_time_naive() {
244        let naive = chrono::NaiveDate::from_ymd_opt(2024, 4, 1)
245            .unwrap()
246            .and_hms_opt(12, 0, 0)
247            .unwrap();
248        let datetime = DateTime::Naive(naive);
249        let system_time = datetime.to_system_time();
250        assert!(system_time > UNIX_EPOCH);
251    }
252
253    #[test]
254    fn test_to_system_time_date() {
255        let date = chrono::NaiveDate::from_ymd_opt(2024, 4, 1).unwrap();
256        let datetime = DateTime::Date(date);
257        let system_time = datetime.to_system_time();
258        assert!(system_time > UNIX_EPOCH);
259    }
260
261    #[test]
262    fn test_to_system_time_epoch() {
263        let datetime = DateTime::Epoch(1234567890, 0);
264        let system_time = datetime.to_system_time();
265        assert!(system_time > UNIX_EPOCH);
266    }
267}