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
97defn!(DurationDaysFn, vec![arg!(number)], None);
98
99impl Function for DurationDaysFn {
100    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
101        self.signature.validate(args, ctx)?;
102
103        let num = args[0]
104            .as_f64()
105            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected number"))?;
106
107        let total_secs = num as u64;
108        let days = (total_secs / 86400) % 7;
109
110        Ok(Value::Number(Number::from(days)))
111    }
112}
113
114defn!(DurationAddFn, vec![arg!(string), arg!(string)], None);
115
116impl Function for DurationAddFn {
117    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
118        self.signature.validate(args, ctx)?;
119
120        let a = args[0]
121            .as_str()
122            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected string"))?;
123        let b = args[1]
124            .as_str()
125            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected string"))?;
126
127        let secs_a = parse_duration_str(a)
128            .ok_or_else(|| crate::functions::custom_error(ctx, "Invalid duration string"))?;
129        let secs_b = parse_duration_str(b)
130            .ok_or_else(|| crate::functions::custom_error(ctx, "Invalid duration string"))?;
131
132        Ok(Value::String(format_duration_secs(secs_a + secs_b)))
133    }
134}
135
136defn!(DurationSubtractFn, vec![arg!(string), arg!(string)], None);
137
138impl Function for DurationSubtractFn {
139    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
140        self.signature.validate(args, ctx)?;
141
142        let a = args[0]
143            .as_str()
144            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected string"))?;
145        let b = args[1]
146            .as_str()
147            .ok_or_else(|| crate::functions::custom_error(ctx, "Expected string"))?;
148
149        let secs_a = parse_duration_str(a)
150            .ok_or_else(|| crate::functions::custom_error(ctx, "Invalid duration string"))?;
151        let secs_b = parse_duration_str(b)
152            .ok_or_else(|| crate::functions::custom_error(ctx, "Invalid duration string"))?;
153
154        Ok(Value::String(format_duration_secs(
155            secs_a.saturating_sub(secs_b),
156        )))
157    }
158}
159
160/// Parse a duration string into total seconds.
161fn parse_duration_str(s: &str) -> Option<u64> {
162    let s = s.trim().to_lowercase();
163    if s.is_empty() {
164        return None;
165    }
166
167    let mut total_secs: u64 = 0;
168    let mut current_num = String::new();
169
170    let chars: Vec<char> = s.chars().collect();
171    let mut i = 0;
172
173    while i < chars.len() {
174        let c = chars[i];
175
176        if c.is_ascii_digit() {
177            current_num.push(c);
178            i += 1;
179        } else if c.is_ascii_alphabetic() {
180            let num: u64 = if current_num.is_empty() {
181                return None;
182            } else {
183                current_num.parse().ok()?
184            };
185            current_num.clear();
186
187            let mut unit = String::new();
188            while i < chars.len() && chars[i].is_ascii_alphabetic() {
189                unit.push(chars[i]);
190                i += 1;
191            }
192
193            let multiplier = match unit.as_str() {
194                "w" | "week" | "weeks" => 7 * 24 * 3600,
195                "d" | "day" | "days" => 24 * 3600,
196                "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
197                "m" | "min" | "mins" | "minute" | "minutes" => 60,
198                "s" | "sec" | "secs" | "second" | "seconds" => 1,
199                _ => return None,
200            };
201
202            total_secs += num * multiplier;
203        } else if c.is_whitespace() {
204            i += 1;
205        } else {
206            return None;
207        }
208    }
209
210    if !current_num.is_empty() {
211        let num: u64 = current_num.parse().ok()?;
212        total_secs += num;
213    }
214
215    Some(total_secs)
216}
217
218/// Format seconds as a human-readable duration string.
219fn format_duration_secs(total_secs: u64) -> String {
220    if total_secs == 0 {
221        return "0s".to_string();
222    }
223
224    let weeks = total_secs / (7 * 24 * 3600);
225    let days = (total_secs / (24 * 3600)) % 7;
226    let hours = (total_secs / 3600) % 24;
227    let minutes = (total_secs / 60) % 60;
228    let seconds = total_secs % 60;
229
230    let mut result = String::new();
231
232    if weeks > 0 {
233        result.push_str(&format!("{}w", weeks));
234    }
235    if days > 0 {
236        result.push_str(&format!("{}d", days));
237    }
238    if hours > 0 {
239        result.push_str(&format!("{}h", hours));
240    }
241    if minutes > 0 {
242        result.push_str(&format!("{}m", minutes));
243    }
244    if seconds > 0 {
245        result.push_str(&format!("{}s", seconds));
246    }
247
248    result
249}
250
251/// Register duration functions that are in the enabled set.
252pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
253    register_if_enabled(
254        runtime,
255        "parse_duration",
256        enabled,
257        Box::new(ParseDurationFn::new()),
258    );
259    register_if_enabled(
260        runtime,
261        "format_duration",
262        enabled,
263        Box::new(FormatDurationFn::new()),
264    );
265    register_if_enabled(
266        runtime,
267        "duration_add",
268        enabled,
269        Box::new(DurationAddFn::new()),
270    );
271    register_if_enabled(
272        runtime,
273        "duration_days",
274        enabled,
275        Box::new(DurationDaysFn::new()),
276    );
277    register_if_enabled(
278        runtime,
279        "duration_hours",
280        enabled,
281        Box::new(DurationHoursFn::new()),
282    );
283    register_if_enabled(
284        runtime,
285        "duration_minutes",
286        enabled,
287        Box::new(DurationMinutesFn::new()),
288    );
289    register_if_enabled(
290        runtime,
291        "duration_seconds",
292        enabled,
293        Box::new(DurationSecondsFn::new()),
294    );
295    register_if_enabled(
296        runtime,
297        "duration_subtract",
298        enabled,
299        Box::new(DurationSubtractFn::new()),
300    );
301}
302
303#[cfg(test)]
304mod tests {
305    use crate::Runtime;
306    use serde_json::json;
307
308    use super::{format_duration_secs, parse_duration_str};
309
310    fn setup_runtime() -> Runtime {
311        Runtime::builder()
312            .with_standard()
313            .with_all_extensions()
314            .build()
315    }
316
317    #[test]
318    fn test_parse_duration() {
319        assert_eq!(parse_duration_str("1h"), Some(3600));
320        assert_eq!(parse_duration_str("30m"), Some(1800));
321        assert_eq!(parse_duration_str("45s"), Some(45));
322        assert_eq!(parse_duration_str("1h30m"), Some(5400));
323        assert_eq!(parse_duration_str("2h30m45s"), Some(9045));
324        assert_eq!(parse_duration_str("1d"), Some(86400));
325        assert_eq!(parse_duration_str("1w"), Some(604800));
326        assert_eq!(parse_duration_str("1w2d3h4m5s"), Some(788645));
327        assert_eq!(parse_duration_str("1 hour 30 minutes"), Some(5400));
328        assert_eq!(parse_duration_str(""), None);
329        assert_eq!(parse_duration_str("invalid"), None);
330    }
331
332    #[test]
333    fn test_format_duration() {
334        assert_eq!(format_duration_secs(0), "0s");
335        assert_eq!(format_duration_secs(45), "45s");
336        assert_eq!(format_duration_secs(60), "1m");
337        assert_eq!(format_duration_secs(3600), "1h");
338        assert_eq!(format_duration_secs(5400), "1h30m");
339        assert_eq!(format_duration_secs(86400), "1d");
340        assert_eq!(format_duration_secs(90061), "1d1h1m1s");
341        assert_eq!(format_duration_secs(788645), "1w2d3h4m5s");
342    }
343
344    #[test]
345    fn test_roundtrip() {
346        let values = [0, 45, 60, 3600, 5400, 86400, 90061, 788645];
347        for &v in &values {
348            let formatted = format_duration_secs(v);
349            let parsed = parse_duration_str(&formatted).unwrap_or(0);
350            assert_eq!(
351                parsed, v,
352                "Roundtrip failed for {}: {} -> {}",
353                v, formatted, parsed
354            );
355        }
356    }
357
358    #[test]
359    fn test_parse_duration_via_runtime() {
360        let runtime = setup_runtime();
361        let data = json!("1h30m");
362        let expr = runtime.compile("parse_duration(@)").unwrap();
363        let result = expr.search(&data).unwrap();
364        assert_eq!(result.as_f64().unwrap(), 5400.0);
365    }
366
367    #[test]
368    fn test_format_duration_via_runtime() {
369        let runtime = setup_runtime();
370        let expr = runtime.compile("format_duration(`5400`)").unwrap();
371        let result = expr.search(&json!(null)).unwrap();
372        assert_eq!(result.as_str().unwrap(), "1h30m");
373    }
374
375    #[test]
376    fn test_duration_hours_via_runtime() {
377        let runtime = setup_runtime();
378        // 90061 seconds = 1d 1h 1m 1s -> hours component is 1
379        let expr = runtime.compile("duration_hours(`90061`)").unwrap();
380        let result = expr.search(&json!(null)).unwrap();
381        assert_eq!(result.as_i64().unwrap(), 1);
382    }
383
384    #[test]
385    fn test_duration_minutes_via_runtime() {
386        let runtime = setup_runtime();
387        // 90061 seconds = 1d 1h 1m 1s -> minutes component is 1
388        let expr = runtime.compile("duration_minutes(`90061`)").unwrap();
389        let result = expr.search(&json!(null)).unwrap();
390        assert_eq!(result.as_i64().unwrap(), 1);
391    }
392
393    #[test]
394    fn test_duration_seconds_via_runtime() {
395        let runtime = setup_runtime();
396        // 90061 seconds = 1d 1h 1m 1s -> seconds component is 1
397        let expr = runtime.compile("duration_seconds(`90061`)").unwrap();
398        let result = expr.search(&json!(null)).unwrap();
399        assert_eq!(result.as_i64().unwrap(), 1);
400    }
401
402    #[test]
403    fn test_duration_days_via_runtime() {
404        let runtime = setup_runtime();
405        // 86400 seconds = 1d -> days component is 1
406        let expr = runtime.compile("duration_days(`86400`)").unwrap();
407        let result = expr.search(&json!(null)).unwrap();
408        assert_eq!(result.as_i64().unwrap(), 1);
409
410        // 90061 seconds = 1d 1h 1m 1s -> days component is 1
411        let expr = runtime.compile("duration_days(`90061`)").unwrap();
412        let result = expr.search(&json!(null)).unwrap();
413        assert_eq!(result.as_i64().unwrap(), 1);
414
415        // 691200 seconds = 8 days = 1w1d -> days component is 8%7 = 1
416        let expr = runtime.compile("duration_days(`691200`)").unwrap();
417        let result = expr.search(&json!(null)).unwrap();
418        assert_eq!(result.as_i64().unwrap(), 1);
419    }
420
421    #[test]
422    fn test_duration_add_via_runtime() {
423        let runtime = setup_runtime();
424
425        let expr = runtime.compile("duration_add('1h', '30m')").unwrap();
426        let result = expr.search(&json!(null)).unwrap();
427        assert_eq!(result.as_str().unwrap(), "1h30m");
428
429        let expr = runtime.compile("duration_add('1d', '12h')").unwrap();
430        let result = expr.search(&json!(null)).unwrap();
431        assert_eq!(result.as_str().unwrap(), "1d12h");
432
433        let expr = runtime.compile("duration_add('1h', '0s')").unwrap();
434        let result = expr.search(&json!(null)).unwrap();
435        assert_eq!(result.as_str().unwrap(), "1h");
436    }
437
438    #[test]
439    fn test_duration_subtract_via_runtime() {
440        let runtime = setup_runtime();
441
442        let expr = runtime.compile("duration_subtract('2h', '30m')").unwrap();
443        let result = expr.search(&json!(null)).unwrap();
444        assert_eq!(result.as_str().unwrap(), "1h30m");
445
446        // Equal durations -> "0s"
447        let expr = runtime.compile("duration_subtract('1h', '1h')").unwrap();
448        let result = expr.search(&json!(null)).unwrap();
449        assert_eq!(result.as_str().unwrap(), "0s");
450
451        // Underflow clamps to "0s"
452        let expr = runtime.compile("duration_subtract('30m', '2h')").unwrap();
453        let result = expr.search(&json!(null)).unwrap();
454        assert_eq!(result.as_str().unwrap(), "0s");
455    }
456}