Skip to main content

shipper_duration/
lib.rs

1//! Duration parsing and serde codecs for shipper.
2//!
3//! This crate centralizes duration handling so CLI parsing and config/state
4//! serde use one implementation.
5
6use std::time::Duration;
7
8use serde::{Deserialize, Deserializer, Serializer};
9
10/// Parse a human-readable duration string (for example `2s`, `500ms`, `1m`).
11///
12/// # Examples
13///
14/// ```
15/// use std::time::Duration;
16/// use shipper_duration::parse_duration;
17///
18/// assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
19/// assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
20/// assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
21/// ```
22pub fn parse_duration(input: &str) -> Result<Duration, humantime::DurationError> {
23    humantime::parse_duration(input)
24}
25
26/// Deserialize a [`Duration`] from either a human-readable string or a millisecond integer.
27pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
28where
29    D: Deserializer<'de>,
30{
31    #[derive(Deserialize)]
32    #[serde(untagged)]
33    enum DurationHelper {
34        String(String),
35        U64(u64),
36    }
37
38    match DurationHelper::deserialize(deserializer)? {
39        DurationHelper::String(s) => parse_duration(&s)
40            .map_err(|e| serde::de::Error::custom(format!("invalid duration: {e}"))),
41        DurationHelper::U64(ms) => Ok(Duration::from_millis(ms)),
42    }
43}
44
45/// Serialize a [`Duration`] as milliseconds (`u64`) for stable round-tripping.
46pub fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
47where
48    S: Serializer,
49{
50    serializer.serialize_u64(duration.as_millis() as u64)
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use proptest::prelude::*;
57
58    #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59    struct DurationHolder {
60        #[serde(
61            deserialize_with = "deserialize_duration",
62            serialize_with = "serialize_duration"
63        )]
64        value: Duration,
65    }
66
67    #[test]
68    fn parse_duration_accepts_human_readable_values() {
69        assert_eq!(
70            parse_duration("250ms").expect("parse"),
71            Duration::from_millis(250)
72        );
73        assert_eq!(parse_duration("2s").expect("parse"), Duration::from_secs(2));
74    }
75
76    #[test]
77    fn deserialize_accepts_number_and_string() {
78        let from_num: DurationHolder = serde_json::from_str(r#"{"value":1500}"#).expect("json");
79        assert_eq!(from_num.value, Duration::from_millis(1500));
80
81        let from_str: DurationHolder = serde_json::from_str(r#"{"value":"1500ms"}"#).expect("json");
82        assert_eq!(from_str.value, Duration::from_millis(1500));
83    }
84
85    #[test]
86    fn serialize_writes_milliseconds() {
87        let value = DurationHolder {
88            value: Duration::from_millis(4321),
89        };
90        let json = serde_json::to_value(&value).expect("json");
91        assert_eq!(json["value"], 4321);
92    }
93
94    #[test]
95    fn deserialize_rejects_invalid_duration_string() {
96        let err = serde_json::from_str::<DurationHolder>(r#"{"value":"not-a-duration"}"#)
97            .expect_err("must fail");
98        assert!(err.to_string().contains("invalid duration"));
99    }
100
101    proptest! {
102        #[test]
103        fn duration_roundtrips_as_milliseconds(ms in 0_u64..10_000_000_000) {
104            let holder = DurationHolder {
105                value: Duration::from_millis(ms),
106            };
107
108            let json = serde_json::to_string(&holder).expect("serialize");
109            let reparsed: DurationHolder = serde_json::from_str(&json).expect("deserialize");
110
111            prop_assert_eq!(reparsed, holder);
112        }
113    }
114
115    #[test]
116    fn serde_json_full_roundtrip() {
117        let holder = DurationHolder {
118            value: Duration::from_secs(3661),
119        };
120        let json = serde_json::to_string(&holder).unwrap();
121        let reparsed: DurationHolder = serde_json::from_str(&json).unwrap();
122        assert_eq!(reparsed, holder);
123    }
124
125    #[test]
126    fn serde_toml_string_deserialization() {
127        let toml_str = r#"value = "2m 30s""#;
128        let holder: DurationHolder = toml::from_str(toml_str).unwrap();
129        assert_eq!(holder.value, Duration::from_secs(150));
130    }
131
132    #[test]
133    fn deserialize_rejects_boolean() {
134        let err =
135            serde_json::from_str::<DurationHolder>(r#"{"value":true}"#).expect_err("must fail");
136        assert!(!err.to_string().is_empty());
137    }
138
139    #[test]
140    fn deserialize_rejects_float() {
141        let err =
142            serde_json::from_str::<DurationHolder>(r#"{"value":1.5}"#).expect_err("must fail");
143        assert!(!err.to_string().is_empty());
144    }
145}
146
147#[cfg(test)]
148mod proptests {
149    use super::*;
150    use proptest::prelude::*;
151
152    #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153    struct DurationHolder {
154        #[serde(
155            deserialize_with = "deserialize_duration",
156            serialize_with = "serialize_duration"
157        )]
158        value: Duration,
159    }
160
161    proptest! {
162        /// Human-readable formatting always produces a non-empty string.
163        #[test]
164        fn format_is_never_empty(ms in 0u64..10_000_000_000u64) {
165            let d = Duration::from_millis(ms);
166            let formatted = humantime::format_duration(d).to_string();
167            prop_assert!(!formatted.is_empty(), "formatted duration was empty for {ms}ms");
168        }
169
170        /// Formatting the same duration twice always yields the same string.
171        #[test]
172        fn format_consistency(ms in 0u64..10_000_000_000u64) {
173            let d = Duration::from_millis(ms);
174            let first = humantime::format_duration(d).to_string();
175            let second = humantime::format_duration(d).to_string();
176            prop_assert_eq!(first, second);
177        }
178
179        /// format → parse round-trip preserves the original duration.
180        #[test]
181        fn parse_format_roundtrip(ms in 0u64..10_000_000u64) {
182            let d = Duration::from_millis(ms);
183            let formatted = humantime::format_duration(d).to_string();
184            let parsed = parse_duration(&formatted).expect("should parse formatted duration");
185            prop_assert_eq!(parsed, d);
186        }
187
188        /// Sub-second durations mention "ms" in the formatted output.
189        #[test]
190        fn millisecond_range_contains_ms(ms in 1u64..1000u64) {
191            let d = Duration::from_millis(ms);
192            let formatted = humantime::format_duration(d).to_string();
193            prop_assert!(formatted.contains("ms"), "expected 'ms' in \"{formatted}\"");
194        }
195
196        /// Whole-second durations (< 1 min) mention "s" in the formatted output.
197        #[test]
198        fn seconds_range_contains_s(secs in 1u64..60u64) {
199            let d = Duration::from_secs(secs);
200            let formatted = humantime::format_duration(d).to_string();
201            prop_assert!(formatted.contains('s'), "expected 's' in \"{formatted}\"");
202        }
203
204        /// Whole-minute durations mention "m" in the formatted output.
205        #[test]
206        fn minutes_range_contains_m(mins in 1u64..60u64) {
207            let d = Duration::from_secs(mins * 60);
208            let formatted = humantime::format_duration(d).to_string();
209            prop_assert!(formatted.contains('m'), "expected 'm' in \"{formatted}\"");
210        }
211
212        /// Whole-hour durations mention "h" in the formatted output.
213        #[test]
214        fn hours_range_contains_h(hours in 1u64..24u64) {
215            let d = Duration::from_secs(hours * 3600);
216            let formatted = humantime::format_duration(d).to_string();
217            prop_assert!(formatted.contains('h'), "expected 'h' in \"{formatted}\"");
218        }
219
220        /// Serde JSON round-trip via integer millisecond representation.
221        #[test]
222        fn serde_json_u64_roundtrip(ms in 0u64..10_000_000_000u64) {
223            let json = format!(r#"{{"value":{ms}}}"#);
224            let holder: DurationHolder = serde_json::from_str(&json).expect("deserialize");
225            prop_assert_eq!(holder.value, Duration::from_millis(ms));
226        }
227
228        /// Serde TOML round-trip via human-readable string representation.
229        #[test]
230        fn serde_toml_string_roundtrip(ms in 1u64..10_000_000u64) {
231            let d = Duration::from_millis(ms);
232            let formatted = humantime::format_duration(d).to_string();
233            let toml_str = format!("value = \"{formatted}\"");
234            let holder: DurationHolder = toml::from_str(&toml_str).expect("toml deserialize");
235            prop_assert_eq!(holder.value, d);
236        }
237
238        /// Arbitrary UTF-8 strings never cause parse_duration to panic.
239        #[test]
240        fn arbitrary_strings_never_panic(s in "\\PC{0,64}") {
241            let _ = parse_duration(&s);
242        }
243
244        /// Adding two durations then formatting and reparsing produces the sum.
245        #[test]
246        fn combined_duration_format_roundtrip(
247            a_ms in 0u64..1_000_000u64,
248            b_ms in 0u64..1_000_000u64,
249        ) {
250            let combined = Duration::from_millis(a_ms) + Duration::from_millis(b_ms);
251            let formatted = humantime::format_duration(combined).to_string();
252            let parsed = parse_duration(&formatted).expect("should parse formatted combined duration");
253            prop_assert_eq!(parsed, combined);
254        }
255    }
256}
257
258#[cfg(test)]
259mod edge_case_tests {
260    use super::*;
261
262    // -- Zero duration --
263
264    #[test]
265    fn parse_zero_ms() {
266        assert_eq!(parse_duration("0ms").unwrap(), Duration::ZERO);
267    }
268
269    #[test]
270    fn parse_zero_seconds() {
271        assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
272    }
273
274    #[test]
275    fn deserialize_zero_from_integer() {
276        #[derive(serde::Deserialize)]
277        struct H {
278            #[serde(deserialize_with = "deserialize_duration")]
279            v: Duration,
280        }
281        let h: H = serde_json::from_str(r#"{"v":0}"#).unwrap();
282        assert_eq!(h.v, Duration::ZERO);
283    }
284
285    #[test]
286    fn serialize_zero_is_zero() {
287        #[derive(serde::Serialize)]
288        struct H {
289            #[serde(serialize_with = "serialize_duration")]
290            v: Duration,
291        }
292        let json = serde_json::to_value(H { v: Duration::ZERO }).unwrap();
293        assert_eq!(json["v"], 0);
294    }
295
296    // -- Large durations --
297
298    #[test]
299    fn parse_large_hours() {
300        let d = parse_duration("9999h").unwrap();
301        assert_eq!(d, Duration::from_secs(9999 * 3600));
302    }
303
304    #[test]
305    fn serialize_large_millis() {
306        #[derive(serde::Serialize)]
307        struct H {
308            #[serde(serialize_with = "serialize_duration")]
309            v: Duration,
310        }
311        let large = Duration::from_secs(365 * 24 * 3600); // 1 year
312        let json = serde_json::to_value(H { v: large }).unwrap();
313        assert_eq!(json["v"], 365 * 24 * 3600 * 1000_u64);
314    }
315
316    // -- Sub-millisecond precision --
317
318    #[test]
319    fn parse_microseconds() {
320        let d = parse_duration("500us").unwrap();
321        assert_eq!(d, Duration::from_micros(500));
322    }
323
324    #[test]
325    fn parse_nanoseconds() {
326        let d = parse_duration("100ns").unwrap();
327        assert_eq!(d, Duration::from_nanos(100));
328    }
329
330    #[test]
331    fn serialize_truncates_sub_millis_to_zero() {
332        // Duration with only microseconds → as_millis() == 0
333        #[derive(serde::Serialize)]
334        struct H {
335            #[serde(serialize_with = "serialize_duration")]
336            v: Duration,
337        }
338        let d = Duration::from_micros(999);
339        let json = serde_json::to_value(H { v: d }).unwrap();
340        assert_eq!(json["v"], 0);
341    }
342
343    // -- Parsing edge cases --
344
345    #[test]
346    fn parse_empty_string_is_error() {
347        assert!(parse_duration("").is_err());
348    }
349
350    #[test]
351    fn parse_whitespace_only_is_error() {
352        assert!(parse_duration("   ").is_err());
353    }
354
355    #[test]
356    fn parse_combined_units() {
357        let d = parse_duration("1h 30m 15s").unwrap();
358        assert_eq!(d, Duration::from_secs(3600 + 30 * 60 + 15));
359    }
360
361    #[test]
362    fn parse_day_unit() {
363        let d = parse_duration("2days").unwrap();
364        assert_eq!(d, Duration::from_secs(2 * 86400));
365    }
366
367    // -- Comparison / ordering --
368
369    #[test]
370    fn parsed_durations_maintain_ordering() {
371        let a = parse_duration("500ms").unwrap();
372        let b = parse_duration("1s").unwrap();
373        let c = parse_duration("1m").unwrap();
374        let d = parse_duration("1h").unwrap();
375        assert!(a < b);
376        assert!(b < c);
377        assert!(c < d);
378    }
379
380    // -- Arithmetic --
381
382    #[test]
383    fn parsed_durations_support_addition() {
384        let a = parse_duration("30s").unwrap();
385        let b = parse_duration("30s").unwrap();
386        assert_eq!(a + b, Duration::from_secs(60));
387    }
388
389    #[test]
390    fn parsed_durations_support_subtraction() {
391        let a = parse_duration("2m").unwrap();
392        let b = parse_duration("30s").unwrap();
393        assert_eq!(a - b, Duration::from_secs(90));
394    }
395
396    #[test]
397    fn parsed_duration_supports_multiplication() {
398        let a = parse_duration("500ms").unwrap();
399        assert_eq!(a * 4, Duration::from_secs(2));
400    }
401
402    // -- Combined units without spaces --
403
404    #[test]
405    fn parse_combined_no_spaces() {
406        assert_eq!(
407            parse_duration("1h30m").unwrap(),
408            Duration::from_secs(3600 + 30 * 60)
409        );
410        assert_eq!(
411            parse_duration("2m30s").unwrap(),
412            Duration::from_secs(2 * 60 + 30)
413        );
414    }
415
416    // -- Zero forms equivalence --
417
418    #[test]
419    fn parse_zero_forms_are_equivalent() {
420        let zero = Duration::ZERO;
421        assert_eq!(parse_duration("0s").unwrap(), zero);
422        assert_eq!(parse_duration("0ms").unwrap(), zero);
423        assert_eq!(parse_duration("0ns").unwrap(), zero);
424        assert_eq!(parse_duration("0us").unwrap(), zero);
425    }
426
427    // -- Invalid input patterns --
428
429    #[test]
430    fn parse_number_without_unit_is_error() {
431        assert!(parse_duration("42").is_err());
432    }
433
434    #[test]
435    fn parse_unknown_unit_is_error() {
436        assert!(parse_duration("5xyz").is_err());
437    }
438
439    #[test]
440    fn parse_negative_is_error() {
441        assert!(parse_duration("-5s").is_err());
442    }
443
444    #[test]
445    fn parse_overflow_is_error() {
446        assert!(parse_duration("99999999999999999999999999999s").is_err());
447    }
448
449    // -- Format-parse roundtrips --
450
451    #[test]
452    fn format_then_parse_roundtrip_deterministic() {
453        let cases = [
454            Duration::ZERO,
455            Duration::from_millis(250),
456            Duration::from_secs(42),
457            Duration::from_secs(3661),
458            Duration::from_secs(90061),
459        ];
460        for d in cases {
461            let formatted = humantime::format_duration(d).to_string();
462            let parsed = parse_duration(&formatted).unwrap();
463            assert_eq!(parsed, d, "roundtrip failed for {formatted}");
464        }
465    }
466
467    // -- Complex multi-unit combinations --
468
469    #[test]
470    fn parse_days_hours_minutes_combined() {
471        let d = parse_duration("1day 2h 30m").unwrap();
472        assert_eq!(d, Duration::from_secs(86400 + 2 * 3600 + 30 * 60));
473    }
474}
475
476#[cfg(test)]
477mod snapshot_tests {
478    use super::*;
479    use insta::assert_debug_snapshot;
480
481    #[test]
482    fn snapshot_parsed_zero() {
483        assert_debug_snapshot!(parse_duration("0s").unwrap());
484    }
485
486    #[test]
487    fn snapshot_parsed_millis() {
488        assert_debug_snapshot!(parse_duration("250ms").unwrap());
489    }
490
491    #[test]
492    fn snapshot_parsed_seconds() {
493        assert_debug_snapshot!(parse_duration("42s").unwrap());
494    }
495
496    #[test]
497    fn snapshot_parsed_minutes() {
498        assert_debug_snapshot!(parse_duration("5m").unwrap());
499    }
500
501    #[test]
502    fn snapshot_parsed_hours() {
503        assert_debug_snapshot!(parse_duration("2h").unwrap());
504    }
505
506    #[test]
507    fn snapshot_parsed_combined() {
508        assert_debug_snapshot!(parse_duration("1h 30m 15s 200ms").unwrap());
509    }
510
511    #[test]
512    fn snapshot_parse_error() {
513        assert_debug_snapshot!(parse_duration("not-valid"));
514    }
515
516    #[test]
517    fn snapshot_deserialized_from_integer() {
518        #[derive(Debug, serde::Deserialize)]
519        #[allow(dead_code)]
520        struct H {
521            #[serde(deserialize_with = "deserialize_duration")]
522            v: Duration,
523        }
524        let h: H = serde_json::from_str(r#"{"v":3661000}"#).unwrap();
525        assert_debug_snapshot!(h);
526    }
527
528    #[test]
529    fn snapshot_deserialized_from_string() {
530        #[derive(Debug, serde::Deserialize)]
531        #[allow(dead_code)]
532        struct H {
533            #[serde(deserialize_with = "deserialize_duration")]
534            v: Duration,
535        }
536        let h: H = serde_json::from_str(r#"{"v":"1h 1m 1s"}"#).unwrap();
537        assert_debug_snapshot!(h);
538    }
539
540    #[test]
541    fn snapshot_formatted_common_durations() {
542        let formatted: Vec<(&str, String)> = vec![
543            ("zero", Duration::ZERO),
544            ("half_second", Duration::from_millis(500)),
545            ("one_minute", Duration::from_secs(60)),
546            ("one_hour_one_min_one_sec", Duration::from_secs(3661)),
547            ("one_day", Duration::from_secs(86400)),
548        ]
549        .into_iter()
550        .map(|(name, d)| (name, humantime::format_duration(d).to_string()))
551        .collect();
552        assert_debug_snapshot!(formatted);
553    }
554
555    #[test]
556    fn snapshot_parse_errors_various() {
557        let results: Vec<(&str, String)> = vec!["", "   ", "42", "-5s", "5xyz"]
558            .into_iter()
559            .map(|input| (input, parse_duration(input).unwrap_err().to_string()))
560            .collect();
561        assert_debug_snapshot!(results);
562    }
563}