Skip to main content

jpx_core/extensions/
duration.rs

1//! Duration parsing and formatting functions.
2
3use std::collections::HashSet;
4
5use serde_json::{Number, Value};
6
7use crate::functions::{Function, number_value};
8use crate::interpreter::SearchResult;
9use crate::registry::register_if_enabled;
10use crate::{Context, Runtime, arg, defn};
11
12defn!(ParseDurationFn, vec![arg!(string)], None);
13
14impl Function for ParseDurationFn {
15    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
16        self.signature.validate(args, ctx)?;
17
18        let s = args[0]
19            .as_str()
20            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected string"))?;
21
22        match parse_duration_str(s) {
23            Some(secs) => Ok(number_value(secs as f64)),
24            None => Ok(Value::Null),
25        }
26    }
27}
28
29defn!(FormatDurationFn, vec![arg!(number)], None);
30
31impl Function for FormatDurationFn {
32    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
33        self.signature.validate(args, ctx)?;
34
35        let num = args[0]
36            .as_f64()
37            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected number"))?;
38
39        let total_secs = num as u64;
40        let formatted = format_duration_secs(total_secs);
41
42        Ok(Value::String(formatted))
43    }
44}
45
46defn!(DurationHoursFn, vec![arg!(number)], None);
47
48impl Function for DurationHoursFn {
49    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
50        self.signature.validate(args, ctx)?;
51
52        let num = args[0]
53            .as_f64()
54            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected number"))?;
55
56        let total_secs = num as u64;
57        let hours = (total_secs / 3600) % 24;
58
59        Ok(Value::Number(Number::from(hours)))
60    }
61}
62
63defn!(DurationMinutesFn, vec![arg!(number)], None);
64
65impl Function for DurationMinutesFn {
66    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
67        self.signature.validate(args, ctx)?;
68
69        let num = args[0]
70            .as_f64()
71            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected number"))?;
72
73        let total_secs = num as u64;
74        let minutes = (total_secs / 60) % 60;
75
76        Ok(Value::Number(Number::from(minutes)))
77    }
78}
79
80defn!(DurationSecondsFn, vec![arg!(number)], None);
81
82impl Function for DurationSecondsFn {
83    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
84        self.signature.validate(args, ctx)?;
85
86        let num = args[0]
87            .as_f64()
88            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected number"))?;
89
90        let total_secs = num as u64;
91        let seconds = total_secs % 60;
92
93        Ok(Value::Number(Number::from(seconds)))
94    }
95}
96
97/// Parse a duration string into total seconds.
98fn parse_duration_str(s: &str) -> Option<u64> {
99    let s = s.trim().to_lowercase();
100    if s.is_empty() {
101        return None;
102    }
103
104    let mut total_secs: u64 = 0;
105    let mut current_num = String::new();
106
107    let chars: Vec<char> = s.chars().collect();
108    let mut i = 0;
109
110    while i < chars.len() {
111        let c = chars[i];
112
113        if c.is_ascii_digit() {
114            current_num.push(c);
115            i += 1;
116        } else if c.is_ascii_alphabetic() {
117            let num: u64 = if current_num.is_empty() {
118                return None;
119            } else {
120                current_num.parse().ok()?
121            };
122            current_num.clear();
123
124            let mut unit = String::new();
125            while i < chars.len() && chars[i].is_ascii_alphabetic() {
126                unit.push(chars[i]);
127                i += 1;
128            }
129
130            let multiplier = match unit.as_str() {
131                "w" | "week" | "weeks" => 7 * 24 * 3600,
132                "d" | "day" | "days" => 24 * 3600,
133                "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
134                "m" | "min" | "mins" | "minute" | "minutes" => 60,
135                "s" | "sec" | "secs" | "second" | "seconds" => 1,
136                _ => return None,
137            };
138
139            total_secs += num * multiplier;
140        } else if c.is_whitespace() {
141            i += 1;
142        } else {
143            return None;
144        }
145    }
146
147    if !current_num.is_empty() {
148        let num: u64 = current_num.parse().ok()?;
149        total_secs += num;
150    }
151
152    Some(total_secs)
153}
154
155/// Format seconds as a human-readable duration string.
156fn format_duration_secs(total_secs: u64) -> String {
157    if total_secs == 0 {
158        return "0s".to_string();
159    }
160
161    let weeks = total_secs / (7 * 24 * 3600);
162    let days = (total_secs / (24 * 3600)) % 7;
163    let hours = (total_secs / 3600) % 24;
164    let minutes = (total_secs / 60) % 60;
165    let seconds = total_secs % 60;
166
167    let mut result = String::new();
168
169    if weeks > 0 {
170        result.push_str(&format!("{}w", weeks));
171    }
172    if days > 0 {
173        result.push_str(&format!("{}d", days));
174    }
175    if hours > 0 {
176        result.push_str(&format!("{}h", hours));
177    }
178    if minutes > 0 {
179        result.push_str(&format!("{}m", minutes));
180    }
181    if seconds > 0 {
182        result.push_str(&format!("{}s", seconds));
183    }
184
185    result
186}
187
188/// Register duration functions that are in the enabled set.
189pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
190    register_if_enabled(
191        runtime,
192        "parse_duration",
193        enabled,
194        Box::new(ParseDurationFn::new()),
195    );
196    register_if_enabled(
197        runtime,
198        "format_duration",
199        enabled,
200        Box::new(FormatDurationFn::new()),
201    );
202    register_if_enabled(
203        runtime,
204        "duration_hours",
205        enabled,
206        Box::new(DurationHoursFn::new()),
207    );
208    register_if_enabled(
209        runtime,
210        "duration_minutes",
211        enabled,
212        Box::new(DurationMinutesFn::new()),
213    );
214    register_if_enabled(
215        runtime,
216        "duration_seconds",
217        enabled,
218        Box::new(DurationSecondsFn::new()),
219    );
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::Runtime;
225    use serde_json::json;
226
227    use super::{format_duration_secs, parse_duration_str};
228
229    fn setup_runtime() -> Runtime {
230        Runtime::builder()
231            .with_standard()
232            .with_all_extensions()
233            .build()
234    }
235
236    #[test]
237    fn test_parse_duration() {
238        assert_eq!(parse_duration_str("1h"), Some(3600));
239        assert_eq!(parse_duration_str("30m"), Some(1800));
240        assert_eq!(parse_duration_str("45s"), Some(45));
241        assert_eq!(parse_duration_str("1h30m"), Some(5400));
242        assert_eq!(parse_duration_str("2h30m45s"), Some(9045));
243        assert_eq!(parse_duration_str("1d"), Some(86400));
244        assert_eq!(parse_duration_str("1w"), Some(604800));
245        assert_eq!(parse_duration_str("1w2d3h4m5s"), Some(788645));
246        assert_eq!(parse_duration_str("1 hour 30 minutes"), Some(5400));
247        assert_eq!(parse_duration_str(""), None);
248        assert_eq!(parse_duration_str("invalid"), None);
249    }
250
251    #[test]
252    fn test_format_duration() {
253        assert_eq!(format_duration_secs(0), "0s");
254        assert_eq!(format_duration_secs(45), "45s");
255        assert_eq!(format_duration_secs(60), "1m");
256        assert_eq!(format_duration_secs(3600), "1h");
257        assert_eq!(format_duration_secs(5400), "1h30m");
258        assert_eq!(format_duration_secs(86400), "1d");
259        assert_eq!(format_duration_secs(90061), "1d1h1m1s");
260        assert_eq!(format_duration_secs(788645), "1w2d3h4m5s");
261    }
262
263    #[test]
264    fn test_roundtrip() {
265        let values = [0, 45, 60, 3600, 5400, 86400, 90061, 788645];
266        for &v in &values {
267            let formatted = format_duration_secs(v);
268            let parsed = parse_duration_str(&formatted).unwrap_or(0);
269            assert_eq!(
270                parsed, v,
271                "Roundtrip failed for {}: {} -> {}",
272                v, formatted, parsed
273            );
274        }
275    }
276
277    #[test]
278    fn test_parse_duration_via_runtime() {
279        let runtime = setup_runtime();
280        let data = json!("1h30m");
281        let expr = runtime.compile("parse_duration(@)").unwrap();
282        let result = expr.search(&data).unwrap();
283        assert_eq!(result.as_f64().unwrap(), 5400.0);
284    }
285
286    #[test]
287    fn test_format_duration_via_runtime() {
288        let runtime = setup_runtime();
289        let expr = runtime.compile("format_duration(`5400`)").unwrap();
290        let result = expr.search(&json!(null)).unwrap();
291        assert_eq!(result.as_str().unwrap(), "1h30m");
292    }
293
294    #[test]
295    fn test_duration_hours_via_runtime() {
296        let runtime = setup_runtime();
297        // 90061 seconds = 1d 1h 1m 1s -> hours component is 1
298        let expr = runtime.compile("duration_hours(`90061`)").unwrap();
299        let result = expr.search(&json!(null)).unwrap();
300        assert_eq!(result.as_i64().unwrap(), 1);
301    }
302
303    #[test]
304    fn test_duration_minutes_via_runtime() {
305        let runtime = setup_runtime();
306        // 90061 seconds = 1d 1h 1m 1s -> minutes component is 1
307        let expr = runtime.compile("duration_minutes(`90061`)").unwrap();
308        let result = expr.search(&json!(null)).unwrap();
309        assert_eq!(result.as_i64().unwrap(), 1);
310    }
311
312    #[test]
313    fn test_duration_seconds_via_runtime() {
314        let runtime = setup_runtime();
315        // 90061 seconds = 1d 1h 1m 1s -> seconds component is 1
316        let expr = runtime.compile("duration_seconds(`90061`)").unwrap();
317        let result = expr.search(&json!(null)).unwrap();
318        assert_eq!(result.as_i64().unwrap(), 1);
319    }
320}