1use 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
160fn 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
218fn 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
251pub 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 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 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 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 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 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 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 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 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}