Skip to main content

fastmcp_core/
duration.rs

1//! Human-readable duration parsing.
2//!
3//! Supports parsing durations in formats like:
4//! - "30s" → 30 seconds
5//! - "5m" → 5 minutes
6//! - "1h" → 1 hour
7//! - "500ms" → 500 milliseconds
8//! - "1h30m" → 1 hour 30 minutes
9
10use std::time::Duration;
11
12/// Error type for duration parsing.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ParseDurationError {
15    /// The invalid input string.
16    pub input: String,
17    /// Description of the error.
18    pub message: String,
19}
20
21impl std::fmt::Display for ParseDurationError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        write!(f, "invalid duration '{}': {}", self.input, self.message)
24    }
25}
26
27impl std::error::Error for ParseDurationError {}
28
29/// Parses a human-readable duration string into a `Duration`.
30///
31/// # Supported Formats
32///
33/// - Milliseconds: "500ms", "100ms"
34/// - Seconds: "30s", "5s"
35/// - Minutes: "5m", "10m"
36/// - Hours: "1h", "2h"
37/// - Combined: "1h30m", "2m30s", "1h30m45s"
38///
39/// # Examples
40///
41/// ```
42/// use fastmcp_core::parse_duration;
43/// use std::time::Duration;
44///
45/// assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
46/// assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
47/// assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
48/// assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
49/// assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
50/// ```
51pub fn parse_duration(s: &str) -> Result<Duration, ParseDurationError> {
52    let s = s.trim();
53    if s.is_empty() {
54        return Err(ParseDurationError {
55            input: s.to_string(),
56            message: "empty string".to_string(),
57        });
58    }
59
60    let mut total_millis: u64 = 0;
61    let mut current_num = String::new();
62    let mut chars = s.chars().peekable();
63
64    while let Some(c) = chars.next() {
65        if c.is_ascii_digit() {
66            current_num.push(c);
67        } else if c.is_ascii_alphabetic() {
68            if current_num.is_empty() {
69                return Err(ParseDurationError {
70                    input: s.to_string(),
71                    message: format!("unexpected unit character '{c}' without preceding number"),
72                });
73            }
74
75            let num: u64 = current_num.parse().map_err(|_| ParseDurationError {
76                input: s.to_string(),
77                message: format!("invalid number: {current_num}"),
78            })?;
79
80            // Check for multi-character units (ms)
81            let unit = if c == 'm' && chars.peek() == Some(&'s') {
82                chars.next(); // consume 's'
83                "ms"
84            } else {
85                // Single character unit
86                match c {
87                    'h' => "h",
88                    'm' => "m",
89                    's' => "s",
90                    _ => {
91                        return Err(ParseDurationError {
92                            input: s.to_string(),
93                            message: format!("unknown unit '{c}'"),
94                        });
95                    }
96                }
97            };
98
99            let millis = match unit {
100                "ms" => num,
101                "s" => num * 1000,
102                "m" => num * 60 * 1000,
103                "h" => num * 60 * 60 * 1000,
104                _ => unreachable!(),
105            };
106
107            total_millis = total_millis.saturating_add(millis);
108            current_num.clear();
109        } else if c.is_whitespace() {
110            // Allow whitespace between components
111            continue;
112        } else {
113            return Err(ParseDurationError {
114                input: s.to_string(),
115                message: format!("unexpected character '{c}'"),
116            });
117        }
118    }
119
120    // Handle trailing number without unit (treat as seconds for compatibility)
121    if !current_num.is_empty() {
122        return Err(ParseDurationError {
123            input: s.to_string(),
124            message: format!("number '{current_num}' missing unit (use s, m, h, or ms)"),
125        });
126    }
127
128    if total_millis == 0 {
129        return Err(ParseDurationError {
130            input: s.to_string(),
131            message: "duration must be greater than zero".to_string(),
132        });
133    }
134
135    Ok(Duration::from_millis(total_millis))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_parse_seconds() {
144        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
145        assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
146        assert_eq!(parse_duration("120s").unwrap(), Duration::from_secs(120));
147    }
148
149    #[test]
150    fn test_parse_minutes() {
151        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
152        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
153        assert_eq!(parse_duration("90m").unwrap(), Duration::from_secs(5400));
154    }
155
156    #[test]
157    fn test_parse_hours() {
158        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
159        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
160        assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86400));
161    }
162
163    #[test]
164    #[allow(clippy::duration_suboptimal_units)]
165    fn test_parse_milliseconds() {
166        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
167        assert_eq!(
168            parse_duration("1000ms").unwrap(),
169            Duration::from_millis(1000)
170        );
171        assert_eq!(parse_duration("1ms").unwrap(), Duration::from_millis(1));
172    }
173
174    #[test]
175    fn test_parse_combined() {
176        assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
177        assert_eq!(parse_duration("2m30s").unwrap(), Duration::from_secs(150));
178        assert_eq!(
179            parse_duration("1h30m45s").unwrap(),
180            Duration::from_secs(5445)
181        );
182        assert_eq!(
183            parse_duration("1m500ms").unwrap(),
184            Duration::from_millis(60500)
185        );
186    }
187
188    #[test]
189    fn test_parse_with_whitespace() {
190        assert_eq!(parse_duration("  30s  ").unwrap(), Duration::from_secs(30));
191        assert_eq!(parse_duration("1h 30m").unwrap(), Duration::from_secs(5400));
192    }
193
194    #[test]
195    fn test_parse_errors() {
196        assert!(parse_duration("").is_err());
197        assert!(parse_duration("abc").is_err());
198        assert!(parse_duration("30").is_err()); // Missing unit
199        assert!(parse_duration("30x").is_err()); // Invalid unit
200        assert!(parse_duration("0s").is_err()); // Zero duration
201    }
202
203    // =========================================================================
204    // Additional coverage tests (bd-1p24)
205    // =========================================================================
206
207    #[test]
208    fn error_display_format() {
209        let err = parse_duration("").unwrap_err();
210        let msg = err.to_string();
211        assert!(msg.contains("invalid duration"));
212        assert!(msg.contains("empty string"));
213    }
214
215    #[test]
216    fn error_is_std_error() {
217        let err = parse_duration("bad").unwrap_err();
218        // Ensure std::error::Error is implemented
219        let _: &dyn std::error::Error = &err;
220    }
221
222    #[test]
223    fn error_debug_clone_eq() {
224        let err = parse_duration("30").unwrap_err();
225        let debug = format!("{err:?}");
226        assert!(debug.contains("ParseDurationError"));
227
228        let cloned = err.clone();
229        assert_eq!(err, cloned);
230    }
231
232    #[test]
233    fn error_unit_without_number() {
234        let err = parse_duration("s").unwrap_err();
235        assert!(err.message.contains("without preceding number"));
236    }
237
238    #[test]
239    fn error_unknown_unit() {
240        let err = parse_duration("30x").unwrap_err();
241        assert!(err.message.contains("unknown unit"));
242    }
243
244    #[test]
245    fn error_unexpected_character() {
246        let err = parse_duration("30s$").unwrap_err();
247        assert!(err.message.contains("unexpected character"));
248    }
249
250    #[test]
251    fn error_missing_unit() {
252        let err = parse_duration("42").unwrap_err();
253        assert!(err.message.contains("missing unit"));
254    }
255
256    #[test]
257    fn error_zero_duration() {
258        let err = parse_duration("0s").unwrap_err();
259        assert!(err.message.contains("greater than zero"));
260    }
261
262    #[test]
263    fn whitespace_between_components() {
264        assert_eq!(
265            parse_duration("2h 30m 15s").unwrap(),
266            Duration::from_secs(2 * 3600 + 30 * 60 + 15)
267        );
268    }
269
270    // =========================================================================
271    // Additional coverage tests (bd-2qlj)
272    // =========================================================================
273
274    #[test]
275    fn saturating_add_overflow() {
276        // u64::MAX millis would overflow without saturating_add
277        let result = parse_duration(&format!("{}ms {}ms", u64::MAX, 1));
278        assert!(result.is_ok());
279        assert_eq!(result.unwrap(), Duration::from_millis(u64::MAX));
280    }
281
282    #[test]
283    fn error_fields_accessible() {
284        let err = parse_duration("42").unwrap_err();
285        assert_eq!(err.input, "42");
286        assert!(err.message.contains("missing unit"));
287    }
288
289    #[test]
290    fn only_whitespace_input() {
291        let err = parse_duration("   ").unwrap_err();
292        assert!(err.message.contains("empty string"));
293    }
294
295    #[test]
296    fn combined_with_ms() {
297        assert_eq!(
298            parse_duration("1h 30m 45s 500ms").unwrap(),
299            Duration::from_millis(3_600_000 + 30 * 60_000 + 45_000 + 500)
300        );
301    }
302}