Skip to main content

jpx_core/extensions/
datetime.rs

1//! Date and time functions.
2
3use std::collections::HashSet;
4
5use chrono::{DateTime, Datelike, NaiveDateTime, TimeDelta, TimeZone, Utc, Weekday};
6use chrono_tz::Tz;
7use serde_json::Value;
8
9use crate::functions::{Function, custom_error, number_value};
10use crate::interpreter::SearchResult;
11use crate::registry::register_if_enabled;
12use crate::{Context, Runtime, arg, defn};
13
14/// Register datetime functions filtered by the enabled set.
15pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
16    register_if_enabled(runtime, "now", enabled, Box::new(NowFn::new()));
17    register_if_enabled(runtime, "now_millis", enabled, Box::new(NowMillisFn::new()));
18    register_if_enabled(runtime, "parse_date", enabled, Box::new(ParseDateFn::new()));
19    register_if_enabled(
20        runtime,
21        "format_date",
22        enabled,
23        Box::new(FormatDateFn::new()),
24    );
25    register_if_enabled(runtime, "date_add", enabled, Box::new(DateAddFn::new()));
26    register_if_enabled(runtime, "date_diff", enabled, Box::new(DateDiffFn::new()));
27    register_if_enabled(
28        runtime,
29        "timezone_convert",
30        enabled,
31        Box::new(TimezoneConvertFn::new()),
32    );
33    register_if_enabled(runtime, "is_weekend", enabled, Box::new(IsWeekendFn::new()));
34    register_if_enabled(runtime, "is_weekday", enabled, Box::new(IsWeekdayFn::new()));
35    register_if_enabled(
36        runtime,
37        "business_days_between",
38        enabled,
39        Box::new(BusinessDaysBetweenFn::new()),
40    );
41    register_if_enabled(
42        runtime,
43        "relative_time",
44        enabled,
45        Box::new(RelativeTimeFn::new()),
46    );
47    register_if_enabled(runtime, "quarter", enabled, Box::new(QuarterFn::new()));
48    register_if_enabled(runtime, "is_after", enabled, Box::new(IsAfterFn::new()));
49    register_if_enabled(runtime, "is_before", enabled, Box::new(IsBeforeFn::new()));
50    register_if_enabled(runtime, "is_between", enabled, Box::new(IsBetweenFn::new()));
51    register_if_enabled(runtime, "time_ago", enabled, Box::new(TimeAgoFn::new()));
52    register_if_enabled(runtime, "from_epoch", enabled, Box::new(FromEpochFn::new()));
53    register_if_enabled(
54        runtime,
55        "from_epoch_ms",
56        enabled,
57        Box::new(FromEpochMsFn::new()),
58    );
59    register_if_enabled(runtime, "to_epoch", enabled, Box::new(ToEpochFn::new()));
60    register_if_enabled(
61        runtime,
62        "to_epoch_ms",
63        enabled,
64        Box::new(ToEpochMsFn::new()),
65    );
66    register_if_enabled(
67        runtime,
68        "duration_since",
69        enabled,
70        Box::new(DurationSinceFn::new()),
71    );
72    register_if_enabled(
73        runtime,
74        "start_of_day",
75        enabled,
76        Box::new(StartOfDayFn::new()),
77    );
78    register_if_enabled(runtime, "end_of_day", enabled, Box::new(EndOfDayFn::new()));
79    register_if_enabled(
80        runtime,
81        "start_of_week",
82        enabled,
83        Box::new(StartOfWeekFn::new()),
84    );
85    register_if_enabled(
86        runtime,
87        "start_of_month",
88        enabled,
89        Box::new(StartOfMonthFn::new()),
90    );
91    register_if_enabled(
92        runtime,
93        "start_of_year",
94        enabled,
95        Box::new(StartOfYearFn::new()),
96    );
97    register_if_enabled(
98        runtime,
99        "is_same_day",
100        enabled,
101        Box::new(IsSameDayFn::new()),
102    );
103    // epoch_ms is an alias for now_millis (common name)
104    register_if_enabled(runtime, "epoch_ms", enabled, Box::new(NowMillisFn::new()));
105    register_if_enabled(
106        runtime,
107        "parse_datetime",
108        enabled,
109        Box::new(ParseDatetimeFn::new()),
110    );
111    register_if_enabled(
112        runtime,
113        "parse_natural_date",
114        enabled,
115        Box::new(ParseNaturalDateFn::new()),
116    );
117}
118
119// now() -> number
120defn!(NowFn, vec![], None);
121
122impl Function for NowFn {
123    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
124        self.signature.validate(args, ctx)?;
125        let ts = Utc::now().timestamp();
126        Ok(number_value(ts as f64))
127    }
128}
129
130// now_millis() -> number
131defn!(NowMillisFn, vec![], None);
132
133impl Function for NowMillisFn {
134    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
135        self.signature.validate(args, ctx)?;
136        let ts = Utc::now().timestamp_millis();
137        Ok(number_value(ts as f64))
138    }
139}
140
141// parse_date(string, format?) -> number | null
142defn!(ParseDateFn, vec![arg!(string)], Some(arg!(string)));
143
144impl Function for ParseDateFn {
145    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
146        self.signature.validate(args, ctx)?;
147
148        let s = args[0].as_str().unwrap();
149
150        if args.len() > 1 {
151            // Custom format provided
152            let format = args[1].as_str().unwrap();
153            match NaiveDateTime::parse_from_str(s, format) {
154                Ok(dt) => Ok(number_value(dt.and_utc().timestamp() as f64)),
155                Err(_) => Ok(Value::Null),
156            }
157        } else {
158            // Try common formats
159            if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
160                return Ok(number_value(dt.timestamp() as f64));
161            }
162            if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
163                return Ok(number_value(dt.and_utc().timestamp() as f64));
164            }
165            if let Ok(dt) =
166                NaiveDateTime::parse_from_str(&format!("{}T00:00:00", s), "%Y-%m-%dT%H:%M:%S")
167            {
168                return Ok(number_value(dt.and_utc().timestamp() as f64));
169            }
170            Ok(Value::Null)
171        }
172    }
173}
174
175// format_date(timestamp, format) -> string
176defn!(FormatDateFn, vec![arg!(number), arg!(string)], None);
177
178impl Function for FormatDateFn {
179    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
180        self.signature.validate(args, ctx)?;
181
182        let ts = args[0].as_f64().unwrap();
183        let format = args[1].as_str().unwrap();
184
185        let dt = Utc.timestamp_opt(ts as i64, 0);
186        match dt {
187            chrono::LocalResult::Single(dt) => Ok(Value::String(dt.format(format).to_string())),
188            _ => Ok(Value::Null),
189        }
190    }
191}
192
193// date_add(timestamp, amount, unit) -> number
194defn!(
195    DateAddFn,
196    vec![arg!(number), arg!(number), arg!(string)],
197    None
198);
199
200impl Function for DateAddFn {
201    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
202        self.signature.validate(args, ctx)?;
203
204        let ts = args[0].as_f64().unwrap();
205        let amount = args[1].as_f64().unwrap();
206        let unit = args[2].as_str().unwrap();
207
208        let duration = match unit.to_lowercase().as_str() {
209            "seconds" | "second" | "s" => TimeDelta::seconds(amount as i64),
210            "minutes" | "minute" | "m" => TimeDelta::minutes(amount as i64),
211            "hours" | "hour" | "h" => TimeDelta::hours(amount as i64),
212            "days" | "day" | "d" => TimeDelta::days(amount as i64),
213            "weeks" | "week" | "w" => TimeDelta::weeks(amount as i64),
214            _ => return Err(custom_error(ctx, &format!("invalid time unit: {}", unit))),
215        };
216
217        let dt = Utc.timestamp_opt(ts as i64, 0);
218        match dt {
219            chrono::LocalResult::Single(dt) => {
220                let new_dt = dt + duration;
221                Ok(number_value(new_dt.timestamp() as f64))
222            }
223            _ => Ok(Value::Null),
224        }
225    }
226}
227
228// date_diff(ts1, ts2, unit) -> number
229defn!(
230    DateDiffFn,
231    vec![arg!(number), arg!(number), arg!(string)],
232    None
233);
234
235impl Function for DateDiffFn {
236    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
237        self.signature.validate(args, ctx)?;
238
239        let ts1 = args[0].as_f64().unwrap();
240        let ts2 = args[1].as_f64().unwrap();
241        let unit = args[2].as_str().unwrap();
242
243        let diff_seconds = (ts1 - ts2) as i64;
244
245        let result = match unit.to_lowercase().as_str() {
246            "seconds" | "second" | "s" => diff_seconds as f64,
247            "minutes" | "minute" | "m" => diff_seconds as f64 / 60.0,
248            "hours" | "hour" | "h" => diff_seconds as f64 / 3600.0,
249            "days" | "day" | "d" => diff_seconds as f64 / 86400.0,
250            "weeks" | "week" | "w" => diff_seconds as f64 / 604800.0,
251            _ => return Err(custom_error(ctx, &format!("invalid time unit: {}", unit))),
252        };
253
254        Ok(number_value(result))
255    }
256}
257
258// timezone_convert(timestamp, from_tz, to_tz) -> string
259defn!(
260    TimezoneConvertFn,
261    vec![arg!(string), arg!(string), arg!(string)],
262    None
263);
264
265impl Function for TimezoneConvertFn {
266    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
267        self.signature.validate(args, ctx)?;
268
269        let timestamp_str = args[0].as_str().unwrap();
270        let from_tz_str = args[1].as_str().unwrap();
271        let to_tz_str = args[2].as_str().unwrap();
272
273        // Parse timezone strings
274        let from_tz: Tz = from_tz_str
275            .parse()
276            .map_err(|_| custom_error(ctx, &format!("invalid timezone: {}", from_tz_str)))?;
277        let to_tz: Tz = to_tz_str
278            .parse()
279            .map_err(|_| custom_error(ctx, &format!("invalid timezone: {}", to_tz_str)))?;
280
281        // Parse the input timestamp (try multiple formats)
282        let naive_dt =
283            if let Ok(dt) = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%dT%H:%M:%S") {
284                dt
285            } else if let Ok(dt) = NaiveDateTime::parse_from_str(
286                &format!("{}T00:00:00", timestamp_str),
287                "%Y-%m-%dT%H:%M:%S",
288            ) {
289                dt
290            } else {
291                return Err(custom_error(
292                    ctx,
293                    &format!("invalid timestamp format: {}", timestamp_str),
294                ));
295            };
296
297        // Interpret the naive datetime in the source timezone
298        let from_dt = from_tz
299            .from_local_datetime(&naive_dt)
300            .single()
301            .ok_or_else(|| custom_error(ctx, "ambiguous or invalid local time"))?;
302
303        // Convert to target timezone
304        let to_dt = from_dt.with_timezone(&to_tz);
305
306        // Format as ISO string without timezone suffix
307        Ok(Value::String(to_dt.format("%Y-%m-%dT%H:%M:%S").to_string()))
308    }
309}
310
311// is_weekend(timestamp) -> boolean
312defn!(IsWeekendFn, vec![arg!(number)], None);
313
314impl Function for IsWeekendFn {
315    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
316        self.signature.validate(args, ctx)?;
317
318        let ts = args[0].as_f64().unwrap();
319        let dt = Utc.timestamp_opt(ts as i64, 0);
320
321        match dt {
322            chrono::LocalResult::Single(dt) => {
323                let weekday = dt.weekday();
324                let is_weekend = weekday == Weekday::Sat || weekday == Weekday::Sun;
325                Ok(Value::Bool(is_weekend))
326            }
327            _ => Ok(Value::Null),
328        }
329    }
330}
331
332// is_weekday(timestamp) -> boolean
333defn!(IsWeekdayFn, vec![arg!(number)], None);
334
335impl Function for IsWeekdayFn {
336    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
337        self.signature.validate(args, ctx)?;
338
339        let ts = args[0].as_f64().unwrap();
340        let dt = Utc.timestamp_opt(ts as i64, 0);
341
342        match dt {
343            chrono::LocalResult::Single(dt) => {
344                let weekday = dt.weekday();
345                let is_weekday = weekday != Weekday::Sat && weekday != Weekday::Sun;
346                Ok(Value::Bool(is_weekday))
347            }
348            _ => Ok(Value::Null),
349        }
350    }
351}
352
353// business_days_between(ts1, ts2) -> number
354defn!(
355    BusinessDaysBetweenFn,
356    vec![arg!(number), arg!(number)],
357    None
358);
359
360impl Function for BusinessDaysBetweenFn {
361    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
362        self.signature.validate(args, ctx)?;
363
364        let ts1 = args[0].as_f64().unwrap() as i64;
365        let ts2 = args[1].as_f64().unwrap() as i64;
366
367        let dt1 = match Utc.timestamp_opt(ts1, 0) {
368            chrono::LocalResult::Single(dt) => dt,
369            _ => return Ok(Value::Null),
370        };
371        let dt2 = match Utc.timestamp_opt(ts2, 0) {
372            chrono::LocalResult::Single(dt) => dt,
373            _ => return Ok(Value::Null),
374        };
375
376        // Ensure we iterate from earlier to later date
377        let (start, end) = if dt1 <= dt2 {
378            (dt1.date_naive(), dt2.date_naive())
379        } else {
380            (dt2.date_naive(), dt1.date_naive())
381        };
382
383        let mut count = 0i64;
384        let mut current = start;
385
386        while current < end {
387            let weekday = current.weekday();
388            if weekday != Weekday::Sat && weekday != Weekday::Sun {
389                count += 1;
390            }
391            current = current.succ_opt().unwrap_or(current);
392        }
393
394        // If original order was reversed, return negative count
395        let result = if ts1 > ts2 { -count } else { count };
396
397        Ok(number_value(result as f64))
398    }
399}
400
401// relative_time(timestamp) -> string
402defn!(RelativeTimeFn, vec![arg!(number)], None);
403
404impl Function for RelativeTimeFn {
405    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
406        self.signature.validate(args, ctx)?;
407
408        let ts = args[0].as_f64().unwrap() as i64;
409        let now = Utc::now().timestamp();
410        let diff = ts - now;
411
412        let (abs_diff, is_future) = if diff >= 0 {
413            (diff, true)
414        } else {
415            (-diff, false)
416        };
417
418        // Determine the unit and value
419        let (value, unit_singular, unit_plural) = if abs_diff < 60 {
420            (abs_diff, "second", "seconds")
421        } else if abs_diff < 3600 {
422            (abs_diff / 60, "minute", "minutes")
423        } else if abs_diff < 86400 {
424            (abs_diff / 3600, "hour", "hours")
425        } else if abs_diff < 2592000 {
426            (abs_diff / 86400, "day", "days")
427        } else if abs_diff < 31536000 {
428            (abs_diff / 2592000, "month", "months")
429        } else {
430            (abs_diff / 31536000, "year", "years")
431        };
432
433        let unit = if value == 1 {
434            unit_singular
435        } else {
436            unit_plural
437        };
438        let result = if is_future {
439            format!("in {} {}", value, unit)
440        } else {
441            format!("{} {} ago", value, unit)
442        };
443
444        Ok(Value::String(result))
445    }
446}
447
448// quarter(timestamp) -> number
449defn!(QuarterFn, vec![arg!(number)], None);
450
451impl Function for QuarterFn {
452    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
453        self.signature.validate(args, ctx)?;
454
455        let ts = args[0].as_f64().unwrap();
456        let dt = Utc.timestamp_opt(ts as i64, 0);
457
458        match dt {
459            chrono::LocalResult::Single(dt) => {
460                let month = dt.month();
461                let quarter = ((month - 1) / 3) + 1;
462                Ok(number_value(quarter as f64))
463            }
464            _ => Ok(Value::Null),
465        }
466    }
467}
468
469/// Helper function to parse a date value that can be either a string or a number (timestamp).
470/// Returns the Unix timestamp as i64, or None if parsing fails.
471fn parse_date_value(value: &Value) -> Option<i64> {
472    match value {
473        Value::Number(n) => n.as_f64().map(|f| f as i64),
474        Value::String(s) => {
475            // Try RFC3339 first
476            if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
477                return Some(dt.timestamp());
478            }
479            // Try ISO datetime without timezone
480            if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
481                return Some(dt.and_utc().timestamp());
482            }
483            // Try date only
484            if let Ok(dt) =
485                NaiveDateTime::parse_from_str(&format!("{}T00:00:00", s), "%Y-%m-%dT%H:%M:%S")
486            {
487                return Some(dt.and_utc().timestamp());
488            }
489            None
490        }
491        _ => None,
492    }
493}
494
495// is_after(date1, date2) -> boolean
496defn!(IsAfterFn, vec![arg!(any), arg!(any)], None);
497
498impl Function for IsAfterFn {
499    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
500        self.signature.validate(args, ctx)?;
501
502        let ts1 = parse_date_value(&args[0]);
503        let ts2 = parse_date_value(&args[1]);
504
505        match (ts1, ts2) {
506            (Some(t1), Some(t2)) => Ok(Value::Bool(t1 > t2)),
507            _ => Ok(Value::Null),
508        }
509    }
510}
511
512// is_before(date1, date2) -> boolean
513defn!(IsBeforeFn, vec![arg!(any), arg!(any)], None);
514
515impl Function for IsBeforeFn {
516    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
517        self.signature.validate(args, ctx)?;
518
519        let ts1 = parse_date_value(&args[0]);
520        let ts2 = parse_date_value(&args[1]);
521
522        match (ts1, ts2) {
523            (Some(t1), Some(t2)) => Ok(Value::Bool(t1 < t2)),
524            _ => Ok(Value::Null),
525        }
526    }
527}
528
529// is_between(date, start, end) -> boolean
530defn!(IsBetweenFn, vec![arg!(any), arg!(any), arg!(any)], None);
531
532impl Function for IsBetweenFn {
533    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
534        self.signature.validate(args, ctx)?;
535
536        let ts = parse_date_value(&args[0]);
537        let start = parse_date_value(&args[1]);
538        let end = parse_date_value(&args[2]);
539
540        match (ts, start, end) {
541            (Some(t), Some(s), Some(e)) => Ok(Value::Bool(t >= s && t <= e)),
542            _ => Ok(Value::Null),
543        }
544    }
545}
546
547// time_ago(date) -> string
548defn!(TimeAgoFn, vec![arg!(any)], None);
549
550impl Function for TimeAgoFn {
551    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
552        self.signature.validate(args, ctx)?;
553
554        let ts = match parse_date_value(&args[0]) {
555            Some(t) => t,
556            None => return Ok(Value::Null),
557        };
558
559        let now = Utc::now().timestamp();
560        let diff = now - ts;
561        let abs_diff = diff.abs();
562
563        // Determine the unit and value
564        let (value, unit_singular, unit_plural) = if abs_diff < 60 {
565            (abs_diff, "second", "seconds")
566        } else if abs_diff < 3600 {
567            (abs_diff / 60, "minute", "minutes")
568        } else if abs_diff < 86400 {
569            (abs_diff / 3600, "hour", "hours")
570        } else if abs_diff < 2592000 {
571            (abs_diff / 86400, "day", "days")
572        } else if abs_diff < 31536000 {
573            (abs_diff / 2592000, "month", "months")
574        } else {
575            (abs_diff / 31536000, "year", "years")
576        };
577
578        let unit = if value == 1 {
579            unit_singular
580        } else {
581            unit_plural
582        };
583
584        let result = if diff < 0 {
585            format!("in {} {}", value, unit)
586        } else {
587            format!("{} {} ago", value, unit)
588        };
589
590        Ok(Value::String(result))
591    }
592}
593
594// =============================================================================
595// from_epoch(seconds) -> string
596// =============================================================================
597
598defn!(FromEpochFn, vec![arg!(number)], None);
599
600impl Function for FromEpochFn {
601    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
602        self.signature.validate(args, ctx)?;
603
604        let epoch = args[0].as_f64().unwrap() as i64;
605
606        match DateTime::from_timestamp(epoch, 0) {
607            Some(dt) => Ok(Value::String(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())),
608            None => Ok(Value::Null),
609        }
610    }
611}
612
613// =============================================================================
614// from_epoch_ms(milliseconds) -> string
615// =============================================================================
616
617defn!(FromEpochMsFn, vec![arg!(number)], None);
618
619impl Function for FromEpochMsFn {
620    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
621        self.signature.validate(args, ctx)?;
622
623        let epoch_ms = args[0].as_f64().unwrap() as i64;
624        let seconds = epoch_ms / 1000;
625        let nanos = ((epoch_ms % 1000) * 1_000_000) as u32;
626
627        match DateTime::from_timestamp(seconds, nanos) {
628            Some(dt) => Ok(Value::String(
629                dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
630            )),
631            None => Ok(Value::Null),
632        }
633    }
634}
635
636// =============================================================================
637// to_epoch(datetime) -> number
638// =============================================================================
639
640defn!(ToEpochFn, vec![arg!(any)], None);
641
642impl Function for ToEpochFn {
643    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
644        self.signature.validate(args, ctx)?;
645
646        match parse_date_value(&args[0]) {
647            Some(ts) => Ok(number_value(ts as f64)),
648            None => Ok(Value::Null),
649        }
650    }
651}
652
653// =============================================================================
654// to_epoch_ms(datetime) -> number
655// =============================================================================
656
657defn!(ToEpochMsFn, vec![arg!(any)], None);
658
659impl Function for ToEpochMsFn {
660    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
661        self.signature.validate(args, ctx)?;
662
663        match parse_date_value(&args[0]) {
664            Some(ts) => {
665                let ts_ms = ts * 1000;
666                Ok(number_value(ts_ms as f64))
667            }
668            None => Ok(Value::Null),
669        }
670    }
671}
672
673// =============================================================================
674// duration_since(datetime) -> object
675// =============================================================================
676
677defn!(DurationSinceFn, vec![arg!(any)], None);
678
679impl Function for DurationSinceFn {
680    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
681        self.signature.validate(args, ctx)?;
682
683        let ts = match parse_date_value(&args[0]) {
684            Some(t) => t,
685            None => return Ok(Value::Null),
686        };
687        let now = Utc::now().timestamp();
688        let diff = now - ts;
689
690        // Calculate components
691        let is_future = diff < 0;
692        let abs_diff = diff.abs();
693
694        let days = abs_diff / 86400;
695        let hours = (abs_diff % 86400) / 3600;
696        let minutes = (abs_diff % 3600) / 60;
697        let seconds = abs_diff % 60;
698
699        // Build human-readable string
700        let human = if days > 0 {
701            if days == 1 {
702                "1 day".to_string()
703            } else {
704                format!("{} days", days)
705            }
706        } else if hours > 0 {
707            if hours == 1 {
708                "1 hour".to_string()
709            } else {
710                format!("{} hours", hours)
711            }
712        } else if minutes > 0 {
713            if minutes == 1 {
714                "1 minute".to_string()
715            } else {
716                format!("{} minutes", minutes)
717            }
718        } else if seconds == 1 {
719            "1 second".to_string()
720        } else {
721            format!("{} seconds", seconds)
722        };
723
724        let human_with_direction = if is_future {
725            format!("in {}", human)
726        } else {
727            format!("{} ago", human)
728        };
729
730        // Build result object
731        let mut map = serde_json::Map::new();
732        map.insert(
733            "seconds".to_string(),
734            Value::Number(serde_json::Number::from(abs_diff)),
735        );
736        map.insert(
737            "minutes".to_string(),
738            Value::Number(serde_json::Number::from(abs_diff / 60)),
739        );
740        map.insert(
741            "hours".to_string(),
742            Value::Number(serde_json::Number::from(abs_diff / 3600)),
743        );
744        map.insert(
745            "days".to_string(),
746            Value::Number(serde_json::Number::from(abs_diff / 86400)),
747        );
748        map.insert("is_future".to_string(), Value::Bool(is_future));
749        map.insert("human".to_string(), Value::String(human_with_direction));
750
751        Ok(Value::Object(map))
752    }
753}
754
755// =============================================================================
756// start_of_day(datetime) -> string
757// =============================================================================
758
759defn!(StartOfDayFn, vec![arg!(any)], None);
760
761impl Function for StartOfDayFn {
762    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
763        self.signature.validate(args, ctx)?;
764
765        let ts = match parse_date_value(&args[0]) {
766            Some(t) => t,
767            None => return Ok(Value::Null),
768        };
769        let dt = DateTime::from_timestamp(ts, 0).unwrap();
770        let start = dt.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
771
772        Ok(Value::String(
773            start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
774        ))
775    }
776}
777
778// =============================================================================
779// end_of_day(datetime) -> string
780// =============================================================================
781
782defn!(EndOfDayFn, vec![arg!(any)], None);
783
784impl Function for EndOfDayFn {
785    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
786        self.signature.validate(args, ctx)?;
787
788        let ts = match parse_date_value(&args[0]) {
789            Some(t) => t,
790            None => return Ok(Value::Null),
791        };
792        let dt = DateTime::from_timestamp(ts, 0).unwrap();
793        let end = dt.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
794
795        Ok(Value::String(end.format("%Y-%m-%dT%H:%M:%SZ").to_string()))
796    }
797}
798
799// =============================================================================
800// start_of_week(datetime) -> string
801// =============================================================================
802
803defn!(StartOfWeekFn, vec![arg!(any)], None);
804
805impl Function for StartOfWeekFn {
806    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
807        self.signature.validate(args, ctx)?;
808
809        let ts = match parse_date_value(&args[0]) {
810            Some(t) => t,
811            None => return Ok(Value::Null),
812        };
813        let dt = DateTime::from_timestamp(ts, 0).unwrap();
814
815        // Calculate days since Monday (Monday = 0)
816        let days_since_monday = dt.weekday().num_days_from_monday();
817        let monday = dt.date_naive() - chrono::Duration::days(days_since_monday as i64);
818        let start = monday.and_hms_opt(0, 0, 0).unwrap().and_utc();
819
820        Ok(Value::String(
821            start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
822        ))
823    }
824}
825
826// =============================================================================
827// start_of_month(datetime) -> string
828// =============================================================================
829
830defn!(StartOfMonthFn, vec![arg!(any)], None);
831
832impl Function for StartOfMonthFn {
833    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
834        self.signature.validate(args, ctx)?;
835
836        let ts = match parse_date_value(&args[0]) {
837            Some(t) => t,
838            None => return Ok(Value::Null),
839        };
840        let dt = DateTime::from_timestamp(ts, 0).unwrap();
841
842        let start = dt
843            .date_naive()
844            .with_day(1)
845            .unwrap()
846            .and_hms_opt(0, 0, 0)
847            .unwrap()
848            .and_utc();
849
850        Ok(Value::String(
851            start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
852        ))
853    }
854}
855
856// =============================================================================
857// start_of_year(datetime) -> string
858// =============================================================================
859
860defn!(StartOfYearFn, vec![arg!(any)], None);
861
862impl Function for StartOfYearFn {
863    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
864        self.signature.validate(args, ctx)?;
865
866        let ts = match parse_date_value(&args[0]) {
867            Some(t) => t,
868            None => return Ok(Value::Null),
869        };
870        let dt = DateTime::from_timestamp(ts, 0).unwrap();
871
872        let start = chrono::NaiveDate::from_ymd_opt(dt.year(), 1, 1)
873            .unwrap()
874            .and_hms_opt(0, 0, 0)
875            .unwrap()
876            .and_utc();
877
878        Ok(Value::String(
879            start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
880        ))
881    }
882}
883
884// =============================================================================
885// is_same_day(datetime1, datetime2) -> boolean
886// =============================================================================
887
888defn!(IsSameDayFn, vec![arg!(any), arg!(any)], None);
889
890impl Function for IsSameDayFn {
891    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
892        self.signature.validate(args, ctx)?;
893
894        let ts1 = match parse_date_value(&args[0]) {
895            Some(t) => t,
896            None => return Ok(Value::Null),
897        };
898        let ts2 = match parse_date_value(&args[1]) {
899            Some(t) => t,
900            None => return Ok(Value::Null),
901        };
902
903        let dt1 = match DateTime::from_timestamp(ts1, 0) {
904            Some(dt) => dt,
905            None => return Ok(Value::Null),
906        };
907        let dt2 = match DateTime::from_timestamp(ts2, 0) {
908            Some(dt) => dt,
909            None => return Ok(Value::Null),
910        };
911
912        let same_day = dt1.date_naive() == dt2.date_naive();
913
914        Ok(Value::Bool(same_day))
915    }
916}
917
918// =============================================================================
919// parse_datetime(date_string) -> string
920// =============================================================================
921
922defn!(ParseDatetimeFn, vec![arg!(string)], None);
923
924impl Function for ParseDatetimeFn {
925    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
926        self.signature.validate(args, ctx)?;
927
928        let input = args[0].as_str().unwrap();
929
930        match dateparser::parse_with_timezone(input, &Utc) {
931            Ok(dt) => {
932                let iso = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
933                Ok(Value::String(iso))
934            }
935            Err(_) => Ok(Value::Null),
936        }
937    }
938}
939
940// =============================================================================
941// parse_natural_date(expression) -> string
942// =============================================================================
943
944defn!(ParseNaturalDateFn, vec![arg!(string)], None);
945
946impl Function for ParseNaturalDateFn {
947    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
948        self.signature.validate(args, ctx)?;
949
950        let input = args[0].as_str().unwrap();
951
952        match interim::parse_date_string(input, Utc::now(), interim::Dialect::Us) {
953            Ok(dt) => {
954                let iso = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
955                Ok(Value::String(iso))
956            }
957            Err(_) => Ok(Value::Null),
958        }
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use crate::Runtime;
965    use chrono::Utc;
966    use serde_json::json;
967
968    fn setup_runtime() -> Runtime {
969        Runtime::builder()
970            .with_standard()
971            .with_all_extensions()
972            .build()
973    }
974
975    #[test]
976    fn test_now() {
977        let runtime = setup_runtime();
978        let expr = runtime.compile("now()").unwrap();
979        let result = expr.search(&json!(null)).unwrap();
980        let ts = result.as_f64().unwrap();
981        // Should be a reasonable timestamp (after 2020)
982        assert!(ts > 1577836800.0);
983    }
984
985    #[test]
986    fn test_now_millis() {
987        let runtime = setup_runtime();
988        // now_millis is registered as epoch_ms in jpx-core
989        let expr = runtime.compile("epoch_ms()").unwrap();
990        let result = expr.search(&json!(null)).unwrap();
991        let ts = result.as_f64().unwrap();
992        // Should be a reasonable timestamp in millis (after 2020)
993        assert!(ts > 1577836800000.0);
994    }
995
996    #[test]
997    fn test_format_date() {
998        let runtime = setup_runtime();
999        // 1720000000 = 2024-07-03T10:26:40Z
1000        let expr = runtime
1001            .compile("format_date(`1720000000`, '%Y-%m-%d')")
1002            .unwrap();
1003        let result = expr.search(&json!(null)).unwrap();
1004        assert_eq!(result.as_str().unwrap(), "2024-07-03");
1005    }
1006
1007    #[test]
1008    fn test_format_date_with_time() {
1009        let runtime = setup_runtime();
1010        // Use a known timestamp and verify output format
1011        let expr = runtime
1012            .compile("format_date(`0`, '%Y-%m-%dT%H:%M:%S')")
1013            .unwrap();
1014        let result = expr.search(&json!(null)).unwrap();
1015        assert_eq!(result.as_str().unwrap(), "1970-01-01T00:00:00");
1016    }
1017
1018    #[test]
1019    fn test_parse_date_iso() {
1020        let runtime = setup_runtime();
1021        let data = json!("1970-01-01T00:00:00Z");
1022        let expr = runtime.compile("parse_date(@)").unwrap();
1023        let result = expr.search(&data).unwrap();
1024        assert_eq!(result.as_f64().unwrap(), 0.0);
1025    }
1026
1027    #[test]
1028    fn test_parse_date_date_only() {
1029        let runtime = setup_runtime();
1030        let data = json!("2024-07-03");
1031        let expr = runtime.compile("parse_date(@)").unwrap();
1032        let result = expr.search(&data).unwrap();
1033        // Should parse as midnight UTC
1034        assert_eq!(result.as_f64().unwrap(), 1719964800.0);
1035    }
1036
1037    #[test]
1038    fn test_parse_date_with_format() {
1039        let runtime = setup_runtime();
1040        // Use datetime format for custom parsing
1041        let data = json!("03/07/2024 00:00:00");
1042        let expr = runtime
1043            .compile("parse_date(@, '%d/%m/%Y %H:%M:%S')")
1044            .unwrap();
1045        let result = expr.search(&data).unwrap();
1046        // Should parse as 2024-07-03 midnight UTC
1047        assert_eq!(result.as_f64().unwrap(), 1719964800.0);
1048    }
1049
1050    #[test]
1051    fn test_parse_date_invalid() {
1052        let runtime = setup_runtime();
1053        let data = json!("not a date");
1054        let expr = runtime.compile("parse_date(@)").unwrap();
1055        let result = expr.search(&data).unwrap();
1056        assert!(result.is_null());
1057    }
1058
1059    #[test]
1060    fn test_date_add_days() {
1061        let runtime = setup_runtime();
1062        // Add 7 days to 1720000000
1063        let expr = runtime
1064            .compile("date_add(`1720000000`, `7`, 'days')")
1065            .unwrap();
1066        let result = expr.search(&json!(null)).unwrap();
1067        assert_eq!(result.as_f64().unwrap(), 1720604800.0);
1068    }
1069
1070    #[test]
1071    fn test_date_add_hours() {
1072        let runtime = setup_runtime();
1073        let expr = runtime
1074            .compile("date_add(`1720000000`, `24`, 'hours')")
1075            .unwrap();
1076        let result = expr.search(&json!(null)).unwrap();
1077        assert_eq!(result.as_f64().unwrap(), 1720086400.0);
1078    }
1079
1080    #[test]
1081    fn test_date_add_negative() {
1082        let runtime = setup_runtime();
1083        // Subtract 1 day
1084        let expr = runtime
1085            .compile("date_add(`1720000000`, `-1`, 'day')")
1086            .unwrap();
1087        let result = expr.search(&json!(null)).unwrap();
1088        assert_eq!(result.as_f64().unwrap(), 1719913600.0);
1089    }
1090
1091    #[test]
1092    fn test_date_diff_days() {
1093        let runtime = setup_runtime();
1094        // 7 days apart
1095        let expr = runtime
1096            .compile("date_diff(`1720604800`, `1720000000`, 'days')")
1097            .unwrap();
1098        let result = expr.search(&json!(null)).unwrap();
1099        assert_eq!(result.as_f64().unwrap(), 7.0);
1100    }
1101
1102    #[test]
1103    fn test_date_diff_hours() {
1104        let runtime = setup_runtime();
1105        let expr = runtime
1106            .compile("date_diff(`1720086400`, `1720000000`, 'hours')")
1107            .unwrap();
1108        let result = expr.search(&json!(null)).unwrap();
1109        assert_eq!(result.as_f64().unwrap(), 24.0);
1110    }
1111
1112    #[test]
1113    fn test_date_diff_negative() {
1114        let runtime = setup_runtime();
1115        // Earlier timestamp first
1116        let expr = runtime
1117            .compile("date_diff(`1720000000`, `1720604800`, 'days')")
1118            .unwrap();
1119        let result = expr.search(&json!(null)).unwrap();
1120        assert_eq!(result.as_f64().unwrap(), -7.0);
1121    }
1122
1123    #[test]
1124    fn test_date_add_invalid_unit() {
1125        let runtime = setup_runtime();
1126        let expr = runtime
1127            .compile("date_add(`1720000000`, `1`, 'invalid')")
1128            .unwrap();
1129        let result = expr.search(&json!(null));
1130        assert!(result.is_err());
1131    }
1132
1133    #[test]
1134    fn test_timezone_convert_ny_to_london() {
1135        let runtime = setup_runtime();
1136        let data = json!("2024-01-15T10:00:00");
1137        let expr = runtime
1138            .compile("timezone_convert(@, 'America/New_York', 'Europe/London')")
1139            .unwrap();
1140        let result = expr.search(&data).unwrap();
1141        // NY is UTC-5 in January, London is UTC+0, so 10:00 NY = 15:00 London
1142        assert_eq!(result.as_str().unwrap(), "2024-01-15T15:00:00");
1143    }
1144
1145    #[test]
1146    fn test_timezone_convert_tokyo_to_la() {
1147        let runtime = setup_runtime();
1148        let data = json!("2024-07-15T09:00:00");
1149        let expr = runtime
1150            .compile("timezone_convert(@, 'Asia/Tokyo', 'America/Los_Angeles')")
1151            .unwrap();
1152        let result = expr.search(&data).unwrap();
1153        // Tokyo is UTC+9, LA is UTC-7 in July (PDT), so 9:00 Tokyo = 17:00 previous day LA
1154        assert_eq!(result.as_str().unwrap(), "2024-07-14T17:00:00");
1155    }
1156
1157    #[test]
1158    fn test_timezone_convert_invalid_tz() {
1159        let runtime = setup_runtime();
1160        let data = json!("2024-01-15T10:00:00");
1161        let expr = runtime
1162            .compile("timezone_convert(@, 'Invalid/Zone', 'Europe/London')")
1163            .unwrap();
1164        let result = expr.search(&data);
1165        assert!(result.is_err());
1166    }
1167
1168    #[test]
1169    fn test_is_weekend_saturday() {
1170        let runtime = setup_runtime();
1171        // 2024-01-13 is a Saturday - timestamp: 1705104000
1172        let expr = runtime.compile("is_weekend(`1705104000`)").unwrap();
1173        let result = expr.search(&json!(null)).unwrap();
1174        assert!(result.as_bool().unwrap());
1175    }
1176
1177    #[test]
1178    fn test_is_weekend_sunday() {
1179        let runtime = setup_runtime();
1180        // 2024-01-14 is a Sunday - timestamp: 1705190400
1181        let expr = runtime.compile("is_weekend(`1705190400`)").unwrap();
1182        let result = expr.search(&json!(null)).unwrap();
1183        assert!(result.as_bool().unwrap());
1184    }
1185
1186    #[test]
1187    fn test_is_weekend_monday() {
1188        let runtime = setup_runtime();
1189        // 2024-01-15 is a Monday - timestamp: 1705276800
1190        let expr = runtime.compile("is_weekend(`1705276800`)").unwrap();
1191        let result = expr.search(&json!(null)).unwrap();
1192        assert!(!result.as_bool().unwrap());
1193    }
1194
1195    #[test]
1196    fn test_is_weekday_monday() {
1197        let runtime = setup_runtime();
1198        // 2024-01-15 is a Monday - timestamp: 1705276800
1199        let expr = runtime.compile("is_weekday(`1705276800`)").unwrap();
1200        let result = expr.search(&json!(null)).unwrap();
1201        assert!(result.as_bool().unwrap());
1202    }
1203
1204    #[test]
1205    fn test_is_weekday_saturday() {
1206        let runtime = setup_runtime();
1207        // 2024-01-13 is a Saturday - timestamp: 1705104000
1208        let expr = runtime.compile("is_weekday(`1705104000`)").unwrap();
1209        let result = expr.search(&json!(null)).unwrap();
1210        assert!(!result.as_bool().unwrap());
1211    }
1212
1213    #[test]
1214    fn test_business_days_between() {
1215        let runtime = setup_runtime();
1216        // 2024-01-01 (Mon) to 2024-01-15 (Mon) - 10 business days
1217        // ts1: 1704067200 (2024-01-01)
1218        // ts2: 1705276800 (2024-01-15)
1219        let expr = runtime
1220            .compile("business_days_between(`1704067200`, `1705276800`)")
1221            .unwrap();
1222        let result = expr.search(&json!(null)).unwrap();
1223        assert_eq!(result.as_f64().unwrap(), 10.0);
1224    }
1225
1226    #[test]
1227    fn test_business_days_between_reversed() {
1228        let runtime = setup_runtime();
1229        // Same dates but reversed - should be negative
1230        let expr = runtime
1231            .compile("business_days_between(`1705276800`, `1704067200`)")
1232            .unwrap();
1233        let result = expr.search(&json!(null)).unwrap();
1234        assert_eq!(result.as_f64().unwrap(), -10.0);
1235    }
1236
1237    #[test]
1238    fn test_business_days_between_same_day() {
1239        let runtime = setup_runtime();
1240        let expr = runtime
1241            .compile("business_days_between(`1705276800`, `1705276800`)")
1242            .unwrap();
1243        let result = expr.search(&json!(null)).unwrap();
1244        assert_eq!(result.as_f64().unwrap(), 0.0);
1245    }
1246
1247    #[test]
1248    fn test_quarter_q1() {
1249        let runtime = setup_runtime();
1250        // January 15, 2024 - timestamp: 1705276800
1251        let expr = runtime.compile("quarter(`1705276800`)").unwrap();
1252        let result = expr.search(&json!(null)).unwrap();
1253        assert_eq!(result.as_f64().unwrap(), 1.0);
1254    }
1255
1256    #[test]
1257    fn test_quarter_q2() {
1258        let runtime = setup_runtime();
1259        // April 15, 2024 - timestamp: 1713139200
1260        let expr = runtime.compile("quarter(`1713139200`)").unwrap();
1261        let result = expr.search(&json!(null)).unwrap();
1262        assert_eq!(result.as_f64().unwrap(), 2.0);
1263    }
1264
1265    #[test]
1266    fn test_quarter_q3() {
1267        let runtime = setup_runtime();
1268        // July 15, 2024 - timestamp: 1721001600
1269        let expr = runtime.compile("quarter(`1721001600`)").unwrap();
1270        let result = expr.search(&json!(null)).unwrap();
1271        assert_eq!(result.as_f64().unwrap(), 3.0);
1272    }
1273
1274    #[test]
1275    fn test_quarter_q4() {
1276        let runtime = setup_runtime();
1277        // October 15, 2024 - timestamp: 1728950400
1278        let expr = runtime.compile("quarter(`1728950400`)").unwrap();
1279        let result = expr.search(&json!(null)).unwrap();
1280        assert_eq!(result.as_f64().unwrap(), 4.0);
1281    }
1282
1283    #[test]
1284    fn test_relative_time_past() {
1285        let runtime = setup_runtime();
1286        // Use a timestamp far in the past (1 year ago)
1287        let one_year_ago = Utc::now().timestamp() - 31536000;
1288        let expr_str = format!("relative_time(`{}`)", one_year_ago);
1289        let expr = runtime.compile(&expr_str).unwrap();
1290        let result = expr.search(&json!(null)).unwrap();
1291        assert!(result.as_str().unwrap().contains("ago"));
1292    }
1293
1294    #[test]
1295    fn test_relative_time_future() {
1296        let runtime = setup_runtime();
1297        // Use a timestamp in the future (1 day from now)
1298        let one_day_future = Utc::now().timestamp() + 86400;
1299        let expr_str = format!("relative_time(`{}`)", one_day_future);
1300        let expr = runtime.compile(&expr_str).unwrap();
1301        let result = expr.search(&json!(null)).unwrap();
1302        assert!(result.as_str().unwrap().starts_with("in "));
1303    }
1304
1305    // Tests for is_after
1306
1307    #[test]
1308    fn test_is_after_with_timestamps() {
1309        let runtime = setup_runtime();
1310        // 1720000000 is after 1710000000
1311        let expr = runtime
1312            .compile("is_after(`1720000000`, `1710000000`)")
1313            .unwrap();
1314        let result = expr.search(&json!(null)).unwrap();
1315        assert!(result.as_bool().unwrap());
1316    }
1317
1318    #[test]
1319    fn test_is_after_with_timestamps_false() {
1320        let runtime = setup_runtime();
1321        // 1710000000 is not after 1720000000
1322        let expr = runtime
1323            .compile("is_after(`1710000000`, `1720000000`)")
1324            .unwrap();
1325        let result = expr.search(&json!(null)).unwrap();
1326        assert!(!result.as_bool().unwrap());
1327    }
1328
1329    #[test]
1330    fn test_is_after_with_date_strings() {
1331        let runtime = setup_runtime();
1332        let data = json!({"d1": "2024-07-15", "d2": "2024-01-01"});
1333        let expr = runtime.compile("is_after(d1, d2)").unwrap();
1334        let result = expr.search(&data).unwrap();
1335        assert!(result.as_bool().unwrap());
1336    }
1337
1338    #[test]
1339    fn test_is_after_with_iso_strings() {
1340        let runtime = setup_runtime();
1341        let data = json!({"d1": "2024-07-15T10:30:00Z", "d2": "2024-07-15T08:00:00Z"});
1342        let expr = runtime.compile("is_after(d1, d2)").unwrap();
1343        let result = expr.search(&data).unwrap();
1344        assert!(result.as_bool().unwrap());
1345    }
1346
1347    #[test]
1348    fn test_is_after_mixed_types() {
1349        let runtime = setup_runtime();
1350        // 1720000000 = 2024-07-03T10:26:40Z, which is after 2024-01-01
1351        let data = json!({"d": "2024-01-01"});
1352        let expr = runtime.compile("is_after(`1720000000`, d)").unwrap();
1353        let result = expr.search(&data).unwrap();
1354        assert!(result.as_bool().unwrap());
1355    }
1356
1357    #[test]
1358    fn test_is_after_equal_dates() {
1359        let runtime = setup_runtime();
1360        let expr = runtime
1361            .compile("is_after(`1720000000`, `1720000000`)")
1362            .unwrap();
1363        let result = expr.search(&json!(null)).unwrap();
1364        assert!(!result.as_bool().unwrap());
1365    }
1366
1367    #[test]
1368    fn test_is_after_invalid_date() {
1369        let runtime = setup_runtime();
1370        let data = json!({"d": "not-a-date"});
1371        let expr = runtime.compile("is_after(d, `1720000000`)").unwrap();
1372        let result = expr.search(&data).unwrap();
1373        assert!(result.is_null());
1374    }
1375
1376    // Tests for is_before
1377
1378    #[test]
1379    fn test_is_before_with_timestamps() {
1380        let runtime = setup_runtime();
1381        // 1710000000 is before 1720000000
1382        let expr = runtime
1383            .compile("is_before(`1710000000`, `1720000000`)")
1384            .unwrap();
1385        let result = expr.search(&json!(null)).unwrap();
1386        assert!(result.as_bool().unwrap());
1387    }
1388
1389    #[test]
1390    fn test_is_before_with_timestamps_false() {
1391        let runtime = setup_runtime();
1392        // 1720000000 is not before 1710000000
1393        let expr = runtime
1394            .compile("is_before(`1720000000`, `1710000000`)")
1395            .unwrap();
1396        let result = expr.search(&json!(null)).unwrap();
1397        assert!(!result.as_bool().unwrap());
1398    }
1399
1400    #[test]
1401    fn test_is_before_with_date_strings() {
1402        let runtime = setup_runtime();
1403        let data = json!({"d1": "2024-01-01", "d2": "2024-07-15"});
1404        let expr = runtime.compile("is_before(d1, d2)").unwrap();
1405        let result = expr.search(&data).unwrap();
1406        assert!(result.as_bool().unwrap());
1407    }
1408
1409    #[test]
1410    fn test_is_before_equal_dates() {
1411        let runtime = setup_runtime();
1412        let expr = runtime
1413            .compile("is_before(`1720000000`, `1720000000`)")
1414            .unwrap();
1415        let result = expr.search(&json!(null)).unwrap();
1416        assert!(!result.as_bool().unwrap());
1417    }
1418
1419    // Tests for is_between
1420
1421    #[test]
1422    fn test_is_between_with_timestamps_true() {
1423        let runtime = setup_runtime();
1424        // 1715000000 is between 1710000000 and 1720000000
1425        let expr = runtime
1426            .compile("is_between(`1715000000`, `1710000000`, `1720000000`)")
1427            .unwrap();
1428        let result = expr.search(&json!(null)).unwrap();
1429        assert!(result.as_bool().unwrap());
1430    }
1431
1432    #[test]
1433    fn test_is_between_with_timestamps_false() {
1434        let runtime = setup_runtime();
1435        // 1700000000 is not between 1710000000 and 1720000000
1436        let expr = runtime
1437            .compile("is_between(`1700000000`, `1710000000`, `1720000000`)")
1438            .unwrap();
1439        let result = expr.search(&json!(null)).unwrap();
1440        assert!(!result.as_bool().unwrap());
1441    }
1442
1443    #[test]
1444    fn test_is_between_with_date_strings() {
1445        let runtime = setup_runtime();
1446        let data = json!({"d": "2024-06-15", "start": "2024-01-01", "end": "2024-12-31"});
1447        let expr = runtime.compile("is_between(d, start, end)").unwrap();
1448        let result = expr.search(&data).unwrap();
1449        assert!(result.as_bool().unwrap());
1450    }
1451
1452    #[test]
1453    fn test_is_between_inclusive_start() {
1454        let runtime = setup_runtime();
1455        // Date equals start - should be true (inclusive)
1456        let expr = runtime
1457            .compile("is_between(`1710000000`, `1710000000`, `1720000000`)")
1458            .unwrap();
1459        let result = expr.search(&json!(null)).unwrap();
1460        assert!(result.as_bool().unwrap());
1461    }
1462
1463    #[test]
1464    fn test_is_between_inclusive_end() {
1465        let runtime = setup_runtime();
1466        // Date equals end - should be true (inclusive)
1467        let expr = runtime
1468            .compile("is_between(`1720000000`, `1710000000`, `1720000000`)")
1469            .unwrap();
1470        let result = expr.search(&json!(null)).unwrap();
1471        assert!(result.as_bool().unwrap());
1472    }
1473
1474    #[test]
1475    fn test_is_between_outside_range() {
1476        let runtime = setup_runtime();
1477        // Date is after end
1478        let expr = runtime
1479            .compile("is_between(`1730000000`, `1710000000`, `1720000000`)")
1480            .unwrap();
1481        let result = expr.search(&json!(null)).unwrap();
1482        assert!(!result.as_bool().unwrap());
1483    }
1484
1485    // Tests for time_ago
1486
1487    #[test]
1488    fn test_time_ago_with_timestamp() {
1489        let runtime = setup_runtime();
1490        // Use a timestamp 1 hour ago
1491        let one_hour_ago = Utc::now().timestamp() - 3600;
1492        let expr_str = format!("time_ago(`{}`)", one_hour_ago);
1493        let expr = runtime.compile(&expr_str).unwrap();
1494        let result = expr.search(&json!(null)).unwrap();
1495        assert_eq!(result.as_str().unwrap(), "1 hour ago");
1496    }
1497
1498    #[test]
1499    fn test_time_ago_with_date_string() {
1500        let runtime = setup_runtime();
1501        // Use a date far in the past (over 1 year)
1502        let data = json!("2020-01-01");
1503        let expr = runtime.compile("time_ago(@)").unwrap();
1504        let result = expr.search(&data).unwrap();
1505        assert!(result.as_str().unwrap().contains("years ago"));
1506    }
1507
1508    #[test]
1509    fn test_time_ago_plural() {
1510        let runtime = setup_runtime();
1511        // Use a timestamp 2 days ago
1512        let two_days_ago = Utc::now().timestamp() - 172800;
1513        let expr_str = format!("time_ago(`{}`)", two_days_ago);
1514        let expr = runtime.compile(&expr_str).unwrap();
1515        let result = expr.search(&json!(null)).unwrap();
1516        assert_eq!(result.as_str().unwrap(), "2 days ago");
1517    }
1518
1519    #[test]
1520    fn test_time_ago_singular() {
1521        let runtime = setup_runtime();
1522        // Use a timestamp 1 day ago
1523        let one_day_ago = Utc::now().timestamp() - 86400;
1524        let expr_str = format!("time_ago(`{}`)", one_day_ago);
1525        let expr = runtime.compile(&expr_str).unwrap();
1526        let result = expr.search(&json!(null)).unwrap();
1527        assert_eq!(result.as_str().unwrap(), "1 day ago");
1528    }
1529
1530    #[test]
1531    fn test_time_ago_future() {
1532        let runtime = setup_runtime();
1533        // Future dates show "in X"
1534        let one_day_future = Utc::now().timestamp() + 86400;
1535        let expr_str = format!("time_ago(`{}`)", one_day_future);
1536        let expr = runtime.compile(&expr_str).unwrap();
1537        let result = expr.search(&json!(null)).unwrap();
1538        assert!(result.as_str().unwrap().starts_with("in "));
1539    }
1540
1541    #[test]
1542    fn test_time_ago_invalid_date() {
1543        let runtime = setup_runtime();
1544        let data = json!("not-a-date");
1545        let expr = runtime.compile("time_ago(@)").unwrap();
1546        let result = expr.search(&data).unwrap();
1547        assert!(result.is_null());
1548    }
1549
1550    #[test]
1551    fn test_from_epoch() {
1552        let runtime = setup_runtime();
1553        // 2023-12-13T00:00:00Z
1554        let expr = runtime.compile("from_epoch(`1702425600`)").unwrap();
1555        let result = expr.search(&json!(null)).unwrap();
1556        assert_eq!(result.as_str().unwrap(), "2023-12-13T00:00:00Z");
1557    }
1558
1559    #[test]
1560    fn test_from_epoch_ms() {
1561        let runtime = setup_runtime();
1562        // 2023-12-13T00:00:00.500Z
1563        let expr = runtime.compile("from_epoch_ms(`1702425600500`)").unwrap();
1564        let result = expr.search(&json!(null)).unwrap();
1565        assert_eq!(result.as_str().unwrap(), "2023-12-13T00:00:00.500Z");
1566    }
1567
1568    #[test]
1569    fn test_to_epoch() {
1570        let runtime = setup_runtime();
1571        let data = json!("2023-12-13T00:00:00Z");
1572        let expr = runtime.compile("to_epoch(@)").unwrap();
1573        let result = expr.search(&data).unwrap();
1574        assert_eq!(result.as_f64().unwrap() as i64, 1702425600);
1575    }
1576
1577    #[test]
1578    fn test_to_epoch_ms() {
1579        let runtime = setup_runtime();
1580        let data = json!("2023-12-13T00:00:00Z");
1581        let expr = runtime.compile("to_epoch_ms(@)").unwrap();
1582        let result = expr.search(&data).unwrap();
1583        assert_eq!(result.as_f64().unwrap() as i64, 1702425600000);
1584    }
1585
1586    #[test]
1587    fn test_to_epoch_from_number() {
1588        let runtime = setup_runtime();
1589        // Pass through if already a number
1590        let expr = runtime.compile("to_epoch(`1702425600`)").unwrap();
1591        let result = expr.search(&json!(null)).unwrap();
1592        assert_eq!(result.as_f64().unwrap() as i64, 1702425600);
1593    }
1594
1595    #[test]
1596    fn test_duration_since() {
1597        let runtime = setup_runtime();
1598        // Use a timestamp 2 days ago
1599        let two_days_ago = Utc::now().timestamp() - 172800;
1600        let expr_str = format!("duration_since(`{}`)", two_days_ago);
1601        let expr = runtime.compile(&expr_str).unwrap();
1602        let result = expr.search(&json!(null)).unwrap();
1603        let obj = result.as_object().unwrap();
1604        assert_eq!(obj.get("days").unwrap().as_i64().unwrap(), 2);
1605        assert!(!obj.get("is_future").unwrap().as_bool().unwrap());
1606        assert!(
1607            obj.get("human")
1608                .unwrap()
1609                .as_str()
1610                .unwrap()
1611                .contains("2 days ago")
1612        );
1613    }
1614
1615    #[test]
1616    fn test_start_of_day() {
1617        let runtime = setup_runtime();
1618        let data = json!("2023-12-13T15:30:45Z");
1619        let expr = runtime.compile("start_of_day(@)").unwrap();
1620        let result = expr.search(&data).unwrap();
1621        assert_eq!(result.as_str().unwrap(), "2023-12-13T00:00:00Z");
1622    }
1623
1624    #[test]
1625    fn test_end_of_day() {
1626        let runtime = setup_runtime();
1627        let data = json!("2023-12-13T15:30:45Z");
1628        let expr = runtime.compile("end_of_day(@)").unwrap();
1629        let result = expr.search(&data).unwrap();
1630        assert_eq!(result.as_str().unwrap(), "2023-12-13T23:59:59Z");
1631    }
1632
1633    #[test]
1634    fn test_start_of_week() {
1635        let runtime = setup_runtime();
1636        // 2023-12-13 is a Wednesday
1637        let data = json!("2023-12-13T15:30:45Z");
1638        let expr = runtime.compile("start_of_week(@)").unwrap();
1639        let result = expr.search(&data).unwrap();
1640        // Monday is 2023-12-11
1641        assert_eq!(result.as_str().unwrap(), "2023-12-11T00:00:00Z");
1642    }
1643
1644    #[test]
1645    fn test_start_of_month() {
1646        let runtime = setup_runtime();
1647        let data = json!("2023-12-13T15:30:45Z");
1648        let expr = runtime.compile("start_of_month(@)").unwrap();
1649        let result = expr.search(&data).unwrap();
1650        assert_eq!(result.as_str().unwrap(), "2023-12-01T00:00:00Z");
1651    }
1652
1653    #[test]
1654    fn test_start_of_year() {
1655        let runtime = setup_runtime();
1656        let data = json!("2023-12-13T15:30:45Z");
1657        let expr = runtime.compile("start_of_year(@)").unwrap();
1658        let result = expr.search(&data).unwrap();
1659        assert_eq!(result.as_str().unwrap(), "2023-01-01T00:00:00Z");
1660    }
1661
1662    #[test]
1663    fn test_is_same_day_true() {
1664        let runtime = setup_runtime();
1665        let expr = runtime
1666            .compile(r#"is_same_day(`"2023-12-13T10:00:00Z"`, `"2023-12-13T23:00:00Z"`)"#)
1667            .unwrap();
1668        let result = expr.search(&json!(null)).unwrap();
1669        assert!(result.as_bool().unwrap());
1670    }
1671
1672    #[test]
1673    fn test_is_same_day_false() {
1674        let runtime = setup_runtime();
1675        let expr = runtime
1676            .compile(r#"is_same_day(`"2023-12-13T10:00:00Z"`, `"2023-12-14T10:00:00Z"`)"#)
1677            .unwrap();
1678        let result = expr.search(&json!(null)).unwrap();
1679        assert!(!result.as_bool().unwrap());
1680    }
1681
1682    #[test]
1683    fn test_epoch_ms_alias() {
1684        let runtime = setup_runtime();
1685        // epoch_ms should work like now_millis
1686        let expr = runtime.compile("epoch_ms()").unwrap();
1687        let result = expr.search(&json!(null)).unwrap();
1688        let ts = result.as_f64().unwrap() as i64;
1689        // Should be a reasonable current timestamp in milliseconds
1690        assert!(ts > 1700000000000);
1691    }
1692
1693    // =========================================================================
1694    // parse_datetime tests (structured formats)
1695    // =========================================================================
1696
1697    #[test]
1698    fn test_parse_datetime_iso_date() {
1699        let runtime = setup_runtime();
1700        let data = json!("2024-01-15");
1701        let expr = runtime.compile("parse_datetime(@)").unwrap();
1702        let result = expr.search(&data).unwrap();
1703        let s = result.as_str().unwrap();
1704        assert!(s.contains("2024-01-15"), "Expected 2024-01-15 in {}", s);
1705        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1706    }
1707
1708    #[test]
1709    fn test_parse_datetime_iso_datetime() {
1710        let runtime = setup_runtime();
1711        let data = json!("2024-01-15T10:30:00Z");
1712        let expr = runtime.compile("parse_datetime(@)").unwrap();
1713        let result = expr.search(&data).unwrap();
1714        let s = result.as_str().unwrap();
1715        assert_eq!(s, "2024-01-15T10:30:00Z");
1716    }
1717
1718    #[test]
1719    fn test_parse_datetime_iso_with_offset() {
1720        let runtime = setup_runtime();
1721        let data = json!("2024-01-15T10:30:00+05:00");
1722        let expr = runtime.compile("parse_datetime(@)").unwrap();
1723        let result = expr.search(&data).unwrap();
1724        let s = result.as_str().unwrap();
1725        // Should convert to UTC (10:30 +05:00 = 05:30 UTC)
1726        assert!(s.contains("2024-01-15"), "Expected 2024-01-15 in {}", s);
1727        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1728    }
1729
1730    #[test]
1731    fn test_parse_datetime_human_month_day_year() {
1732        let runtime = setup_runtime();
1733        let data = json!("Jan 15, 2024");
1734        let expr = runtime.compile("parse_datetime(@)").unwrap();
1735        let result = expr.search(&data).unwrap();
1736        let s = result.as_str().unwrap();
1737        assert!(s.contains("2024-01-15"), "Expected 2024-01-15 in {}", s);
1738    }
1739
1740    #[test]
1741    fn test_parse_datetime_human_full_month() {
1742        let runtime = setup_runtime();
1743        let data = json!("January 15, 2024");
1744        let expr = runtime.compile("parse_datetime(@)").unwrap();
1745        let result = expr.search(&data).unwrap();
1746        let s = result.as_str().unwrap();
1747        assert!(s.contains("2024-01-15"), "Expected 2024-01-15 in {}", s);
1748    }
1749
1750    #[test]
1751    fn test_parse_datetime_us_format() {
1752        let runtime = setup_runtime();
1753        let data = json!("01/15/2024");
1754        let expr = runtime.compile("parse_datetime(@)").unwrap();
1755        let result = expr.search(&data).unwrap();
1756        let s = result.as_str().unwrap();
1757        assert!(s.contains("2024"), "Expected year 2024 in {}", s);
1758    }
1759
1760    #[test]
1761    fn test_parse_datetime_rfc2822() {
1762        let runtime = setup_runtime();
1763        let data = json!("Mon, 15 Jan 2024 10:30:00 +0000");
1764        let expr = runtime.compile("parse_datetime(@)").unwrap();
1765        let result = expr.search(&data).unwrap();
1766        let s = result.as_str().unwrap();
1767        assert!(s.contains("2024-01-15"), "Expected 2024-01-15 in {}", s);
1768    }
1769
1770    #[test]
1771    fn test_parse_datetime_unix_timestamp() {
1772        let runtime = setup_runtime();
1773        // 1705363200 = 2024-01-16T00:00:00Z
1774        let data = json!("1705363200");
1775        let expr = runtime.compile("parse_datetime(@)").unwrap();
1776        let result = expr.search(&data).unwrap();
1777        let s = result.as_str().unwrap();
1778        assert!(s.contains("2024-01-16"), "Expected 2024-01-16 in {}", s);
1779    }
1780
1781    #[test]
1782    fn test_parse_datetime_invalid() {
1783        let runtime = setup_runtime();
1784        let data = json!("not a date at all xyz123");
1785        let expr = runtime.compile("parse_datetime(@)").unwrap();
1786        let result = expr.search(&data).unwrap();
1787        assert!(result.is_null(), "Invalid date should return null");
1788    }
1789
1790    #[test]
1791    fn test_parse_datetime_empty() {
1792        let runtime = setup_runtime();
1793        let data = json!("");
1794        let expr = runtime.compile("parse_datetime(@)").unwrap();
1795        let result = expr.search(&data).unwrap();
1796        assert!(result.is_null(), "Empty string should return null");
1797    }
1798
1799    // =========================================================================
1800    // parse_natural_date tests (natural language, relative to now)
1801    // =========================================================================
1802
1803    #[test]
1804    fn test_parse_natural_date_yesterday() {
1805        let runtime = setup_runtime();
1806        let data = json!("yesterday");
1807        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1808        let result = expr.search(&data).unwrap();
1809        let s = result.as_str().unwrap();
1810        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1811        assert!(
1812            s.contains('T'),
1813            "Expected ISO format with T separator in {}",
1814            s
1815        );
1816    }
1817
1818    #[test]
1819    fn test_parse_natural_date_tomorrow() {
1820        let runtime = setup_runtime();
1821        let data = json!("tomorrow");
1822        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1823        let result = expr.search(&data).unwrap();
1824        let s = result.as_str().unwrap();
1825        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1826    }
1827
1828    #[test]
1829    fn test_parse_natural_date_days_ago() {
1830        let runtime = setup_runtime();
1831        let data = json!("3 days ago");
1832        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1833        let result = expr.search(&data).unwrap();
1834        let s = result.as_str().unwrap();
1835        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1836    }
1837
1838    #[test]
1839    fn test_parse_natural_date_weeks_ago() {
1840        let runtime = setup_runtime();
1841        let data = json!("2 weeks ago");
1842        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1843        let result = expr.search(&data).unwrap();
1844        let s = result.as_str().unwrap();
1845        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1846    }
1847
1848    #[test]
1849    fn test_parse_natural_date_next_weekday() {
1850        let runtime = setup_runtime();
1851        let data = json!("next friday");
1852        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1853        let result = expr.search(&data).unwrap();
1854        let s = result.as_str().unwrap();
1855        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1856    }
1857
1858    #[test]
1859    fn test_parse_natural_date_last_weekday() {
1860        let runtime = setup_runtime();
1861        let data = json!("last monday");
1862        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1863        let result = expr.search(&data).unwrap();
1864        let s = result.as_str().unwrap();
1865        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1866    }
1867
1868    #[test]
1869    fn test_parse_natural_date_hours() {
1870        let runtime = setup_runtime();
1871        let data = json!("5 hours ago");
1872        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1873        let result = expr.search(&data).unwrap();
1874        let s = result.as_str().unwrap();
1875        assert!(s.ends_with('Z'), "Expected UTC timezone marker in {}", s);
1876    }
1877
1878    #[test]
1879    fn test_parse_natural_date_invalid() {
1880        let runtime = setup_runtime();
1881        let data = json!("not a natural date expression");
1882        let expr = runtime.compile("parse_natural_date(@)").unwrap();
1883        let result = expr.search(&data).unwrap();
1884        assert!(result.is_null(), "Invalid expression should return null");
1885    }
1886}