durstr/
lib.rs

1/*!
2A simple library for parsing human-readable duration strings into `std::time::Duration`.
3
4## Usage
5
6This library provides a [`parse`] function for quick and easy parsing, and a [`Parser`]
7struct for more control over parsing behavior.
8
9### The `parse` function
10
11The [`parse`] function is a convenience wrapper around a default [`Parser`].
12
13```rust
14use durstr::parse;
15use std::time::Duration;
16
17let dur = parse("12 minutes, 21 seconds");
18assert_eq!(dur, Ok(Duration::from_secs(741)));
19
20let dur = parse("1hr 2min 3sec");
21assert_eq!(dur, Ok(Duration::from_secs(3723)));
22```
23
24### The `Parser` struct
25
26For more control, you can use the [`Parser`] struct directly. For example, to parse with case-insensitivity:
27
28```rust
29use durstr::{Parser, ParserOptions};
30use std::time::Duration;
31
32let parser = Parser::new(ParserOptions { ignore_case: true });
33let dur = parser.parse("1 MINUTE, 2 SECONDS");
34assert_eq!(dur, Ok(Duration::from_secs(62)));
35```
36
37## Supported Units
38
39| Unit        | Aliases                            |
40|-------------|------------------------------------|
41| Millisecond | `ms`, `msec(s)`, `millisecond(s)`  |
42| Second      | `s`, `sec(s)`, `second(s)`         |
43| Minute      | `m`, `min(s)`, `minute(s)`         |
44| Hour        | `h`, `hr(s)`, `hour(s)`            |
45*/
46
47use std::{borrow::Cow, iter::Peekable, str::CharIndices, time::Duration};
48
49/// An error that can occur when parsing a duration string.
50#[derive(thiserror::Error, Debug, PartialEq)]
51pub enum Error {
52    /// An unexpected character was found.
53    #[error("unexpected character: {0}")]
54    UnexpectedChar(char),
55    /// An unexpected unit was found.
56    #[error("unexpected unit: {0}")]
57    UnexpectedUnit(String),
58    /// A unit was expected, but not found.
59    #[error("expected a unit")]
60    ExpectedUnit,
61    /// A number was expected, but not found.
62    #[error("expected a number")]
63    ExpectedNumber,
64}
65
66#[derive(Debug, PartialEq, Eq)]
67enum Token<'a> {
68    Number(u32),
69    Unit(&'a str),
70}
71
72struct Scanner<'a> {
73    source: &'a str,
74    chars: Peekable<CharIndices<'a>>,
75}
76
77impl<'a> Scanner<'a> {
78    fn new(source: &'a str) -> Self {
79        Scanner {
80            source,
81            chars: source.char_indices().peekable(),
82        }
83    }
84
85    fn scan_tokens(mut self) -> Result<Vec<Token<'a>>, Error> {
86        let mut tokens = vec![];
87
88        while let Some(&(i, c)) = self.chars.peek() {
89            match c {
90                c if self.should_skip(c) => {
91                    self.chars.next();
92                }
93                c if c.is_ascii_digit() => {
94                    tokens.push(Token::Number(self.scan_number(i)));
95                }
96                c if c.is_ascii_alphabetic() => {
97                    tokens.push(Token::Unit(self.scan_unit(i)));
98                }
99                unexpected => return Err(Error::UnexpectedChar(unexpected)),
100            };
101        }
102
103        Ok(tokens)
104    }
105
106    fn should_skip(&self, c: char) -> bool {
107        c.is_ascii_whitespace() || c == ','
108    }
109
110    fn scan_number(&mut self, start: usize) -> u32 {
111        let mut end = start;
112        while let Some((_, c)) = self.chars.peek() {
113            if !c.is_ascii_digit() {
114                break;
115            }
116            end = self.chars.next().unwrap().0;
117        }
118
119        self.source[start..=end].parse().unwrap()
120    }
121
122    fn scan_unit(&mut self, start: usize) -> &'a str {
123        let mut end = start;
124        while let Some((_, c)) = self.chars.peek() {
125            if !c.is_ascii_alphabetic() {
126                break;
127            }
128            end = self.chars.next().unwrap().0;
129        }
130
131        &self.source[start..=end]
132    }
133}
134
135/// Options to customize the behavior of a [`Parser`].
136///
137/// This struct allows for more control over how duration strings are
138/// interpreted. (e.g. enabling case-insensitivity)
139#[derive(Default)]
140pub struct ParserOptions {
141    pub ignore_case: bool,
142}
143
144/// A configurable parser for duration strings.
145///
146/// Use this when you need to configure the parsing logic. Otherwise, the
147/// top-level [`parse`] function is likely sufficient.
148#[derive(Default)]
149pub struct Parser {
150    options: ParserOptions,
151}
152
153impl Parser {
154    /// Create a new [`Parser`] with provided [`ParserOptions`]
155    pub fn new(options: ParserOptions) -> Self {
156        Parser { options }
157    }
158
159    /// Parses a string into a `Duration`, ignoring whitespaces and commas.
160    ///
161    /// ## Supported Units
162    /// - `ms`, `msec(s)`, `millisecond(s)`
163    /// - `s`, `sec(s)`, `second(s)`
164    /// - `m`, `min(s)`, `minute(s)`
165    /// - `h`, `hr(s)`, `hour(s)`
166    ///
167    /// ## Examples
168    /// ```
169    /// use durstr::{Parser, ParserOptions};
170    /// use std::time::Duration;
171    ///
172    /// let parser = Parser::new(ParserOptions { ignore_case: true });
173    /// let dur = parser.parse("1 MINUTE, 2 SECONDS");
174    /// assert_eq!(dur, Ok(Duration::from_secs(62)));
175    /// ```
176    pub fn parse(&self, input: &str) -> Result<Duration, Error> {
177        let tokens = Scanner::new(input).scan_tokens()?;
178        self.parse_tokens(tokens)
179    }
180
181    fn parse_tokens(&self, tokens: Vec<Token>) -> Result<Duration, Error> {
182        let mut tokens = tokens.into_iter();
183        let mut dur = Duration::ZERO;
184
185        while let Some(token) = tokens.next() {
186            let num = match token {
187                Token::Number(n) => n,
188                Token::Unit(_) => return Err(Error::ExpectedNumber),
189            };
190
191            let unit = match tokens.next() {
192                Some(Token::Unit(u)) => u,
193                _ => return Err(Error::ExpectedUnit),
194            };
195
196            dur += num * self.get_unit_duration(unit)?;
197        }
198
199        Ok(dur)
200    }
201
202    fn get_unit_duration(&self, unit: &str) -> Result<Duration, Error> {
203        let unit = if self.options.ignore_case {
204            Cow::Owned(unit.to_lowercase())
205        } else {
206            Cow::Borrowed(unit)
207        };
208
209        match unit.as_ref() {
210            "h" | "hr" | "hrs" | "hour" | "hours" => Ok(Duration::from_secs(3600)),
211            "m" | "min" | "mins" | "minute" | "minutes" => Ok(Duration::from_secs(60)),
212            "s" | "sec" | "secs" | "second" | "seconds" => Ok(Duration::from_secs(1)),
213            "ms" | "msec" | "msecs" | "millisecond" | "milliseconds" => {
214                Ok(Duration::from_millis(1))
215            }
216            _ => Err(Error::UnexpectedUnit(unit.into_owned())),
217        }
218    }
219}
220
221/// Parses a duration string into a `std::time::Duration`.
222///
223/// This function provides a quick and easy way to parse common duration
224/// formats. It is a convenience wrapper around a default [`Parser`], which is
225/// case-sensitive and ignores whitespace and commas.
226///
227/// For more control over parsing behavior, such as enabling case-insensitivity,
228/// construct a [`Parser`] with custom [`ParserOptions`].
229///
230/// ## Examples
231/// ```
232/// use durstr::parse;
233/// use std::time::Duration;
234///
235/// let dur = parse("12 minutes, 21 seconds");
236/// assert_eq!(dur, Ok(Duration::from_secs(741)));
237///
238/// let dur = parse("1hr 2min 3sec");
239/// assert_eq!(dur, Ok(Duration::from_secs(3723)));
240///
241/// // By default, parsing is case-sensitive.
242/// let dur = parse("1 MINUTE");
243/// assert!(dur.is_err());
244/// ```
245pub fn parse(input: &str) -> Result<Duration, Error> {
246    Parser::default().parse(input)
247}
248
249#[cfg(test)]
250mod tests {
251    use crate::{Scanner, Token};
252
253    #[test]
254    fn test_scanner() {
255        let scanner = Scanner::new("10 seconds");
256        let tokens = scanner.scan_tokens();
257        assert_eq!(tokens, Ok(vec![Token::Number(10), Token::Unit("seconds")]));
258
259        let scanner = Scanner::new("9hr1min");
260        let tokens = scanner.scan_tokens();
261        assert_eq!(
262            tokens,
263            Ok(vec![
264                Token::Number(9),
265                Token::Unit("hr"),
266                Token::Number(1),
267                Token::Unit("min"),
268            ])
269        );
270
271        let scanner = Scanner::new("712635 days");
272        let tokens = scanner.scan_tokens();
273        assert_eq!(tokens, Ok(vec![Token::Number(712635), Token::Unit("days")]));
274    }
275}