1pub 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 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 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
70pub 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}