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