Skip to main content

adaptive_timeout/
parse.rs

1//! Parser for duration-range strings into [`BackoffInterval`].
2//!
3//! Format: `<min>..<max>`
4//!
5//! Each duration is a number (integer or fractional) followed by a unit suffix.
6//! Spaces between the number and suffix are allowed.
7//!
8//! Unit designators are compatible with [jiff's friendly duration format][jiff],
9//! which is what restate uses for configuration parsing.
10//!
11//! [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
12//!
13//! # Examples
14//!
15//! ```
16//! use adaptive_timeout::BackoffInterval;
17//!
18//! let b: BackoffInterval = "10ms..1s".parse().unwrap();
19//! assert_eq!(b.min_ms.get(), 10);
20//! assert_eq!(b.max_ms.get(), 1_000);
21//!
22//! let b: BackoffInterval = "0.5s..1min".parse().unwrap();
23//! assert_eq!(b.min_ms.get(), 500);
24//! assert_eq!(b.max_ms.get(), 60_000);
25//! ```
26
27use std::fmt;
28use std::str::FromStr;
29
30use crate::config::{MillisNonZero, TimeoutConfig};
31
32/// Timeout floor and ceiling parsed from a duration-range string.
33///
34/// Format: `<min>..<max>` (e.g., `10ms..60s`).
35///
36/// Both bounds use [`MillisNonZero`] — they are always positive.
37///
38/// Unit designators are compatible with [jiff's friendly duration format][jiff],
39/// which is what restate uses for configuration parsing.
40///
41/// [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
42///
43/// # Parsing
44///
45/// ```
46/// use adaptive_timeout::BackoffInterval;
47///
48/// let b: BackoffInterval = "10ms..60s".parse().unwrap();
49/// assert_eq!(b.min_ms.get(), 10);
50/// assert_eq!(b.max_ms.get(), 60_000);
51///
52/// // Jiff-style compact designators work:
53/// let b: BackoffInterval = "250ms..1m".parse().unwrap();
54/// assert_eq!(b.min_ms.get(), 250);
55/// assert_eq!(b.max_ms.get(), 60_000);
56///
57/// // Verbose designators too:
58/// let b: BackoffInterval = "500 milliseconds..2 minutes".parse().unwrap();
59/// assert_eq!(b.min_ms.get(), 500);
60/// assert_eq!(b.max_ms.get(), 120_000);
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct BackoffInterval {
64    /// Floor for computed timeout in milliseconds. Default: 250ms.
65    pub min_ms: MillisNonZero,
66    /// Ceiling for computed timeout in milliseconds. Default: 60,000ms (1min).
67    pub max_ms: MillisNonZero,
68}
69
70impl Default for BackoffInterval {
71    fn default() -> Self {
72        Self {
73            min_ms: MillisNonZero::new(250).unwrap(),
74            max_ms: MillisNonZero::new(60_000).unwrap(),
75        }
76    }
77}
78
79impl fmt::Display for BackoffInterval {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        let min = format_ms(self.min_ms.get() as u64);
82        let max = format_ms(self.max_ms.get() as u64);
83        write!(f, "{min}..{max}")
84    }
85}
86
87/// Format milliseconds into the most natural unit.
88///
89/// Uses jiff's compact designators (`d`, `h`, `m`, `s`, `ms`) so that
90/// output is directly parseable by both this crate and jiff/restate.
91fn format_ms(ms: u64) -> String {
92    if ms == 0 {
93        return "0ms".to_string();
94    }
95    if ms.is_multiple_of(86_400_000) {
96        format!("{}d", ms / 86_400_000)
97    } else if ms.is_multiple_of(3_600_000) {
98        format!("{}h", ms / 3_600_000)
99    } else if ms.is_multiple_of(60_000) {
100        format!("{}m", ms / 60_000)
101    } else if ms.is_multiple_of(1_000) {
102        format!("{}s", ms / 1_000)
103    } else {
104        format!("{ms}ms")
105    }
106}
107
108/// Convert a [`BackoffInterval`] into a [`TimeoutConfig`].
109///
110/// Quantile and safety factor remain at defaults.
111impl From<BackoffInterval> for TimeoutConfig {
112    fn from(backoff: BackoffInterval) -> Self {
113        TimeoutConfig {
114            backoff,
115            ..TimeoutConfig::default()
116        }
117    }
118}
119
120/// Parse a [`TimeoutConfig`] from a duration-range string.
121///
122/// The string is parsed as a [`BackoffInterval`]; `quantile` and
123/// `safety_factor` are left at their defaults.
124///
125/// # Example
126///
127/// ```
128/// use adaptive_timeout::TimeoutConfig;
129///
130/// let cfg: TimeoutConfig = "10ms..60s".parse().unwrap();
131/// assert_eq!(cfg.backoff.min_ms.get(), 10);
132/// assert_eq!(cfg.backoff.max_ms.get(), 60_000);
133/// assert_eq!(cfg.quantile, 0.9999);
134/// assert_eq!(cfg.safety_factor, 2.0);
135/// ```
136impl std::str::FromStr for TimeoutConfig {
137    type Err = ParseError;
138
139    fn from_str(s: &str) -> Result<Self, Self::Err> {
140        Ok(s.parse::<BackoffInterval>()?.into())
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Serde support for TimeoutConfig (feature = "serde")
146// ---------------------------------------------------------------------------
147
148/// Serializes as the backoff interval string, e.g. `"250ms..1m"`.
149///
150/// `quantile` and `safety_factor` are not included; they are restored from
151/// defaults on deserialization.
152#[cfg(feature = "serde")]
153impl serde::Serialize for TimeoutConfig {
154    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
155        self.backoff.serialize(serializer)
156    }
157}
158
159/// Deserializes from a `"<min>..<max>"` string, setting `quantile` and
160/// `safety_factor` to their defaults.
161#[cfg(feature = "serde")]
162impl<'de> serde::Deserialize<'de> for TimeoutConfig {
163    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
164        Ok(BackoffInterval::deserialize(deserializer)?.into())
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Schemars support (feature = "schemars")
170// ---------------------------------------------------------------------------
171
172/// The JSON Schema pattern that matches the `<min>..<max>` duration-range
173/// format accepted by [`BackoffInterval`]'s parser.
174///
175/// Matches strings like `"250ms..1m"`, `"10ms..60s"`, `"0.5s..2 minutes"`.
176#[cfg(feature = "schemars")]
177const BACKOFF_INTERVAL_PATTERN: &str = r"^\d+(\.\d+)?\s*(ns|us|ms|s|m|h|d|nanoseconds?|microseconds?|milliseconds?|seconds?|minutes?|hours?|days?)\s*\.\.\s*\d+(\.\d+)?\s*(ns|us|ms|s|m|h|d|nanoseconds?|microseconds?|milliseconds?|seconds?|minutes?|hours?|days?)$";
178
179#[cfg(feature = "schemars")]
180impl schemars::JsonSchema for BackoffInterval {
181    fn inline_schema() -> bool {
182        true
183    }
184
185    fn schema_name() -> std::borrow::Cow<'static, str> {
186        "BackoffInterval".into()
187    }
188
189    fn schema_id() -> std::borrow::Cow<'static, str> {
190        concat!(module_path!(), "::BackoffInterval").into()
191    }
192
193    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
194        schemars::json_schema!({
195            "type": "string",
196            "pattern": BACKOFF_INTERVAL_PATTERN,
197            "examples": ["250ms..1m", "10ms..60s", "0.5s..5m", "1s..1h"],
198            "description": concat!(
199                "Timeout floor and ceiling expressed as a duration range: \"<min>..<max>\".\n",
200                "\n",
201                "Each bound is a non-negative number followed by a unit. ",
202                "Fractional values are allowed (e.g. \"0.5s\"). ",
203                "Spaces between the number and the unit are permitted (e.g. \"10 ms\").\n",
204                "\n",
205                "Supported units:\n",
206                "  ns / nanoseconds\n",
207                "  us / microseconds\n",
208                "  ms / milliseconds\n",
209                "   s / seconds\n",
210                "   m / minutes\n",
211                "   h / hours\n",
212                "   d / days\n",
213                "\n",
214                "Both compact (\"ms\", \"s\", \"m\") and verbose (\"milliseconds\", \"seconds\", \"minutes\") ",
215                "forms are accepted.\n",
216                "\n",
217                "The minimum must be greater than zero and must not exceed the maximum.\n",
218                "\n",
219                "Examples: \"250ms..1m\", \"10ms..60s\", \"0.5s..5m\", \"1s..1h\"."
220            )
221        })
222    }
223}
224
225/// [`TimeoutConfig`] serializes and deserializes as the same `"<min>..<max>"`
226/// string as [`BackoffInterval`], so its JSON Schema is identical.
227#[cfg(feature = "schemars")]
228impl schemars::JsonSchema for TimeoutConfig {
229    fn inline_schema() -> bool {
230        true
231    }
232
233    fn schema_name() -> std::borrow::Cow<'static, str> {
234        "TimeoutConfig".into()
235    }
236
237    fn schema_id() -> std::borrow::Cow<'static, str> {
238        concat!(module_path!(), "::TimeoutConfig").into()
239    }
240
241    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
242        // Same wire format as BackoffInterval — delegate to keep them in sync.
243        BackoffInterval::json_schema(generator)
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Parsing
249// ---------------------------------------------------------------------------
250
251/// Errors that can occur when parsing a [`BackoffInterval`] string.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum ParseError {
254    /// The input string is empty.
255    Empty,
256    /// Missing the `..` range separator.
257    MissingRangeSeparator,
258    /// Failed to parse the minimum timeout duration.
259    InvalidMin(String),
260    /// Failed to parse the maximum timeout duration.
261    InvalidMax(String),
262    /// The minimum timeout is zero.
263    ZeroMin,
264    /// The max timeout is zero.
265    ZeroMax,
266    /// The minimum timeout exceeds `u32::MAX`.
267    MinOverflow(u64),
268    /// The maximum timeout exceeds `u32::MAX`.
269    MaxOverflow(u64),
270    /// `min > max`.
271    MinExceedsMax { min_ms: u64, max_ms: u64 },
272}
273
274impl fmt::Display for ParseError {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Empty => write!(f, "empty input"),
278            Self::MissingRangeSeparator => write!(f, "missing '..' range separator"),
279            Self::InvalidMin(s) => write!(f, "invalid min timeout: {s}"),
280            Self::InvalidMax(s) => write!(f, "invalid max timeout: {s}"),
281            Self::ZeroMin => write!(f, "min timeout must be > 0"),
282            Self::ZeroMax => write!(f, "max timeout must be > 0"),
283            Self::MinOverflow(v) => write!(f, "min timeout ({v}ms) exceeds u32::MAX"),
284            Self::MaxOverflow(v) => write!(f, "max timeout ({v}ms) exceeds u32::MAX"),
285            Self::MinExceedsMax { min_ms, max_ms } => {
286                write!(
287                    f,
288                    "min timeout ({min_ms}ms) exceeds max timeout ({max_ms}ms)"
289                )
290            }
291        }
292    }
293}
294
295impl std::error::Error for ParseError {}
296
297// ---------------------------------------------------------------------------
298// Serde support (feature = "serde")
299// ---------------------------------------------------------------------------
300
301/// Serializes as the human-readable display string, e.g. `"250ms..1m"`.
302#[cfg(feature = "serde")]
303impl serde::Serialize for BackoffInterval {
304    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
305        serializer.collect_str(self)
306    }
307}
308
309/// Deserializes from the same `"<min>..<max>"` string format accepted by
310/// [`FromStr`].
311#[cfg(feature = "serde")]
312impl<'de> serde::Deserialize<'de> for BackoffInterval {
313    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
314        let s = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
315        s.parse().map_err(serde::de::Error::custom)
316    }
317}
318
319impl FromStr for BackoffInterval {
320    type Err = ParseError;
321
322    fn from_str(s: &str) -> Result<Self, Self::Err> {
323        let s = s.trim();
324        if s.is_empty() {
325            return Err(ParseError::Empty);
326        }
327
328        let (min_str, max_str) = s
329            .split_once("..")
330            .ok_or(ParseError::MissingRangeSeparator)?;
331
332        let min_raw =
333            parse_duration_ms(min_str.trim()).map_err(|e| ParseError::InvalidMin(e.to_string()))?;
334        let max_raw =
335            parse_duration_ms(max_str.trim()).map_err(|e| ParseError::InvalidMax(e.to_string()))?;
336
337        if min_raw == 0 {
338            return Err(ParseError::ZeroMin);
339        }
340        if max_raw == 0 {
341            return Err(ParseError::ZeroMax);
342        }
343        if min_raw > max_raw {
344            return Err(ParseError::MinExceedsMax {
345                min_ms: min_raw,
346                max_ms: max_raw,
347            });
348        }
349
350        let min_u32 = u32::try_from(min_raw).map_err(|_| ParseError::MinOverflow(min_raw))?;
351        let max_u32 = u32::try_from(max_raw).map_err(|_| ParseError::MaxOverflow(max_raw))?;
352
353        // Safety: we already checked > 0 above.
354        let min_ms = MillisNonZero::new(min_u32).unwrap();
355        let max_ms = MillisNonZero::new(max_u32).unwrap();
356
357        Ok(BackoffInterval { min_ms, max_ms })
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Duration string parser
363// ---------------------------------------------------------------------------
364
365/// Errors from parsing a single duration string.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub(crate) enum DurationParseError {
368    Empty,
369    InvalidNumber(String),
370    UnknownUnit(String),
371    /// The parsed value truncates to zero milliseconds (e.g., `1ns`).
372    TruncatesToZero(String),
373}
374
375impl fmt::Display for DurationParseError {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        match self {
378            Self::Empty => write!(f, "empty duration string"),
379            Self::InvalidNumber(s) => write!(f, "invalid number: {s}"),
380            Self::UnknownUnit(s) => write!(f, "unknown unit: {s}"),
381            Self::TruncatesToZero(s) => {
382                write!(f, "duration '{s}' truncates to 0ms")
383            }
384        }
385    }
386}
387
388/// Parse a duration string like `10ms`, `1.5s`, `2min` into milliseconds.
389///
390/// Unit designators are compatible with [jiff's friendly duration format][jiff].
391/// All jiff unit labels (compact, short, and verbose) are accepted:
392///
393/// | Unit         | Accepted designators                                                           |
394/// |--------------|-------------------------------------------------------------------------------|
395/// | nanoseconds  | `ns`, `nsec`, `nsecs`, `nano`, `nanos`, `nanosecond`, `nanoseconds`            |
396/// | microseconds | `us`, `µs`/`μs`, `usec`, `usecs`, `micro`, `micros`, `microsecond`, `microseconds` |
397/// | milliseconds | `ms`, `msec`, `msecs`, `milli`, `millis`, `millisecond`, `milliseconds`        |
398/// | seconds      | `s`, `sec`, `secs`, `second`, `seconds`                                       |
399/// | minutes      | `m`, `min`, `mins`, `minute`, `minutes`                                       |
400/// | hours        | `h`, `hr`, `hrs`, `hour`, `hours`                                             |
401/// | days         | `d`, `day`, `days`                                                            |
402///
403/// [jiff]: https://docs.rs/jiff/latest/jiff/fmt/friendly/index.html
404///
405/// Spaces between the number and unit are allowed: `10 ms` is valid.
406/// Fractional values are supported: `0.5s` = 500ms.
407fn parse_duration_ms(s: &str) -> Result<u64, DurationParseError> {
408    let s = s.trim();
409    if s.is_empty() {
410        return Err(DurationParseError::Empty);
411    }
412
413    // Special case: bare "0"
414    if s == "0" {
415        return Ok(0);
416    }
417
418    // Find the boundary between number and unit.
419    let num_end = s
420        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
421        .unwrap_or(s.len());
422
423    let num_str = s[..num_end].trim();
424    let unit_str = s[num_end..].trim();
425
426    if num_str.is_empty() {
427        return Err(DurationParseError::InvalidNumber(s.to_string()));
428    }
429    if unit_str.is_empty() {
430        return Err(DurationParseError::UnknownUnit(
431            "missing unit suffix".to_string(),
432        ));
433    }
434
435    let value: f64 = num_str
436        .parse()
437        .map_err(|_| DurationParseError::InvalidNumber(num_str.to_string()))?;
438
439    // Unit designators match jiff's friendly format grammar exactly.
440    // U+00B5 (µ MICRO SIGN) and U+03BC (μ GREEK SMALL LETTER MU) both accepted.
441    let factor_ns: f64 = match unit_str {
442        "nanoseconds" | "nanosecond" | "nanos" | "nano" | "nsecs" | "nsec" | "ns" => 1.0,
443        "microseconds" | "microsecond" | "micros" | "micro" | "usecs" | "usec" | "us"
444        | "\u{00B5}s" | "\u{00B5}secs" | "\u{00B5}sec" | "\u{03BC}s" | "\u{03BC}secs"
445        | "\u{03BC}sec" => 1_000.0,
446        "milliseconds" | "millisecond" | "millis" | "milli" | "msecs" | "msec" | "ms" => {
447            1_000_000.0
448        }
449        "seconds" | "second" | "secs" | "sec" | "s" => 1_000_000_000.0,
450        "minutes" | "minute" | "mins" | "min" | "m" => 60.0 * 1_000_000_000.0,
451        "hours" | "hour" | "hrs" | "hr" | "h" => 3_600.0 * 1_000_000_000.0,
452        "days" | "day" | "d" => 86_400.0 * 1_000_000_000.0,
453        _ => return Err(DurationParseError::UnknownUnit(unit_str.to_string())),
454    };
455
456    let total_ns = value * factor_ns;
457    let ms = (total_ns / 1_000_000.0).round() as u64;
458
459    // Reject durations that round to 0ms but had a non-zero input.
460    if ms == 0 && value != 0.0 {
461        return Err(DurationParseError::TruncatesToZero(s.to_string()));
462    }
463
464    Ok(ms)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    // -----------------------------------------------------------------------
472    // Duration parsing
473    // -----------------------------------------------------------------------
474
475    #[test]
476    fn parse_milliseconds() {
477        assert_eq!(parse_duration_ms("10ms").unwrap(), 10);
478        assert_eq!(parse_duration_ms("100ms").unwrap(), 100);
479        assert_eq!(parse_duration_ms("1ms").unwrap(), 1);
480        assert_eq!(parse_duration_ms("0ms").unwrap(), 0);
481    }
482
483    #[test]
484    fn parse_seconds() {
485        assert_eq!(parse_duration_ms("1s").unwrap(), 1_000);
486        assert_eq!(parse_duration_ms("10s").unwrap(), 10_000);
487        assert_eq!(parse_duration_ms("1sec").unwrap(), 1_000);
488        assert_eq!(parse_duration_ms("2secs").unwrap(), 2_000);
489        assert_eq!(parse_duration_ms("1second").unwrap(), 1_000);
490        assert_eq!(parse_duration_ms("3seconds").unwrap(), 3_000);
491    }
492
493    #[test]
494    fn parse_fractional() {
495        assert_eq!(parse_duration_ms("0.5s").unwrap(), 500);
496        assert_eq!(parse_duration_ms("1.5s").unwrap(), 1_500);
497        assert_eq!(parse_duration_ms("0.1s").unwrap(), 100);
498        assert_eq!(parse_duration_ms("2.5ms").unwrap(), 3); // rounds
499    }
500
501    #[test]
502    fn parse_minutes() {
503        assert_eq!(parse_duration_ms("1min").unwrap(), 60_000);
504        assert_eq!(parse_duration_ms("2mins").unwrap(), 120_000);
505        assert_eq!(parse_duration_ms("1m").unwrap(), 60_000);
506        assert_eq!(parse_duration_ms("1minute").unwrap(), 60_000);
507        assert_eq!(parse_duration_ms("3minutes").unwrap(), 180_000);
508    }
509
510    #[test]
511    fn parse_hours() {
512        assert_eq!(parse_duration_ms("1h").unwrap(), 3_600_000);
513        assert_eq!(parse_duration_ms("1hr").unwrap(), 3_600_000);
514        assert_eq!(parse_duration_ms("2hrs").unwrap(), 7_200_000);
515        assert_eq!(parse_duration_ms("1hour").unwrap(), 3_600_000);
516        assert_eq!(parse_duration_ms("3hours").unwrap(), 10_800_000);
517    }
518
519    #[test]
520    fn parse_days() {
521        assert_eq!(parse_duration_ms("1d").unwrap(), 86_400_000);
522        assert_eq!(parse_duration_ms("1day").unwrap(), 86_400_000);
523        assert_eq!(parse_duration_ms("2days").unwrap(), 172_800_000);
524    }
525
526    #[test]
527    fn parse_microseconds() {
528        assert_eq!(parse_duration_ms("1000us").unwrap(), 1);
529        assert_eq!(parse_duration_ms("500us").unwrap(), 1); // rounds to 1ms
530        assert_eq!(parse_duration_ms("1000\u{03BC}s").unwrap(), 1); // Greek mu
531        assert_eq!(parse_duration_ms("1000\u{00B5}s").unwrap(), 1); // Micro sign (jiff)
532        assert_eq!(parse_duration_ms("1000usec").unwrap(), 1);
533        assert_eq!(parse_duration_ms("1000usecs").unwrap(), 1);
534        assert_eq!(parse_duration_ms("1000micro").unwrap(), 1);
535        assert_eq!(parse_duration_ms("1000micros").unwrap(), 1);
536        assert_eq!(parse_duration_ms("1000microsecond").unwrap(), 1);
537        assert_eq!(parse_duration_ms("1000microseconds").unwrap(), 1);
538    }
539
540    #[test]
541    fn parse_nanoseconds() {
542        assert_eq!(parse_duration_ms("1000000ns").unwrap(), 1);
543        assert_eq!(parse_duration_ms("1000000nsec").unwrap(), 1);
544        assert_eq!(parse_duration_ms("1000000nsecs").unwrap(), 1);
545        assert_eq!(parse_duration_ms("1000000nano").unwrap(), 1);
546        assert_eq!(parse_duration_ms("1000000nanos").unwrap(), 1);
547        assert_eq!(parse_duration_ms("1000000nanosecond").unwrap(), 1);
548        assert_eq!(parse_duration_ms("1000000nanoseconds").unwrap(), 1);
549    }
550
551    #[test]
552    fn parse_millisecond_aliases() {
553        assert_eq!(parse_duration_ms("10msec").unwrap(), 10);
554        assert_eq!(parse_duration_ms("10msecs").unwrap(), 10);
555        assert_eq!(parse_duration_ms("10milli").unwrap(), 10);
556        assert_eq!(parse_duration_ms("10millis").unwrap(), 10);
557        assert_eq!(parse_duration_ms("10millisecond").unwrap(), 10);
558        assert_eq!(parse_duration_ms("10milliseconds").unwrap(), 10);
559    }
560
561    #[test]
562    fn parse_with_spaces() {
563        assert_eq!(parse_duration_ms("10 ms").unwrap(), 10);
564        assert_eq!(parse_duration_ms(" 1 s ").unwrap(), 1_000);
565    }
566
567    #[test]
568    fn parse_bare_zero() {
569        assert_eq!(parse_duration_ms("0").unwrap(), 0);
570    }
571
572    #[test]
573    fn parse_truncates_to_zero() {
574        assert!(matches!(
575            parse_duration_ms("1ns"),
576            Err(DurationParseError::TruncatesToZero(_))
577        ));
578    }
579
580    #[test]
581    fn parse_unknown_unit() {
582        assert!(matches!(
583            parse_duration_ms("10xyz"),
584            Err(DurationParseError::UnknownUnit(_))
585        ));
586    }
587
588    #[test]
589    fn parse_missing_unit() {
590        assert!(matches!(
591            parse_duration_ms("42"),
592            Err(DurationParseError::UnknownUnit(_))
593        ));
594    }
595
596    #[test]
597    fn parse_empty() {
598        assert!(matches!(
599            parse_duration_ms(""),
600            Err(DurationParseError::Empty)
601        ));
602    }
603
604    // -----------------------------------------------------------------------
605    // BackoffInterval parsing
606    // -----------------------------------------------------------------------
607
608    #[test]
609    fn parse_basic_range() {
610        let b: BackoffInterval = "10ms..1s".parse().unwrap();
611        assert_eq!(b.min_ms.get(), 10);
612        assert_eq!(b.max_ms.get(), 1_000);
613    }
614
615    #[test]
616    fn parse_fractional_range() {
617        let b: BackoffInterval = "0.5s..1.5s".parse().unwrap();
618        assert_eq!(b.min_ms.get(), 500);
619        assert_eq!(b.max_ms.get(), 1_500);
620    }
621
622    #[test]
623    fn parse_with_spaces_around() {
624        let b: BackoffInterval = "  10ms .. 1s  ".parse().unwrap();
625        assert_eq!(b.min_ms.get(), 10);
626        assert_eq!(b.max_ms.get(), 1_000);
627    }
628
629    #[test]
630    fn parse_same_min_max() {
631        let b: BackoffInterval = "100ms..100ms".parse().unwrap();
632        assert_eq!(b.min_ms.get(), 100);
633        assert_eq!(b.max_ms.get(), 100);
634    }
635
636    #[test]
637    fn parse_large_values() {
638        let b: BackoffInterval = "1h..3d".parse().unwrap();
639        assert_eq!(b.min_ms.get(), 3_600_000);
640        assert_eq!(b.max_ms.get(), 259_200_000);
641    }
642
643    #[test]
644    fn parse_mixed_units() {
645        let b: BackoffInterval = "100ms..1min".parse().unwrap();
646        assert_eq!(b.min_ms.get(), 100);
647        assert_eq!(b.max_ms.get(), 60_000);
648    }
649
650    #[test]
651    fn err_empty() {
652        assert_eq!(
653            "".parse::<BackoffInterval>().unwrap_err(),
654            ParseError::Empty
655        );
656    }
657
658    #[test]
659    fn err_missing_separator() {
660        assert_eq!(
661            "10ms".parse::<BackoffInterval>().unwrap_err(),
662            ParseError::MissingRangeSeparator
663        );
664    }
665
666    #[test]
667    fn err_zero_min() {
668        assert_eq!(
669            "0ms..1s".parse::<BackoffInterval>().unwrap_err(),
670            ParseError::ZeroMin
671        );
672    }
673
674    #[test]
675    fn err_min_exceeds_max() {
676        assert!(matches!(
677            "10s..1s".parse::<BackoffInterval>(),
678            Err(ParseError::MinExceedsMax { .. })
679        ));
680    }
681
682    #[test]
683    fn err_invalid_min() {
684        assert!(matches!(
685            "abc..1s".parse::<BackoffInterval>(),
686            Err(ParseError::InvalidMin(_))
687        ));
688    }
689
690    #[test]
691    fn err_invalid_max() {
692        assert!(matches!(
693            "10ms..abc".parse::<BackoffInterval>(),
694            Err(ParseError::InvalidMax(_))
695        ));
696    }
697
698    // -----------------------------------------------------------------------
699    // Display round-trip
700    // -----------------------------------------------------------------------
701
702    #[test]
703    fn display_basic() {
704        let b: BackoffInterval = "10ms..1min".parse().unwrap();
705        assert_eq!(b.to_string(), "10ms..1m");
706    }
707
708    #[test]
709    fn display_sub_second() {
710        let b: BackoffInterval = "500ms..1500ms".parse().unwrap();
711        assert_eq!(b.to_string(), "500ms..1500ms");
712    }
713
714    #[test]
715    fn display_round_trip() {
716        for original in &["10ms..60s", "250ms..1m", "1s..1h", "100ms..1d"] {
717            let b: BackoffInterval = original.parse().unwrap();
718            let displayed = b.to_string();
719            let reparsed: BackoffInterval = displayed.parse().unwrap();
720            assert_eq!(b, reparsed, "round-trip failed for {original}");
721        }
722    }
723
724    // -----------------------------------------------------------------------
725    // From<BackoffInterval> for TimeoutConfig
726    // -----------------------------------------------------------------------
727
728    #[test]
729    fn into_timeout_config() {
730        let b: BackoffInterval = "50ms..30s".parse().unwrap();
731        let cfg: TimeoutConfig = b.into();
732        assert_eq!(cfg.backoff.min_ms.get(), 50);
733        assert_eq!(cfg.backoff.max_ms.get(), 30_000);
734        // Defaults preserved:
735        assert_eq!(cfg.quantile, 0.9999);
736        assert_eq!(cfg.safety_factor, 2.0);
737    }
738
739    // -----------------------------------------------------------------------
740    // Serde round-trips (feature = "serde")
741    // -----------------------------------------------------------------------
742
743    #[cfg(feature = "serde")]
744    mod serde_tests {
745        use super::*;
746
747        #[test]
748        fn serialize_to_string() {
749            let b: BackoffInterval = "250ms..1m".parse().unwrap();
750            let json = serde_json::to_string(&b).unwrap();
751            assert_eq!(json, r#""250ms..1m""#);
752        }
753
754        #[test]
755        fn deserialize_from_string() {
756            let b: BackoffInterval = serde_json::from_str(r#""10ms..60s""#).unwrap();
757            assert_eq!(b.min_ms.get(), 10);
758            assert_eq!(b.max_ms.get(), 60_000);
759        }
760
761        #[test]
762        fn serde_round_trip() {
763            for s in &["10ms..60s", "250ms..1m", "1s..1h", "100ms..1d"] {
764                let original: BackoffInterval = s.parse().unwrap();
765                let json = serde_json::to_string(&original).unwrap();
766                let restored: BackoffInterval = serde_json::from_str(&json).unwrap();
767                assert_eq!(original, restored, "round-trip failed for {s}");
768            }
769        }
770
771        #[test]
772        fn deserialize_error_propagated() {
773            let err = serde_json::from_str::<BackoffInterval>(r#""not-a-range""#);
774            assert!(err.is_err());
775        }
776    }
777
778    // -----------------------------------------------------------------------
779    // Schemars (feature = "schemars")
780    // -----------------------------------------------------------------------
781
782    #[cfg(feature = "schemars")]
783    mod schemars_tests {
784        use schemars::JsonSchema;
785
786        use super::*;
787
788        fn backoff_schema() -> schemars::Schema {
789            let mut generator = schemars::SchemaGenerator::default();
790            BackoffInterval::json_schema(&mut generator)
791        }
792
793        fn timeout_schema() -> schemars::Schema {
794            let mut generator = schemars::SchemaGenerator::default();
795            TimeoutConfig::json_schema(&mut generator)
796        }
797
798        #[test]
799        fn backoff_interval_is_string_type() {
800            let schema = backoff_schema();
801            let obj = schema.as_object().unwrap();
802            assert_eq!(obj["type"], "string");
803        }
804
805        #[test]
806        fn backoff_interval_has_pattern() {
807            let schema = backoff_schema();
808            let obj = schema.as_object().unwrap();
809            assert!(obj.contains_key("pattern"), "schema should have a pattern");
810            // Spot-check: the pattern must mention ".." as a separator.
811            assert!(
812                obj["pattern"].as_str().unwrap().contains(r"\.\."),
813                "pattern should include the '..' separator"
814            );
815        }
816
817        #[test]
818        fn backoff_interval_has_examples() {
819            let schema = backoff_schema();
820            let obj = schema.as_object().unwrap();
821            assert!(obj.contains_key("examples"), "schema should have examples");
822        }
823
824        #[test]
825        fn timeout_config_schema_matches_backoff_interval() {
826            // TimeoutConfig delegates to BackoffInterval — schemas must be equal.
827            assert_eq!(
828                backoff_schema().as_object().unwrap()["type"],
829                timeout_schema().as_object().unwrap()["type"],
830            );
831            assert_eq!(
832                backoff_schema().as_object().unwrap()["pattern"],
833                timeout_schema().as_object().unwrap()["pattern"],
834            );
835        }
836
837        #[test]
838        fn backoff_interval_schema_name() {
839            assert_eq!(BackoffInterval::schema_name(), "BackoffInterval");
840        }
841
842        #[test]
843        fn timeout_config_schema_name() {
844            assert_eq!(TimeoutConfig::schema_name(), "TimeoutConfig");
845        }
846
847        #[test]
848        fn both_are_inline() {
849            assert!(BackoffInterval::inline_schema());
850            assert!(TimeoutConfig::inline_schema());
851        }
852    }
853}