Skip to main content

mtp_rs/ptp/pack/
datetime.rs

1//! DateTime struct and serialization for MTP/PTP.
2
3use super::{pack_string, unpack_string};
4
5/// Date and time structure for MTP/PTP.
6///
7/// Format: "YYYYMMDDThhmmss" (ISO 8601 subset)
8///
9/// # Validation
10///
11/// DateTime values must satisfy these constraints:
12/// - Year: 0-9999 (4-digit representation)
13/// - Month: 1-12
14/// - Day: 1-31
15/// - Hour: 0-23
16/// - Minute: 0-59
17/// - Second: 0-59
18///
19/// Use [`DateTime::new()`] to create validated instances, or [`DateTime::is_valid()`]
20/// to check existing instances.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct DateTime {
23    /// Year (0-9999)
24    pub year: u16,
25    /// Month (1-12)
26    pub month: u8,
27    /// Day (1-31)
28    pub day: u8,
29    /// Hour (0-23)
30    pub hour: u8,
31    /// Minute (0-59)
32    pub minute: u8,
33    /// Second (0-59)
34    pub second: u8,
35}
36
37impl DateTime {
38    /// Create a new DateTime with validation.
39    ///
40    /// Returns `None` if any value is out of range.
41    ///
42    /// # Example
43    ///
44    /// ```
45    /// use mtp_rs::ptp::DateTime;
46    ///
47    /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap();
48    /// assert_eq!(dt.year, 2024);
49    ///
50    /// // Invalid values return None
51    /// assert!(DateTime::new(2024, 13, 1, 0, 0, 0).is_none()); // month > 12
52    /// assert!(DateTime::new(2024, 1, 1, 0, 60, 0).is_none()); // minute > 59
53    /// ```
54    #[must_use]
55    pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option<Self> {
56        let dt = DateTime {
57            year,
58            month,
59            day,
60            hour,
61            minute,
62            second,
63        };
64        if dt.is_valid() {
65            Some(dt)
66        } else {
67            None
68        }
69    }
70
71    /// Check if this DateTime has valid values.
72    ///
73    /// Returns `true` if all fields are within valid ranges:
74    /// - Year: 0-9999
75    /// - Month: 1-12
76    /// - Day: 1-31
77    /// - Hour: 0-23
78    /// - Minute: 0-59
79    /// - Second: 0-59
80    ///
81    /// Note: This does not validate day-of-month against the specific month
82    /// (e.g., Feb 31 would pass). MTP devices generally accept any 1-31 value.
83    #[must_use]
84    pub fn is_valid(&self) -> bool {
85        self.year <= 9999
86            && (1..=12).contains(&self.month)
87            && (1..=31).contains(&self.day)
88            && self.hour <= 23
89            && self.minute <= 59
90            && self.second <= 59
91    }
92
93    /// Parse a datetime string in MTP format.
94    ///
95    /// Format: "YYYYMMDDThhmmss" with optional timezone suffix (Z or +hhmm/-hhmm).
96    /// The timezone suffix is parsed but ignored.
97    ///
98    /// Returns `None` if the string is malformed or contains invalid values.
99    #[must_use]
100    pub fn parse(s: &str) -> Option<Self> {
101        // Minimum length: "YYYYMMDDThhmmss" = 15 characters
102        if s.len() < 15 {
103            return None;
104        }
105
106        // Check for 'T' separator at position 8
107        if s.as_bytes().get(8) != Some(&b'T') {
108            return None;
109        }
110
111        // Parse components
112        let year: u16 = s.get(0..4)?.parse().ok()?;
113        let month: u8 = s.get(4..6)?.parse().ok()?;
114        let day: u8 = s.get(6..8)?.parse().ok()?;
115        let hour: u8 = s.get(9..11)?.parse().ok()?;
116        let minute: u8 = s.get(11..13)?.parse().ok()?;
117        let second: u8 = s.get(13..15)?.parse().ok()?;
118
119        // Use new() which validates
120        Self::new(year, month, day, hour, minute, second)
121    }
122
123    /// Format the datetime as an MTP string.
124    ///
125    /// Returns `Some("YYYYMMDDThhmmss")` if the values are valid (exactly 15 characters),
126    /// or `None` if any value is out of range.
127    ///
128    /// # Example
129    ///
130    /// ```
131    /// use mtp_rs::ptp::DateTime;
132    ///
133    /// let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap();
134    /// assert_eq!(dt.format(), Some("20240315T143022".to_string()));
135    ///
136    /// // Invalid DateTime returns None
137    /// let invalid = DateTime { year: 2024, month: 13, day: 1, hour: 0, minute: 0, second: 0 };
138    /// assert_eq!(invalid.format(), None);
139    /// ```
140    #[must_use]
141    pub fn format(&self) -> Option<String> {
142        if !self.is_valid() {
143            return None;
144        }
145        Some(format!(
146            "{:04}{:02}{:02}T{:02}{:02}{:02}",
147            self.year, self.month, self.day, self.hour, self.minute, self.second
148        ))
149    }
150}
151
152/// Pack a DateTime into MTP string format.
153///
154/// Returns an error if the DateTime contains invalid values.
155pub fn pack_datetime(dt: &DateTime) -> Result<Vec<u8>, crate::Error> {
156    let formatted = dt.format().ok_or_else(|| {
157        crate::Error::invalid_data(format!(
158            "invalid DateTime: year={}, month={}, day={}, hour={}, minute={}, second={}",
159            dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
160        ))
161    })?;
162    Ok(pack_string(&formatted))
163}
164
165/// Unpack a DateTime from a buffer.
166///
167/// Returns the datetime (or None for empty string) and the number of bytes consumed.
168pub fn unpack_datetime(buf: &[u8]) -> Result<(Option<DateTime>, usize), crate::Error> {
169    let (s, consumed) = unpack_string(buf)?;
170
171    if s.is_empty() {
172        return Ok((None, consumed));
173    }
174
175    let dt = DateTime::parse(&s)
176        .ok_or_else(|| crate::Error::invalid_data(format!("invalid datetime format: {}", s)))?;
177
178    Ok((Some(dt), consumed))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use proptest::prelude::*;
185
186    // --- DateTime parsing tests ---
187
188    #[test]
189    fn datetime_parse_basic() {
190        let dt = DateTime::parse("20240315T143022").unwrap();
191        assert_eq!(
192            (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
193            (2024, 3, 15, 14, 30, 22)
194        );
195    }
196
197    #[test]
198    fn datetime_parse_with_timezone_z() {
199        assert!(DateTime::parse("20240315T143022Z").is_some());
200    }
201
202    #[test]
203    fn datetime_parse_with_timezone_positive() {
204        assert!(DateTime::parse("20240315T143022+0530").is_some());
205    }
206
207    #[test]
208    fn datetime_parse_with_timezone_negative() {
209        assert!(DateTime::parse("20240315T143022-0800").is_some());
210    }
211
212    #[test]
213    fn datetime_parse_invalid_too_short() {
214        for s in ["2024031", ""] {
215            assert!(DateTime::parse(s).is_none());
216        }
217    }
218
219    #[test]
220    fn datetime_parse_invalid_no_t_separator() {
221        for s in ["20240315 143022", "20240315143022"] {
222            assert!(DateTime::parse(s).is_none());
223        }
224    }
225
226    #[test]
227    fn datetime_parse_invalid_month() {
228        for s in ["20240015T143022", "20241315T143022"] {
229            // month=0, month=13
230            assert!(DateTime::parse(s).is_none());
231        }
232    }
233
234    #[test]
235    fn datetime_parse_invalid_day() {
236        for s in ["20240100T143022", "20240132T143022"] {
237            // day=0, day=32
238            assert!(DateTime::parse(s).is_none());
239        }
240    }
241
242    #[test]
243    fn datetime_parse_invalid_hour() {
244        assert!(DateTime::parse("20240315T243022").is_none()); // hour=24
245    }
246
247    #[test]
248    fn datetime_parse_invalid_minute() {
249        assert!(DateTime::parse("20240315T146022").is_none()); // minute=60
250    }
251
252    #[test]
253    fn datetime_parse_invalid_second() {
254        assert!(DateTime::parse("20240315T143060").is_none()); // second=60
255    }
256
257    // --- DateTime format tests ---
258
259    #[test]
260    fn datetime_format() {
261        assert_eq!(
262            DateTime::new(2024, 3, 15, 14, 30, 22).unwrap().format(),
263            Some("20240315T143022".into())
264        );
265    }
266
267    #[test]
268    fn datetime_format_with_leading_zeros() {
269        assert_eq!(
270            DateTime::new(2024, 1, 5, 9, 5, 3).unwrap().format(),
271            Some("20240105T090503".into())
272        );
273    }
274
275    #[test]
276    fn datetime_roundtrip() {
277        let original = DateTime::new(2024, 12, 31, 23, 59, 59).unwrap();
278        assert_eq!(
279            DateTime::parse(&original.format().unwrap()).unwrap(),
280            original
281        );
282    }
283
284    #[test]
285    fn datetime_format_invalid_returns_none() {
286        let invalid_cases = [
287            (2024, 13, 1, 0, 0, 0), // invalid month
288            (2024, 1, 1, 0, 60, 0), // invalid minute
289            (10000, 1, 1, 0, 0, 0), // year too large
290        ];
291        for (y, mo, d, h, mi, s) in invalid_cases {
292            let dt = DateTime {
293                year: y,
294                month: mo,
295                day: d,
296                hour: h,
297                minute: mi,
298                second: s,
299            };
300            assert_eq!(dt.format(), None);
301        }
302    }
303
304    #[test]
305    fn datetime_default() {
306        let dt = DateTime::default();
307        assert_eq!(
308            (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
309            (0, 0, 0, 0, 0, 0)
310        );
311    }
312
313    // --- Pack/unpack tests ---
314
315    #[test]
316    fn pack_datetime_basic() {
317        let packed = pack_datetime(&DateTime::new(2024, 3, 15, 14, 30, 22).unwrap()).unwrap();
318        assert_eq!(packed[0], 16); // 15 chars + null terminator
319    }
320
321    #[test]
322    fn pack_datetime_invalid_returns_error() {
323        let invalid = DateTime {
324            year: 2024,
325            month: 13,
326            day: 1,
327            hour: 0,
328            minute: 0,
329            second: 0,
330        };
331        assert!(pack_datetime(&invalid).is_err());
332    }
333
334    #[test]
335    fn unpack_datetime_basic() {
336        let dt = DateTime::new(2024, 3, 15, 14, 30, 22).unwrap();
337        let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap();
338        assert_eq!(unpacked, Some(dt));
339    }
340
341    #[test]
342    fn unpack_datetime_empty_string() {
343        let (dt, consumed) = unpack_datetime(&[0x00]).unwrap();
344        assert_eq!((dt, consumed), (None, 1));
345    }
346
347    #[test]
348    fn unpack_datetime_invalid_format() {
349        assert!(unpack_datetime(&pack_string("not a date")).is_err());
350    }
351
352    #[test]
353    fn datetime_pack_unpack_roundtrip() {
354        for dt in [
355            DateTime::new(2024, 1, 1, 0, 0, 0).unwrap(),
356            DateTime::new(2024, 12, 31, 23, 59, 59).unwrap(),
357            DateTime::new(1999, 6, 15, 12, 30, 45).unwrap(),
358        ] {
359            let (unpacked, _) = unpack_datetime(&pack_datetime(&dt).unwrap()).unwrap();
360            assert_eq!(unpacked, Some(dt));
361        }
362    }
363
364    // --- Boundary tests ---
365
366    #[test]
367    fn datetime_boundary_day_31() {
368        let dt = DateTime {
369            year: 2024,
370            month: 1,
371            day: 31,
372            hour: 0,
373            minute: 0,
374            second: 0,
375        };
376        let parsed = DateTime::parse(&dt.format().unwrap()).unwrap();
377        assert_eq!(parsed.day, 31);
378    }
379
380    #[test]
381    fn datetime_boundary_year_0() {
382        let dt = DateTime {
383            year: 0,
384            month: 1,
385            day: 1,
386            hour: 0,
387            minute: 0,
388            second: 0,
389        };
390        if let Some(p) = DateTime::parse(&dt.format().unwrap()) {
391            assert_eq!(p.year, 0);
392        }
393    }
394
395    #[test]
396    fn datetime_boundary_year_9999() {
397        let dt = DateTime {
398            year: 9999,
399            month: 12,
400            day: 31,
401            hour: 23,
402            minute: 59,
403            second: 59,
404        };
405        assert_eq!(DateTime::parse(&dt.format().unwrap()).unwrap().year, 9999);
406    }
407
408    #[test]
409    fn datetime_boundary_year_10000() {
410        let dt = DateTime {
411            year: 10000,
412            month: 1,
413            day: 1,
414            hour: 0,
415            minute: 0,
416            second: 0,
417        };
418        assert!(dt.format().is_none());
419        assert!(pack_datetime(&dt).is_err());
420    }
421
422    // --- Property-based tests ---
423
424    fn valid_datetime() -> impl Strategy<Value = DateTime> {
425        (
426            1000u16..9999u16,
427            1u8..=12u8,
428            1u8..=28u8,
429            0u8..=23u8,
430            0u8..=59u8,
431            0u8..=59u8,
432        )
433            .prop_map(|(year, month, day, hour, minute, second)| DateTime {
434                year,
435                month,
436                day,
437                hour,
438                minute,
439                second,
440            })
441    }
442
443    proptest! {
444        #[test]
445        fn prop_datetime_format_parse_roundtrip(dt in valid_datetime()) {
446            let formatted = dt.format().unwrap();
447            prop_assert_eq!(DateTime::parse(&formatted).unwrap(), dt);
448        }
449
450        #[test]
451        fn prop_datetime_pack_unpack_roundtrip(dt in valid_datetime()) {
452            let packed = pack_datetime(&dt).unwrap();
453            let (unpacked, consumed) = unpack_datetime(&packed).unwrap();
454            prop_assert_eq!(unpacked, Some(dt));
455            prop_assert_eq!(consumed, packed.len());
456        }
457
458        #[test]
459        fn prop_datetime_format_length(dt in valid_datetime()) {
460            prop_assert_eq!(dt.format().unwrap().len(), 15);
461        }
462
463        #[test]
464        fn fuzz_datetime_invalid_month(
465            year in 1900u16..2100u16,
466            month in prop::sample::select(vec![0u8, 13, 14, 99, 255]),
467            day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8,
468        ) {
469            let dt = DateTime { year, month, day, hour, minute, second };
470            prop_assert!(dt.format().is_none());
471            prop_assert!(pack_datetime(&dt).is_err());
472        }
473
474        #[test]
475        fn fuzz_datetime_invalid_day(
476            year in 1900u16..2100u16, month in 1u8..=12u8,
477            day in prop::sample::select(vec![0u8, 32, 33, 99, 255]),
478            hour in 0u8..=23u8, minute in 0u8..=59u8, second in 0u8..=59u8,
479        ) {
480            let dt = DateTime { year, month, day, hour, minute, second };
481            prop_assert!(dt.format().is_none());
482            prop_assert!(pack_datetime(&dt).is_err());
483        }
484
485        #[test]
486        fn fuzz_datetime_invalid_hour(
487            year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8,
488            hour in prop::sample::select(vec![24u8, 25, 99, 255]),
489            minute in 0u8..=59u8, second in 0u8..=59u8,
490        ) {
491            let dt = DateTime { year, month, day, hour, minute, second };
492            prop_assert!(dt.format().is_none());
493            prop_assert!(pack_datetime(&dt).is_err());
494        }
495
496        #[test]
497        fn fuzz_datetime_invalid_minute(
498            year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8,
499            minute in prop::sample::select(vec![60u8, 61, 99]),
500            second in 0u8..=59u8,
501        ) {
502            let dt = DateTime { year, month, day, hour, minute, second };
503            prop_assert!(dt.format().is_none());
504            prop_assert!(pack_datetime(&dt).is_err());
505        }
506
507        #[test]
508        fn fuzz_datetime_minute_overflow(
509            year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8,
510            minute in 100u8..=255u8, second in 0u8..=59u8,
511        ) {
512            let dt = DateTime { year, month, day, hour, minute, second };
513            prop_assert!(dt.format().is_none());
514            prop_assert!(pack_datetime(&dt).is_err());
515        }
516
517        #[test]
518        fn fuzz_datetime_invalid_second(
519            year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8,
520            second in prop::sample::select(vec![60u8, 61, 99]),
521        ) {
522            let dt = DateTime { year, month, day, hour, minute, second };
523            prop_assert!(dt.format().is_none());
524            prop_assert!(pack_datetime(&dt).is_err());
525        }
526
527        #[test]
528        fn fuzz_datetime_second_overflow(
529            year in 1900u16..2100u16, month in 1u8..=12u8, day in 1u8..=28u8, hour in 0u8..=23u8, minute in 0u8..=59u8,
530            second in 100u8..=255u8,
531        ) {
532            let dt = DateTime { year, month, day, hour, minute, second };
533            prop_assert!(dt.format().is_none());
534            prop_assert!(pack_datetime(&dt).is_err());
535        }
536
537        #[test]
538        fn fuzz_datetime_parse_garbage(s in ".*") {
539            let _ = DateTime::parse(&s);
540        }
541
542        #[test]
543        fn fuzz_datetime_parse_malformed(prefix in "[0-9]{0,20}", suffix in "[^T]*") {
544            let _ = DateTime::parse(&format!("{}{}", prefix, suffix));
545        }
546    }
547
548    crate::fuzz_bytes_fn!(fuzz_unpack_datetime, unpack_datetime, 50);
549}