Skip to main content

stint_core/
duration.rs

1//! Human-friendly duration parsing.
2
3/// Parses a human-friendly duration string into total seconds.
4///
5/// Supported formats: "2h30m", "45m", "1h", "90s", "1h30m15s", "2h 30m".
6/// Units: `h` (hours), `m` (minutes), `s` (seconds).
7/// At least one unit must be present.
8pub fn parse_duration(input: &str) -> Result<i64, String> {
9    let input = input.trim();
10    if input.is_empty() {
11        return Err("duration cannot be empty".to_string());
12    }
13
14    let mut total_secs: i64 = 0;
15    let mut current_num = String::new();
16    let mut found_unit = false;
17
18    for ch in input.chars() {
19        if ch.is_ascii_digit() {
20            current_num.push(ch);
21        } else if ch == ' ' {
22            // Allow spaces between components
23            continue;
24        } else {
25            let unit = ch.to_ascii_lowercase();
26            if current_num.is_empty() {
27                return Err(format!("expected a number before '{unit}' in '{input}'"));
28            }
29            let value: i64 = current_num
30                .parse()
31                .map_err(|_| format!("invalid number in '{input}'"))?;
32
33            let secs = match unit {
34                'h' => value.checked_mul(3600),
35                'm' => value.checked_mul(60),
36                's' => Some(value),
37                _ => {
38                    return Err(format!(
39                        "unknown unit '{unit}' in '{input}' (use h, m, or s)"
40                    ))
41                }
42            };
43            total_secs = secs
44                .and_then(|s| total_secs.checked_add(s))
45                .ok_or_else(|| format!("duration too large: '{input}'"))?;
46
47            current_num.clear();
48            found_unit = true;
49        }
50    }
51
52    // Trailing number without a unit
53    if !current_num.is_empty() {
54        return Err(format!(
55            "missing unit after '{current_num}' in '{input}' (use h, m, or s)"
56        ));
57    }
58
59    if !found_unit {
60        return Err(format!("no valid duration found in '{input}'"));
61    }
62
63    if total_secs == 0 {
64        return Err("duration must be greater than zero".to_string());
65    }
66
67    Ok(total_secs)
68}
69
70/// Formats a duration in seconds as a human-readable string (e.g., "2h 30m").
71///
72/// Shows hours and minutes. Seconds are only shown if the duration is under a minute
73/// or if there is a non-zero seconds component.
74pub fn format_duration_human(secs: i64) -> String {
75    let h = secs / 3600;
76    let m = (secs % 3600) / 60;
77    let s = secs % 60;
78
79    if h > 0 && m > 0 && s > 0 {
80        format!("{h}h {m}m {s}s")
81    } else if h > 0 && m > 0 {
82        format!("{h}h {m}m")
83    } else if h > 0 && s > 0 {
84        format!("{h}h {s}s")
85    } else if h > 0 {
86        format!("{h}h")
87    } else if m > 0 && s > 0 {
88        format!("{m}m {s}s")
89    } else if m > 0 {
90        format!("{m}m")
91    } else {
92        format!("{s}s")
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn parse_hours_only() {
102        assert_eq!(parse_duration("2h").unwrap(), 7200);
103    }
104
105    #[test]
106    fn parse_minutes_only() {
107        assert_eq!(parse_duration("45m").unwrap(), 2700);
108    }
109
110    #[test]
111    fn parse_seconds_only() {
112        assert_eq!(parse_duration("90s").unwrap(), 90);
113    }
114
115    #[test]
116    fn parse_hours_and_minutes() {
117        assert_eq!(parse_duration("2h30m").unwrap(), 9000);
118    }
119
120    #[test]
121    fn parse_hours_minutes_seconds() {
122        assert_eq!(parse_duration("1h30m15s").unwrap(), 5415);
123    }
124
125    #[test]
126    fn parse_with_spaces() {
127        assert_eq!(parse_duration("2h 30m").unwrap(), 9000);
128    }
129
130    #[test]
131    fn parse_uppercase() {
132        assert_eq!(parse_duration("2H30M").unwrap(), 9000);
133    }
134
135    #[test]
136    fn parse_empty_errors() {
137        assert!(parse_duration("").is_err());
138    }
139
140    #[test]
141    fn parse_no_unit_errors() {
142        assert!(parse_duration("30").is_err());
143    }
144
145    #[test]
146    fn parse_zero_errors() {
147        assert!(parse_duration("0h").is_err());
148    }
149
150    #[test]
151    fn parse_unknown_unit_errors() {
152        assert!(parse_duration("5d").is_err());
153    }
154
155    #[test]
156    fn parse_unit_without_number_errors() {
157        assert!(parse_duration("h").is_err());
158    }
159
160    #[test]
161    fn format_hours_and_minutes() {
162        assert_eq!(format_duration_human(5400), "1h 30m");
163    }
164
165    #[test]
166    fn format_minutes_only() {
167        assert_eq!(format_duration_human(300), "5m");
168    }
169
170    #[test]
171    fn format_seconds_only() {
172        assert_eq!(format_duration_human(45), "45s");
173    }
174
175    #[test]
176    fn format_hours_only() {
177        assert_eq!(format_duration_human(7200), "2h");
178    }
179
180    #[test]
181    fn format_zero() {
182        assert_eq!(format_duration_human(0), "0s");
183    }
184
185    #[test]
186    fn format_all_components() {
187        assert_eq!(format_duration_human(3661), "1h 1m 1s");
188    }
189}