Skip to main content

ito_core/ralph/
duration.rs

1use crate::errors::CoreResult;
2use std::time::Duration;
3
4/// Parse a human-readable duration string into a Duration.
5///
6/// Supported formats:
7/// - `30s` - 30 seconds
8/// - `5m` - 5 minutes
9/// - `1m30s` - 1 minute 30 seconds
10/// - `2h` - 2 hours
11/// - `1h30m` - 1 hour 30 minutes
12/// - `1h30m45s` - 1 hour 30 minutes 45 seconds
13/// - `90` - 90 seconds (bare number defaults to seconds)
14///
15/// # Examples
16/// ```
17/// use ito_core::ralph::duration::parse_duration;
18/// use std::time::Duration;
19///
20/// assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
21/// assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
22/// assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90));
23/// assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
24/// ```
25pub fn parse_duration(s: &str) -> CoreResult<Duration> {
26    let s = s.trim();
27    if s.is_empty() {
28        return Err(crate::errors::CoreError::Parse(
29            "Duration string cannot be empty".into(),
30        ));
31    }
32
33    // Try parsing as bare number (seconds)
34    if let Ok(secs) = s.parse::<u64>() {
35        return Ok(Duration::from_secs(secs));
36    }
37
38    let mut total_secs: u64 = 0;
39    let mut current_num = String::new();
40
41    for c in s.chars() {
42        if c.is_ascii_digit() {
43            current_num.push(c);
44        } else {
45            let unit = c.to_ascii_lowercase();
46            if current_num.is_empty() {
47                return Err(crate::errors::CoreError::Parse(format!(
48                    "Invalid duration format: missing number before '{unit}'"
49                )));
50            }
51            let num: u64 = current_num.parse().map_err(|_| {
52                crate::errors::CoreError::Parse(format!(
53                    "Invalid number in duration: {current_num}"
54                ))
55            })?;
56            current_num.clear();
57
58            let multiplier = match unit {
59                's' => 1,
60                'm' => 60,
61                'h' => 3600,
62                _ => {
63                    return Err(crate::errors::CoreError::Parse(format!(
64                        "Invalid duration unit '{unit}'. Use 's', 'm', or 'h'"
65                    )));
66                }
67            };
68
69            total_secs = total_secs
70                .checked_add(num.saturating_mul(multiplier))
71                .ok_or_else(|| crate::errors::CoreError::Parse("Duration overflow".into()))?;
72        }
73    }
74
75    // Handle trailing number without unit (treat as seconds)
76    if !current_num.is_empty() {
77        let num: u64 = current_num.parse().map_err(|_| {
78            crate::errors::CoreError::Parse(format!("Invalid number in duration: {current_num}"))
79        })?;
80        total_secs = total_secs
81            .checked_add(num)
82            .ok_or_else(|| crate::errors::CoreError::Parse("Duration overflow".into()))?;
83    }
84
85    if total_secs == 0 {
86        return Err(crate::errors::CoreError::Parse(
87            "Duration must be greater than 0".into(),
88        ));
89    }
90
91    Ok(Duration::from_secs(total_secs))
92}
93
94/// Format a Duration as a human-readable string.
95pub fn format_duration(d: Duration) -> String {
96    let total_secs = d.as_secs();
97    let hours = total_secs / 3600;
98    let minutes = (total_secs % 3600) / 60;
99    let seconds = total_secs % 60;
100
101    let mut parts = Vec::new();
102    if hours > 0 {
103        parts.push(format!("{hours}h"));
104    }
105    if minutes > 0 {
106        parts.push(format!("{minutes}m"));
107    }
108    if seconds > 0 || parts.is_empty() {
109        parts.push(format!("{seconds}s"));
110    }
111    parts.join("")
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_parse_seconds() {
120        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
121        assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
122        assert_eq!(parse_duration("120s").unwrap(), Duration::from_secs(120));
123    }
124
125    #[test]
126    fn test_parse_minutes() {
127        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
128        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
129    }
130
131    #[test]
132    fn test_parse_hours() {
133        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
134        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
135    }
136
137    #[test]
138    fn test_parse_combined() {
139        assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90));
140        assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
141        assert_eq!(
142            parse_duration("1h30m45s").unwrap(),
143            Duration::from_secs(5445)
144        );
145    }
146
147    #[test]
148    fn test_parse_bare_number() {
149        assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
150        assert_eq!(parse_duration("1").unwrap(), Duration::from_secs(1));
151    }
152
153    #[test]
154    fn test_parse_case_insensitive() {
155        assert_eq!(parse_duration("5M").unwrap(), Duration::from_secs(300));
156        assert_eq!(parse_duration("2H").unwrap(), Duration::from_secs(7200));
157        assert_eq!(parse_duration("30S").unwrap(), Duration::from_secs(30));
158    }
159
160    #[test]
161    fn test_parse_with_whitespace() {
162        assert_eq!(parse_duration(" 30s ").unwrap(), Duration::from_secs(30));
163    }
164
165    #[test]
166    fn test_parse_errors() {
167        assert!(parse_duration("").is_err());
168        assert!(parse_duration("abc").is_err());
169        assert!(parse_duration("5x").is_err());
170        assert!(parse_duration("m5").is_err());
171    }
172
173    #[test]
174    fn test_format_duration() {
175        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
176        assert_eq!(format_duration(Duration::from_secs(60)), "1m");
177        assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
178        assert_eq!(format_duration(Duration::from_secs(3600)), "1h");
179        assert_eq!(format_duration(Duration::from_secs(3660)), "1h1m");
180        assert_eq!(format_duration(Duration::from_secs(3661)), "1h1m1s");
181        assert_eq!(format_duration(Duration::from_secs(0)), "0s");
182    }
183}