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