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}