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//! Two entry points are provided: [`parse_duration`] accepts only Go's strict `time.ParseDuration` grammar, while
10//! [`parse_duration_or_nanos`] adds viper/cast's coercion where a unit-less bare integer string (for example `"30"`)
11//! is read as that many nanoseconds. Callers that need Agent-compatible configuration coercion should use the latter.
12//!
13//! [go-duration]: https://pkg.go.dev/time#ParseDuration
14//! [viper]: https://github.com/spf13/viper
15//! [cast]: https://github.com/spf13/cast
16#![deny(warnings)]
17#![deny(missing_docs)]
18
19use std::error::Error;
20use std::fmt::{self, Display, Formatter};
21use std::time::Duration;
22
23/// Maximum nanosecond value accepted for compatibility with Go's `time.Duration`.
24pub const MAX_DURATION_NANOS: 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_DURATION_NANOS as u128 {
163        return Err(ParseDurationError::Overflow);
164    }
165    Ok(Duration::from_nanos(total_ns as u64))
166}
167
168/// Parses a duration string the way the Datadog Agent does: first as a strict Go duration via [`parse_duration`],
169/// then as a bare integer count of nanoseconds when the trimmed input contains only an integer. For example, `"30"`
170/// becomes 30 nanoseconds. Surrounding whitespace is ignored.
171///
172/// Use [`parse_duration`] instead when only Go's `time.ParseDuration` grammar should be accepted.
173///
174/// # Errors
175///
176/// Returns [`ParseDurationError`] when the value is neither a Go duration nor a bare integer, is negative, or exceeds
177/// the supported nanosecond range.
178pub fn parse_duration_or_nanos(s: &str) -> Result<Duration, ParseDurationError> {
179    let trimmed = s.trim();
180    match parse_duration(trimmed) {
181        Ok(duration) => Ok(duration),
182        Err(unit_error) => match trimmed.parse::<i128>() {
183            Ok(nanos) if nanos < 0 => Err(ParseDurationError::Negative),
184            Ok(nanos) if nanos > MAX_DURATION_NANOS as i128 => Err(ParseDurationError::Overflow),
185            Ok(nanos) => Ok(Duration::from_nanos(nanos as u64)),
186            Err(_) => Err(unit_error),
187        },
188    }
189}
190
191fn consume_digits(s: &str) -> (&str, &str) {
192    let end = s.bytes().take_while(|b| b.is_ascii_digit()).count();
193    s.split_at(end)
194}
195
196fn consume_unit(s: &str) -> &str {
197    let mut end = 0;
198    for (i, c) in s.char_indices() {
199        if c.is_ascii_alphabetic() || c == 'µ' || c == 'μ' {
200            end = i + c.len_utf8();
201        } else {
202            break;
203        }
204    }
205    &s[..end]
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    const NS: Duration = Duration::from_nanos(1);
213    const MS: Duration = Duration::from_millis(1);
214    const S: Duration = Duration::from_secs(1);
215    const M: Duration = Duration::from_secs(60);
216    const H: Duration = Duration::from_secs(3600);
217
218    #[test]
219    fn supports_go_style_units() {
220        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
221        assert_eq!(parse_duration("1m0s").unwrap(), Duration::from_secs(60));
222        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
223        assert_eq!(
224            parse_duration("1h2m3.5s").unwrap(),
225            Duration::from_secs(3723) + Duration::from_millis(500)
226        );
227        assert_eq!(parse_duration("250us").unwrap(), Duration::from_micros(250));
228        assert_eq!(parse_duration("250µs").unwrap(), Duration::from_micros(250));
229        assert_eq!(parse_duration("250μs").unwrap(), Duration::from_micros(250));
230    }
231
232    #[test]
233    fn supports_signs_zero_and_fractions() {
234        assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
235        assert_eq!(parse_duration("+0").unwrap(), Duration::ZERO);
236        assert_eq!(parse_duration("-0").unwrap(), Duration::ZERO);
237        assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
238        assert_eq!(parse_duration("+5h").unwrap(), 5 * H);
239        assert_eq!(parse_duration(".5s").unwrap(), 500 * MS);
240        assert_eq!(parse_duration("1.5h").unwrap(), 90 * M);
241        assert_eq!(
242            parse_duration("2h45m30.5s").unwrap(),
243            (2 * H) + (45 * M) + (30 * S) + (500 * MS)
244        );
245        assert_eq!(
246            parse_duration("1h1m1s1ms1us1ns").unwrap(),
247            H + M + S + MS + (1000 * NS) + NS
248        );
249    }
250
251    #[test]
252    fn largest_representable_value() {
253        assert_eq!(
254            parse_duration("9223372036854775807ns").unwrap(),
255            Duration::from_nanos(9_223_372_036_854_775_807)
256        );
257    }
258
259    #[test]
260    fn rejects_invalid_and_out_of_range() {
261        // Bare integers (viper's nanosecond fallback) are not part of Go's grammar and are rejected here.
262        assert!(matches!(parse_duration("10"), Err(ParseDurationError::Invalid { .. })));
263        assert!(matches!(parse_duration(""), Err(ParseDurationError::Invalid { .. })));
264        assert!(matches!(parse_duration("abc"), Err(ParseDurationError::Invalid { .. })));
265        assert!(matches!(parse_duration("1d"), Err(ParseDurationError::Invalid { .. })));
266        assert!(matches!(
267            parse_duration("5m32sFOO"),
268            Err(ParseDurationError::Invalid { .. })
269        ));
270        assert!(matches!(parse_duration("-1s"), Err(ParseDurationError::Negative)));
271        assert!(matches!(
272            parse_duration("9223372036854775808ns"),
273            Err(ParseDurationError::Overflow)
274        ));
275    }
276
277    #[test]
278    fn error_display_messages() {
279        assert!(parse_duration("").unwrap_err().to_string().contains("empty duration"));
280        assert!(parse_duration(".s")
281            .unwrap_err()
282            .to_string()
283            .contains("expected digits"));
284        assert!(parse_duration("5ns5").unwrap_err().to_string().contains("missing unit"));
285        assert!(parse_duration("1d")
286            .unwrap_err()
287            .to_string()
288            .contains("unknown unit 'd'"));
289        assert!(parse_duration("-1s").unwrap_err().to_string().contains("negative"));
290        assert!(parse_duration("9223372036854775808ns")
291            .unwrap_err()
292            .to_string()
293            .contains("exceeds"));
294    }
295
296    #[test]
297    fn or_nanos_accepts_go_durations_and_bare_integers() {
298        assert_eq!(parse_duration_or_nanos("10s").unwrap(), 10 * S);
299        assert_eq!(parse_duration_or_nanos("1h30m").unwrap(), H + 30 * M);
300        assert_eq!(parse_duration_or_nanos("30").unwrap(), 30 * NS);
301        assert_eq!(parse_duration_or_nanos("0").unwrap(), Duration::ZERO);
302        // Whitespace around the value is ignored before parsing.
303        assert_eq!(parse_duration_or_nanos("  42  ").unwrap(), 42 * NS);
304        assert_eq!(
305            parse_duration_or_nanos("9223372036854775807").unwrap(),
306            Duration::from_nanos(9_223_372_036_854_775_807)
307        );
308    }
309
310    #[test]
311    fn or_nanos_rejects_negative_overflow_and_gibberish() {
312        assert!(matches!(
313            parse_duration_or_nanos("-5"),
314            Err(ParseDurationError::Negative)
315        ));
316        assert!(matches!(
317            parse_duration_or_nanos("9223372036854775808"),
318            Err(ParseDurationError::Overflow)
319        ));
320        // A value that is neither a Go duration nor a bare integer returns the strict parser's error.
321        assert!(matches!(
322            parse_duration_or_nanos("abc"),
323            Err(ParseDurationError::Invalid { .. })
324        ));
325    }
326}