Skip to main content

saluki_config/
duration_string.rs

1//! A duration configuration value compatible with the Agent.
2//!
3//! The Agent loads configuration via [spf13/viper][viper], which uses [spf13/cast][cast] to coerce YAML/JSON/env
4//! values into Go's [`time.Duration`][go-duration]. [`DurationString`] reproduces that coercion so ADP accepts the
5//! same inputs and interprets them the same way.
6//!
7//! [viper]: https://github.com/spf13/viper
8//! [cast]: https://github.com/spf13/cast
9//! [go-duration]: https://pkg.go.dev/time#ParseDuration
10
11use std::fmt;
12use std::fmt::{Debug, Display, Formatter};
13use std::str::FromStr;
14use std::time::Duration;
15
16pub use go_duration::{parse_duration, ParseDurationError};
17use serde::de::{self, Deserializer, Visitor};
18use serde::{Deserialize, Serialize, Serializer};
19
20/// A duration value that deserializes from the formats accepted by the Agent's configuration loader.
21///
22/// # Deserialization
23///
24/// Accepted inputs:
25///
26/// - Strings with Go time-unit suffixes: `"30s"`, `"1h30m"`, `"250ms"`, `"2h45m30s"`, `"1.5h"`. Valid suffixes: `ns`,
27///   `us`, `µs`, `μs`, `ms`, `s`, `m`, `h`.
28///
29/// - Strings containing only a bare integer: `"5"` is 5 **nanoseconds**. This matches vipers `cast.ToDurationE`'s
30///   fallback for unit-less string values.
31///
32/// - Integer numbers: `5` is 5 **nanoseconds**.
33///
34/// - Floating-point numbers: `5.0` is 5 **nanoseconds** (truncated toward zero).
35///
36/// Negative durations (for example `"-1h"`) are rejected because [`std::time::Duration`] can't represent them.
37///
38/// # Bare numbers are nanoseconds, not seconds (!!)
39///
40/// A configuration value like `expected_tags_duration: 30` means 30 **nanoseconds**, not 30 seconds. Use `"30s"` for
41/// 30 seconds. This matches the Agent's `time.Duration` coercion.
42///
43/// # Serialization
44///
45/// Serializes as `"{seconds}s{nanoseconds}ns"`. For example, 30 seconds becomes `"30s0ns"` and 30.5 seconds becomes
46/// `"30s500000000ns"`. Whole seconds are maximized and the nanosecond component is always less than `1_000_000_000`,
47/// so the form is unambiguous and round-trips through this parser and would be accepted by the Agent as well.
48#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub struct DurationString(Duration);
50
51impl Debug for DurationString {
52    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
53        Debug::fmt(&self.0, f)
54    }
55}
56
57impl DurationString {
58    /// Creates a new `DurationString` wrapping the given [`Duration`].
59    pub const fn new(d: Duration) -> Self {
60        Self(d)
61    }
62
63    /// Returns the underlying [`Duration`].
64    pub const fn as_duration(&self) -> Duration {
65        self.0
66    }
67}
68
69impl From<Duration> for DurationString {
70    fn from(d: Duration) -> Self {
71        Self(d)
72    }
73}
74
75impl From<DurationString> for Duration {
76    fn from(d: DurationString) -> Self {
77        d.0
78    }
79}
80
81impl std::ops::Deref for DurationString {
82    type Target = Duration;
83
84    fn deref(&self) -> &Duration {
85        &self.0
86    }
87}
88
89impl FromStr for DurationString {
90    type Err = ParseDurationError;
91
92    fn from_str(s: &str) -> Result<Self, Self::Err> {
93        parse_string(s).map(Self)
94    }
95}
96
97impl Display for DurationString {
98    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
99        write!(f, "{}s{}ns", self.0.as_secs(), self.0.subsec_nanos())
100    }
101}
102
103impl Serialize for DurationString {
104    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
105        serializer.collect_str(self)
106    }
107}
108
109impl<'de> Deserialize<'de> for DurationString {
110    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
111        deserializer.deserialize_any(DurationStringVisitor)
112    }
113}
114
115struct DurationStringVisitor;
116
117impl<'de> Visitor<'de> for DurationStringVisitor {
118    type Value = DurationString;
119
120    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str("a duration string (e.g. \"30s\", \"1h30m\") or a non-negative number of nanoseconds")
122    }
123
124    fn visit_i64<E: de::Error>(self, n: i64) -> Result<DurationString, E> {
125        if n < 0 {
126            return Err(E::custom(ParseDurationError::Negative));
127        }
128        Ok(DurationString(Duration::from_nanos(n as u64)))
129    }
130
131    fn visit_i128<E: de::Error>(self, n: i128) -> Result<DurationString, E> {
132        if n < 0 {
133            return Err(E::custom(ParseDurationError::Negative));
134        }
135        if n > MAX_NANOS_U64 as i128 {
136            return Err(E::custom(ParseDurationError::Overflow));
137        }
138        Ok(DurationString(Duration::from_nanos(n as u64)))
139    }
140
141    fn visit_u64<E: de::Error>(self, n: u64) -> Result<DurationString, E> {
142        if n > MAX_NANOS_U64 {
143            return Err(E::custom(ParseDurationError::Overflow));
144        }
145        Ok(DurationString(Duration::from_nanos(n)))
146    }
147
148    fn visit_u128<E: de::Error>(self, n: u128) -> Result<DurationString, E> {
149        if n > MAX_NANOS_U64 as u128 {
150            return Err(E::custom(ParseDurationError::Overflow));
151        }
152        Ok(DurationString(Duration::from_nanos(n as u64)))
153    }
154
155    fn visit_f64<E: de::Error>(self, f: f64) -> Result<DurationString, E> {
156        if !f.is_finite() {
157            return Err(E::custom("duration nanoseconds must be finite"));
158        }
159        if f < 0.0 {
160            return Err(E::custom(ParseDurationError::Negative));
161        }
162        if f > MAX_NANOS_U64 as f64 {
163            return Err(E::custom(ParseDurationError::Overflow));
164        }
165        Ok(DurationString(Duration::from_nanos(f as u64)))
166    }
167
168    fn visit_str<E: de::Error>(self, s: &str) -> Result<DurationString, E> {
169        parse_string(s).map(DurationString).map_err(E::custom)
170    }
171
172    fn visit_string<E: de::Error>(self, s: String) -> Result<DurationString, E> {
173        self.visit_str(&s)
174    }
175}
176
177/// Maximum number of nanoseconds we will accept, matching the Agent's cap (Go's `time.Duration` is `int64`, so
178/// `i64::MAX` nanoseconds is the largest representable value).
179const MAX_NANOS_U64: u64 = i64::MAX as u64;
180
181/// Parses a string using viper/cast precedence: try matching Go's `time.ParseDuration` first (via the
182/// [`go_duration`] crate), then fall back to a bare integer (treated as nanoseconds).
183fn parse_string(s: &str) -> Result<Duration, ParseDurationError> {
184    let trimmed = s.trim();
185    match parse_duration(trimmed) {
186        Ok(d) => Ok(d),
187        Err(err) => match trimmed.parse::<i128>() {
188            Ok(n) if n < 0 => Err(ParseDurationError::Negative),
189            Ok(n) => {
190                if n > MAX_NANOS_U64 as i128 {
191                    return Err(ParseDurationError::Overflow);
192                }
193                Ok(Duration::from_nanos(n as u64))
194            }
195            Err(_) => Err(err),
196        },
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use anyhow::Context as _;
203    use serde_json::json;
204
205    use super::*;
206
207    const NS: Duration = Duration::from_nanos(1);
208    const _US: Duration = Duration::from_micros(1);
209    const MS: Duration = Duration::from_millis(1);
210    const S: Duration = Duration::from_secs(1);
211    const M: Duration = Duration::from_secs(60);
212    const H: Duration = Duration::from_secs(3600);
213
214    #[test]
215    fn deserialize_integer_succeeds() {
216        let json = r#"{ "value": 15 }"#;
217        let deserialized: SerdeTest = serde_json::from_str(json).unwrap();
218        assert_eq!(deserialized.value.as_duration(), 15 * NS);
219    }
220
221    /// Interesting test case because the 1.5 is interpreted as nanoseconds then truncated to 1 ns in the Duration
222    #[test]
223    fn deserialize_float_succeeds() {
224        let json = r#"{ "value": 1.5 }"#;
225        let deserialized: SerdeTest = serde_json::from_str(json).unwrap();
226        assert_eq!(deserialized.value.as_duration(), 1 * NS);
227    }
228
229    #[derive(Default, Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)]
230    struct SerdeTest {
231        value: DurationString,
232    }
233
234    impl From<Duration> for SerdeTest {
235        fn from(value: Duration) -> Self {
236            Self { value: value.into() }
237        }
238    }
239
240    fn test_json(input_value: &str) -> String {
241        json!({"value": input_value}).to_string()
242    }
243
244    fn test_yaml(input_value: &str) -> String {
245        format!("value: {input_value}")
246    }
247
248    fn run_success_case(input: &str, expected: Duration, serialized: &str) -> anyhow::Result<()> {
249        let expected_struct: SerdeTest = expected.into();
250        let json = test_json(input);
251        let yaml = test_yaml(input);
252        let msg = format!("failure for duration test case '{input}'");
253        let parsed_duration = DurationString::from_str(input).context(msg.clone())?;
254        anyhow::ensure!(
255            expected == parsed_duration.as_duration(),
256            "{msg}, expected: {expected:?}, got {:?}",
257            parsed_duration.as_duration()
258        );
259        let deserialized_from_json: SerdeTest = serde_json::from_str(&json).context(msg.clone())?;
260        anyhow::ensure!(
261            expected_struct == deserialized_from_json,
262            "{msg}, expected: {expected_struct:?}, got {deserialized_from_json:?}"
263        );
264        let roundtrip_json = serde_json::from_str(&serde_json::to_string(&expected_struct)?)?;
265        anyhow::ensure!(
266            expected_struct == roundtrip_json,
267            "{msg}, expected json roundrip to produce {expected_struct:?}, but got {roundtrip_json:?}"
268        );
269        let deserialized_from_yaml: SerdeTest = serde_yaml::from_str(&yaml).context(msg.clone())?;
270        anyhow::ensure!(
271            expected_struct == deserialized_from_yaml,
272            "{msg}, expected: {expected_struct:?}, got {deserialized_from_yaml:?}"
273        );
274        let roundtrip_yaml = serde_yaml::from_str(&serde_yaml::to_string(&expected_struct)?)?;
275        anyhow::ensure!(
276            expected_struct == roundtrip_yaml,
277            "{msg}, expected json roundrip to produce {expected_struct:?}, but got {roundtrip_yaml:?}"
278        );
279        let actual_serialized = parsed_duration.to_string();
280        anyhow::ensure!(
281            serialized == actual_serialized,
282            "Expected the input '{input}' to be serialized as '{serialized}' but got '{actual_serialized}'"
283        );
284        Ok(())
285    }
286
287    #[test]
288    fn parse_duration_supports_go_style_units() {
289        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
290        assert_eq!(parse_duration("1m0s").unwrap(), Duration::from_secs(60));
291        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
292        assert_eq!(
293            parse_duration("1h2m3.5s").unwrap(),
294            Duration::from_secs(3723) + Duration::from_millis(500)
295        );
296        assert_eq!(parse_duration("250us").unwrap(), Duration::from_micros(250));
297        assert_eq!(parse_duration("250µs").unwrap(), Duration::from_micros(250));
298        assert_eq!(parse_duration("250μs").unwrap(), Duration::from_micros(250));
299    }
300
301    #[test]
302    fn parse_duration_rejects_config_only_values() {
303        assert!(parse_duration("").is_err());
304        assert!(parse_duration("abc").is_err());
305        assert!(parse_duration("10").is_err());
306        assert!(parse_duration(" 10s").is_err());
307        assert!(parse_duration("1xs").is_err());
308    }
309
310    #[test]
311    fn duration_string_success_cases() {
312        let cases: &[(&str, Duration, &str)] = &[
313            ("0", Duration::ZERO, "0s0ns"),
314            ("-0", Duration::ZERO, "0s0ns"),
315            ("+0", Duration::ZERO, "0s0ns"),
316            ("+5h", Duration::from_hours(5), "18000s0ns"),
317            (".5s", Duration::from_millis(500), "0s500000000ns"),
318            ("5.s", Duration::from_secs(5), "5s0ns"),
319            ("0.000000001s", Duration::from_nanos(1), "0s1ns"),
320            ("1.5h", Duration::from_mins(90), "5400s0ns"),
321            (
322                "2h45m30.5s",
323                (2 * H) + (45 * M) + (30 * S) + (500 * MS),
324                "9930s500000000ns",
325            ),
326            ("12µs", Duration::from_micros(12), "0s12000ns"),
327            ("12μs", Duration::from_micros(12), "0s12000ns"),
328            ("0s", Duration::ZERO, "0s0ns"),
329            ("1h1m1s1ms1us1ns", H + M + S + MS + (1000 * NS) + NS, "3661s1001001ns"),
330            ("24h", Duration::from_hours(24), "86400s0ns"),
331            (
332                "9223372036854775807ns",
333                Duration::from_nanos(9223372036854775807),
334                "9223372036s854775807ns",
335            ),
336            (
337                "9223372036854775.807us",
338                Duration::from_secs(9223372036) + (854775807 * NS),
339                "9223372036s854775807ns",
340            ),
341            (
342                "2562047h47m16.854775807s",
343                Duration::from_secs(9223372036) + (854775807 * NS),
344                "9223372036s854775807ns",
345            ),
346            ("0.1ns", Duration::ZERO, "0s0ns"),
347            ("05s", Duration::from_secs(5), "5s0ns"),
348            ("1ns1s", S + NS, "1s1ns"),
349            ("100h100m100s", (100 * H) + (100 * M) + (100 * S), "366100s0ns"),
350            ("5m32s", (5 * M) + (32 * S), "332s0ns"),
351            ("1m0s", M, "60s0ns"),
352            ("5m0s", 5 * M, "300s0ns"),
353            ("6m0s", 6 * M, "360s0ns"),
354            ("10m0s", 10 * M, "600s0ns"),
355            ("15m0s", 15 * M, "900s0ns"),
356            ("30m0s", 30 * M, "1800s0ns"),
357            ("40m0s", 40 * M, "2400s0ns"),
358            ("50m0s", 50 * M, "3000s0ns"),
359            ("87600h0m0s", 87600 * H, "315360000s0ns"),
360            ("5", 5 * NS, "0s5ns"),
361            (" 5s", 5 * S, "5s0ns"),
362            ("5s", 5 * S, "5s0ns"),
363        ];
364
365        for (input, expected, serialized) in cases {
366            run_success_case(input, *expected, serialized).unwrap();
367        }
368    }
369
370    fn run_failure_case(input: &str, expected_msg: &str) -> anyhow::Result<()> {
371        let result = DurationString::from_str(input);
372        match result {
373            Ok(value) => {
374                anyhow::bail!("Expected an error when parsing '{input}', but instead received the value '{value:?}'")
375            }
376            Err(e) => {
377                anyhow::ensure!(
378                    e.to_string().contains(expected_msg),
379                    "Expected the error message when parsing '{input}' to contain {expected_msg:?}, but the message is {e}"
380                );
381            }
382        }
383
384        Ok(())
385    }
386
387    #[test]
388    fn duration_string_failure_cases() {
389        let cases: &[(&str, &str)] = &[
390            ("5m32sFOO", "unknown unit 'sFOO'"),
391            ("", "empty duration"),
392            (" ", "empty duration"),
393            ("+", "empty duration"),
394            ("-", "empty duration"),
395            (".", "expected digits"),
396            ("s", "expected digits"),
397            (".s", "expected digits"),
398            ("--5s", "expected digits"),
399            ("5.5.5s", "missing unit"),
400            ("1e3s", "unknown unit 'e'"),
401            ("5ns5", "missing unit"),
402            ("9223372036854775808ns", "exceeds"),
403            ("-1s", "negative"),
404            ("-0.5h", "negative"),
405            ("1d", "unknown unit 'd'"),
406            ("1w", "unknown unit 'w'"),
407            ("1S", "unknown unit 'S'"),
408            ("12 µs", "missing unit"),
409            ("5 s", "missing unit"),
410            ("5. s", "missing unit"),
411        ];
412
413        for (input, expected_msg) in cases {
414            run_failure_case(input, expected_msg).unwrap();
415        }
416    }
417}