Skip to main content

systemd_unit_edit/
timespan.rs

1//! Systemd time span parsing
2//!
3//! This module provides functionality to parse systemd time span values
4//! as used in directives like `RuntimeMaxSec=`, `TimeoutStartSec=`, etc.
5
6use std::time::Duration;
7
8/// Parse a systemd time span string into a Duration
9///
10/// Systemd accepts time spans in the following formats:
11/// - Plain numbers are interpreted as seconds (e.g., `30` = 30 seconds)
12/// - Numbers with units: `s` (seconds), `min` (minutes), `h` (hours),
13///   `d` (days), `w` (weeks), `ms` (milliseconds), `us` (microseconds)
14/// - Multiple values can be combined additively: `2min 30s` = 150 seconds
15/// - Whitespace between values is optional: `2min30s` = 150 seconds
16///
17/// # Example
18///
19/// ```
20/// # use systemd_unit_edit::parse_timespan;
21/// # use std::time::Duration;
22/// assert_eq!(parse_timespan("30"), Ok(Duration::from_secs(30)));
23/// assert_eq!(parse_timespan("2min"), Ok(Duration::from_secs(120)));
24/// assert_eq!(parse_timespan("1h 30min"), Ok(Duration::from_secs(5400)));
25/// assert_eq!(parse_timespan("2min 30s"), Ok(Duration::from_millis(150_000)));
26/// ```
27pub fn parse_timespan(s: &str) -> Result<Duration, TimespanParseError> {
28    let s = s.trim();
29    if s.is_empty() {
30        return Err(TimespanParseError::Empty);
31    }
32
33    let mut total_micros: u128 = 0;
34    let mut current_number = String::new();
35    let mut chars = s.chars().peekable();
36
37    while let Some(ch) = chars.next() {
38        if ch.is_ascii_digit() {
39            current_number.push(ch);
40        } else if ch.is_whitespace() {
41            // Whitespace can separate values or be between number and unit
42            if !current_number.is_empty() {
43                // Check if next is a unit or another number
44                if let Some(&next) = chars.peek() {
45                    if next.is_ascii_alphabetic() {
46                        // Continue to unit parsing
47                        continue;
48                    } else if next.is_ascii_digit() {
49                        // Number followed by whitespace and another number means default unit (seconds)
50                        let value: u64 = current_number
51                            .parse()
52                            .map_err(|_| TimespanParseError::InvalidNumber)?;
53                        total_micros += value as u128 * 1_000_000;
54                        current_number.clear();
55                    }
56                } else {
57                    // Number at end with no unit = seconds
58                    let value: u64 = current_number
59                        .parse()
60                        .map_err(|_| TimespanParseError::InvalidNumber)?;
61                    total_micros += value as u128 * 1_000_000;
62                    current_number.clear();
63                }
64            }
65        } else if ch.is_ascii_alphabetic() {
66            // Parse unit
67            let mut unit = String::from(ch);
68            while let Some(&next) = chars.peek() {
69                if next.is_ascii_alphabetic() {
70                    unit.push(chars.next().unwrap());
71                } else {
72                    break;
73                }
74            }
75
76            if current_number.is_empty() {
77                return Err(TimespanParseError::MissingNumber);
78            }
79
80            let value: u64 = current_number
81                .parse()
82                .map_err(|_| TimespanParseError::InvalidNumber)?;
83
84            let micros = match unit.as_str() {
85                "us" | "usec" => value as u128,
86                "ms" | "msec" => value as u128 * 1_000,
87                "s" | "sec" | "second" | "seconds" => value as u128 * 1_000_000,
88                "min" | "minute" | "minutes" => value as u128 * 60 * 1_000_000,
89                "h" | "hr" | "hour" | "hours" => value as u128 * 60 * 60 * 1_000_000,
90                "d" | "day" | "days" => value as u128 * 24 * 60 * 60 * 1_000_000,
91                "w" | "week" | "weeks" => value as u128 * 7 * 24 * 60 * 60 * 1_000_000,
92                _ => return Err(TimespanParseError::InvalidUnit(unit)),
93            };
94
95            total_micros += micros;
96            current_number.clear();
97        } else {
98            return Err(TimespanParseError::InvalidCharacter(ch));
99        }
100    }
101
102    // Handle remaining number (no unit = seconds)
103    if !current_number.is_empty() {
104        let value: u64 = current_number
105            .parse()
106            .map_err(|_| TimespanParseError::InvalidNumber)?;
107        total_micros += value as u128 * 1_000_000;
108    }
109
110    if total_micros == 0 {
111        return Err(TimespanParseError::Empty);
112    }
113
114    // Convert microseconds to Duration
115    let secs = (total_micros / 1_000_000) as u64;
116    let nanos = ((total_micros % 1_000_000) * 1_000) as u32;
117
118    Ok(Duration::new(secs, nanos))
119}
120
121/// Error type for timespan parsing
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum TimespanParseError {
124    /// The input string is empty
125    Empty,
126    /// Invalid number format
127    InvalidNumber,
128    /// Number without a unit
129    MissingNumber,
130    /// Unknown time unit
131    InvalidUnit(String),
132    /// Invalid character in input
133    InvalidCharacter(char),
134}
135
136impl std::fmt::Display for TimespanParseError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            TimespanParseError::Empty => write!(f, "empty timespan"),
140            TimespanParseError::InvalidNumber => write!(f, "invalid number format"),
141            TimespanParseError::MissingNumber => write!(f, "unit specified without a number"),
142            TimespanParseError::InvalidUnit(unit) => write!(f, "invalid time unit: {}", unit),
143            TimespanParseError::InvalidCharacter(ch) => {
144                write!(f, "invalid character: {}", ch)
145            }
146        }
147    }
148}
149
150impl std::error::Error for TimespanParseError {}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_parse_plain_number() {
158        assert_eq!(parse_timespan("30"), Ok(Duration::from_secs(30)));
159        assert_eq!(parse_timespan("0"), Err(TimespanParseError::Empty));
160        assert_eq!(parse_timespan("120"), Ok(Duration::from_secs(120)));
161    }
162
163    #[test]
164    fn test_parse_seconds() {
165        assert_eq!(parse_timespan("30s"), Ok(Duration::from_secs(30)));
166        assert_eq!(parse_timespan("1sec"), Ok(Duration::from_secs(1)));
167        assert_eq!(parse_timespan("5seconds"), Ok(Duration::from_secs(5)));
168    }
169
170    #[test]
171    fn test_parse_minutes() {
172        assert_eq!(parse_timespan("2min"), Ok(Duration::from_secs(120)));
173        assert_eq!(parse_timespan("1minute"), Ok(Duration::from_secs(60)));
174        assert_eq!(parse_timespan("5minutes"), Ok(Duration::from_secs(300)));
175    }
176
177    #[test]
178    fn test_parse_hours() {
179        assert_eq!(parse_timespan("1h"), Ok(Duration::from_secs(3600)));
180        assert_eq!(parse_timespan("2hr"), Ok(Duration::from_secs(7200)));
181        assert_eq!(parse_timespan("1hour"), Ok(Duration::from_secs(3600)));
182        assert_eq!(parse_timespan("3hours"), Ok(Duration::from_secs(10800)));
183    }
184
185    #[test]
186    fn test_parse_days() {
187        assert_eq!(parse_timespan("1d"), Ok(Duration::from_secs(86400)));
188        assert_eq!(parse_timespan("2days"), Ok(Duration::from_secs(172800)));
189    }
190
191    #[test]
192    fn test_parse_weeks() {
193        assert_eq!(parse_timespan("1w"), Ok(Duration::from_secs(604800)));
194        assert_eq!(parse_timespan("2weeks"), Ok(Duration::from_secs(1209600)));
195    }
196
197    #[test]
198    fn test_parse_milliseconds() {
199        assert_eq!(parse_timespan("500ms"), Ok(Duration::from_millis(500)));
200        assert_eq!(parse_timespan("1000msec"), Ok(Duration::from_millis(1000)));
201    }
202
203    #[test]
204    fn test_parse_microseconds() {
205        assert_eq!(parse_timespan("500us"), Ok(Duration::from_micros(500)));
206        assert_eq!(parse_timespan("1000usec"), Ok(Duration::from_micros(1000)));
207    }
208
209    #[test]
210    fn test_parse_combined() {
211        assert_eq!(parse_timespan("2min 30s"), Ok(Duration::from_secs(150)));
212        assert_eq!(parse_timespan("1h 30min"), Ok(Duration::from_secs(5400)));
213        assert_eq!(
214            parse_timespan("1d 2h 3min 4s"),
215            Ok(Duration::from_secs(93784))
216        );
217    }
218
219    #[test]
220    fn test_parse_combined_no_space() {
221        assert_eq!(parse_timespan("2min30s"), Ok(Duration::from_secs(150)));
222        assert_eq!(parse_timespan("1h30min"), Ok(Duration::from_secs(5400)));
223    }
224
225    #[test]
226    fn test_parse_with_extra_whitespace() {
227        assert_eq!(
228            parse_timespan("  2min  30s  "),
229            Ok(Duration::from_secs(150))
230        );
231        assert_eq!(parse_timespan("1h    30min"), Ok(Duration::from_secs(5400)));
232    }
233
234    #[test]
235    fn test_parse_errors() {
236        assert_eq!(parse_timespan(""), Err(TimespanParseError::Empty));
237        assert_eq!(parse_timespan("   "), Err(TimespanParseError::Empty));
238        assert!(parse_timespan("abc").is_err());
239        assert!(parse_timespan("10xyz").is_err());
240    }
241
242    #[test]
243    fn test_parse_subsecond_precision() {
244        let result = parse_timespan("1s 500ms").unwrap();
245        assert_eq!(result, Duration::from_millis(1500));
246
247        let result = parse_timespan("200ms").unwrap();
248        assert_eq!(result.as_millis(), 200);
249    }
250}