Skip to main content

go_duration/
lib.rs

1//! Go `time.Duration` string parsing.
2//!
3//! A small, dependency-free primitive that parses duration strings in the exact format accepted by Go's
4//! [`time.ParseDuration`][go-duration]. The Datadog Agent (via [spf13/viper][viper] and [spf13/cast][cast]) coerces
5//! configuration values into Go durations, and several places in Saluki need to interpret those same strings,
6//! including the runtime configuration layer and build-time config-schema codegen. This crate is the single owner of
7//! that algorithm so it isn't duplicated in each of those places.
8//!
9//! Only the parsing primitive lives here. Higher-level coercion (for example, viper's "a bare integer is nanoseconds"
10//! fallback) is intentionally left to callers.
11//!
12//! [go-duration]: https://pkg.go.dev/time#ParseDuration
13//! [viper]: https://github.com/spf13/viper
14//! [cast]: https://github.com/spf13/cast
15#![deny(warnings)]
16#![deny(missing_docs)]
17
18use std::error::Error;
19use std::fmt::{self, Display, Formatter};
20use std::time::Duration;
21
22/// Maximum number of nanoseconds we accept, matching the Agent's cap: Go's `time.Duration` is an `int64`, so
23/// `i64::MAX` nanoseconds is the largest representable value.
24const MAX_NANOS_U64: u64 = i64::MAX as u64;
25
26/// Error returned when a duration string can't be parsed.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum ParseDurationError {
29    /// The value was syntactically invalid.
30    Invalid {
31        /// The original input string.
32        input: String,
33        /// Reason the input was rejected.
34        reason: String,
35    },
36    /// The value parsed to a negative duration, which [`std::time::Duration`] can't represent.
37    Negative,
38    /// The value exceeds the range of [`std::time::Duration`] as nanoseconds.
39    Overflow,
40}
41
42impl Display for ParseDurationError {
43    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
44        match self {
45            ParseDurationError::Invalid { input, reason } => {
46                write!(f, "invalid duration '{}': {}", input, reason)
47            }
48            ParseDurationError::Negative => write!(f, "negative durations are not supported"),
49            ParseDurationError::Overflow => write!(f, "duration value exceeds supported range"),
50        }
51    }
52}
53
54impl Error for ParseDurationError {}
55
56fn invalid(input: &str, reason: impl Into<String>) -> ParseDurationError {
57    ParseDurationError::Invalid {
58        input: input.to_string(),
59        reason: reason.into(),
60    }
61}
62
63/// Parses a string in the exact format accepted by Go's `time.ParseDuration`, restricted to non-negative values
64/// (since [`std::time::Duration`] can't represent negatives).
65///
66/// Accepts a decimal number with a required unit suffix, optionally repeated (for example, `"300ms"`, `"1h30m"`,
67/// `"2h45m30.5s"`), with an optional leading sign. Valid units are `ns`, `us` (or `µs`/`μs`), `ms`, `s`, `m`, and `h`.
68/// A bare `0` (optionally signed) is accepted as zero.
69///
70/// # Errors
71///
72/// Returns [`ParseDurationError`] if the string is empty, has a missing or unknown unit, contains no digits, parses to
73/// a negative duration, or overflows the supported nanosecond range.
74pub fn parse_duration(s: &str) -> Result<Duration, ParseDurationError> {
75    let orig = s;
76    let mut rest = s;
77    let mut total_ns: u128 = 0;
78    let mut negative = false;
79
80    if let Some(c) = rest.chars().next() {
81        if c == '+' || c == '-' {
82            negative = c == '-';
83            rest = &rest[1..];
84        }
85    }
86
87    // Special case: "0" alone (possibly after a sign) is zero.
88    if rest == "0" {
89        return Ok(Duration::ZERO);
90    }
91    if rest.is_empty() {
92        return Err(invalid(orig, "empty duration"));
93    }
94
95    while !rest.is_empty() {
96        let (int_part, after_int) = consume_digits(rest);
97        let had_int = !int_part.is_empty();
98
99        let (frac_part, after_frac) = if let Some(stripped) = after_int.strip_prefix('.') {
100            consume_digits(stripped)
101        } else {
102            ("", after_int)
103        };
104        let consumed_dot = after_int.starts_with('.');
105        let had_frac = consumed_dot && !frac_part.is_empty();
106
107        if !had_int && !had_frac {
108            return Err(invalid(orig, "expected digits"));
109        }
110
111        rest = after_frac;
112
113        let unit_str = consume_unit(rest);
114        if unit_str.is_empty() {
115            return Err(invalid(orig, "missing unit"));
116        }
117        rest = &rest[unit_str.len()..];
118
119        let unit_ns: u128 = match unit_str {
120            "ns" => 1,
121            "us" | "µs" | "μs" => 1_000,
122            "ms" => 1_000_000,
123            "s" => 1_000_000_000,
124            "m" => 60 * 1_000_000_000,
125            "h" => 3_600 * 1_000_000_000,
126            other => return Err(invalid(orig, format!("unknown unit '{}'", other))),
127        };
128
129        let int_val: u128 = if int_part.is_empty() {
130            0
131        } else {
132            int_part
133                .parse::<u128>()
134                .map_err(|_| invalid(orig, "integer overflow"))?
135        };
136
137        let mut ns = int_val.checked_mul(unit_ns).ok_or_else(|| invalid(orig, "overflow"))?;
138
139        if !frac_part.is_empty() {
140            // Truncate the fraction to at most 18 digits to keep the intermediate u128 math well within range. 18
141            // decimal digits of precision is well beyond nanoseconds for every supported unit.
142            let keep = frac_part.len().min(18);
143            let frac_digits = &frac_part[..keep];
144            let mut scale: u128 = 1;
145            for _ in 0..keep {
146                scale *= 10;
147            }
148            let f: u128 = frac_digits
149                .parse::<u128>()
150                .map_err(|_| invalid(orig, "invalid fractional"))?;
151            let frac_ns = f.checked_mul(unit_ns).ok_or_else(|| invalid(orig, "overflow"))? / scale;
152            ns = ns.checked_add(frac_ns).ok_or_else(|| invalid(orig, "overflow"))?;
153        }
154
155        total_ns = total_ns.checked_add(ns).ok_or_else(|| invalid(orig, "overflow"))?;
156    }
157
158    if negative && total_ns != 0 {
159        return Err(ParseDurationError::Negative);
160    }
161
162    if total_ns > MAX_NANOS_U64 as u128 {
163        return Err(ParseDurationError::Overflow);
164    }
165    Ok(Duration::from_nanos(total_ns as u64))
166}
167
168fn consume_digits(s: &str) -> (&str, &str) {
169    let end = s.bytes().take_while(|b| b.is_ascii_digit()).count();
170    s.split_at(end)
171}
172
173fn consume_unit(s: &str) -> &str {
174    let mut end = 0;
175    for (i, c) in s.char_indices() {
176        if c.is_ascii_alphabetic() || c == 'µ' || c == 'μ' {
177            end = i + c.len_utf8();
178        } else {
179            break;
180        }
181    }
182    &s[..end]
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const NS: Duration = Duration::from_nanos(1);
190    const MS: Duration = Duration::from_millis(1);
191    const S: Duration = Duration::from_secs(1);
192    const M: Duration = Duration::from_secs(60);
193    const H: Duration = Duration::from_secs(3600);
194
195    #[test]
196    fn supports_go_style_units() {
197        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
198        assert_eq!(parse_duration("1m0s").unwrap(), Duration::from_secs(60));
199        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
200        assert_eq!(
201            parse_duration("1h2m3.5s").unwrap(),
202            Duration::from_secs(3723) + Duration::from_millis(500)
203        );
204        assert_eq!(parse_duration("250us").unwrap(), Duration::from_micros(250));
205        assert_eq!(parse_duration("250µs").unwrap(), Duration::from_micros(250));
206        assert_eq!(parse_duration("250μs").unwrap(), Duration::from_micros(250));
207    }
208
209    #[test]
210    fn supports_signs_zero_and_fractions() {
211        assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
212        assert_eq!(parse_duration("+0").unwrap(), Duration::ZERO);
213        assert_eq!(parse_duration("-0").unwrap(), Duration::ZERO);
214        assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
215        assert_eq!(parse_duration("+5h").unwrap(), 5 * H);
216        assert_eq!(parse_duration(".5s").unwrap(), 500 * MS);
217        assert_eq!(parse_duration("1.5h").unwrap(), 90 * M);
218        assert_eq!(
219            parse_duration("2h45m30.5s").unwrap(),
220            (2 * H) + (45 * M) + (30 * S) + (500 * MS)
221        );
222        assert_eq!(
223            parse_duration("1h1m1s1ms1us1ns").unwrap(),
224            H + M + S + MS + (1000 * NS) + NS
225        );
226    }
227
228    #[test]
229    fn largest_representable_value() {
230        assert_eq!(
231            parse_duration("9223372036854775807ns").unwrap(),
232            Duration::from_nanos(9_223_372_036_854_775_807)
233        );
234    }
235
236    #[test]
237    fn rejects_invalid_and_out_of_range() {
238        // Bare integers (viper's nanosecond fallback) are not part of Go's grammar and are rejected here.
239        assert!(matches!(parse_duration("10"), Err(ParseDurationError::Invalid { .. })));
240        assert!(matches!(parse_duration(""), Err(ParseDurationError::Invalid { .. })));
241        assert!(matches!(parse_duration("abc"), Err(ParseDurationError::Invalid { .. })));
242        assert!(matches!(parse_duration("1d"), Err(ParseDurationError::Invalid { .. })));
243        assert!(matches!(
244            parse_duration("5m32sFOO"),
245            Err(ParseDurationError::Invalid { .. })
246        ));
247        assert!(matches!(parse_duration("-1s"), Err(ParseDurationError::Negative)));
248        assert!(matches!(
249            parse_duration("9223372036854775808ns"),
250            Err(ParseDurationError::Overflow)
251        ));
252    }
253
254    #[test]
255    fn error_display_messages() {
256        assert!(parse_duration("").unwrap_err().to_string().contains("empty duration"));
257        assert!(parse_duration(".s")
258            .unwrap_err()
259            .to_string()
260            .contains("expected digits"));
261        assert!(parse_duration("5ns5").unwrap_err().to_string().contains("missing unit"));
262        assert!(parse_duration("1d")
263            .unwrap_err()
264            .to_string()
265            .contains("unknown unit 'd'"));
266        assert!(parse_duration("-1s").unwrap_err().to_string().contains("negative"));
267        assert!(parse_duration("9223372036854775808ns")
268            .unwrap_err()
269            .to_string()
270            .contains("exceeds"));
271    }
272}