1use oqx::{OqxError, Value};
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct DateValue {
42 pub ms: i64,
43 pub offset: i64,
44 pub date_only: bool,
45 pub fraction: bool,
46}
47
48const DAY_MS: i64 = 86_400_000;
49const MINUTE_MS: i64 = 60_000;
50
51fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
55 let y = if month <= 2 { year - 1 } else { year };
56 let era = if y >= 0 { y } else { y - 399 } / 400;
57 let yoe = y - era * 400;
58 let mp = (month + 9) % 12;
59 let doy = (153 * mp + 2) / 5 + day - 1;
60 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
61 era * 146_097 + doe - 719_468
62}
63
64fn civil_from_days(days: i64) -> (i64, i64, i64) {
67 let z = days + 719_468;
68 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
69 let doe = z - era * 146_097;
70 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
71 let y = yoe + era * 400;
72 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
73 let mp = (5 * doy + 2) / 153;
74 let d = doy - (153 * mp + 2) / 5 + 1;
75 let m = if mp < 10 { mp + 3 } else { mp - 9 };
76 (if m <= 2 { y + 1 } else { y }, m, d)
77}
78
79fn utc_ms(
81 year: i64,
82 month: i64,
83 day: i64,
84 hour: i64,
85 minute: i64,
86 second: i64,
87 millis: i64,
88) -> i64 {
89 days_from_civil(year, month, day) * DAY_MS
90 + hour * 3_600_000
91 + minute * MINUTE_MS
92 + second * 1000
93 + millis
94}
95
96fn dow_of_days(days: i64) -> i64 {
99 (days + 4).rem_euclid(7)
100}
101
102fn is_leap(year: i64) -> bool {
103 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
104}
105
106fn days_in_month(year: i64, month: i64) -> i64 {
107 match month {
108 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
109 4 | 6 | 9 | 11 => 30,
110 2 => {
111 if is_leap(year) {
112 29
113 } else {
114 28
115 }
116 }
117 _ => 30,
118 }
119}
120
121fn digits(s: &str, n: usize) -> Option<(i64, &str)> {
123 let b = s.as_bytes();
124 if b.len() < n || !b[..n].iter().all(u8::is_ascii_digit) {
125 return None;
126 }
127 Some((s[..n].parse().ok()?, &s[n..]))
129}
130
131#[must_use]
138pub fn parse_date(x: &Value) -> Option<DateValue> {
139 let Value::Str(s) = x else { return None };
140 let s = s.trim();
141 let (year, s) = digits(s, 4)?;
142 let s = s.strip_prefix('-')?;
143 let (month, s) = digits(s, 2)?;
144 let s = s.strip_prefix('-')?;
145 let (day, s) = digits(s, 2)?;
146 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
147 return None;
148 }
149 if s.is_empty() {
150 return Some(DateValue {
151 ms: utc_ms(year, month, day, 0, 0, 0, 0),
152 offset: 0,
153 date_only: true,
154 fraction: false,
155 });
156 }
157 let s = s
158 .strip_prefix('T')
159 .or_else(|| s.strip_prefix('t'))
160 .or_else(|| s.strip_prefix(' '))?;
161 let (hour, s) = digits(s, 2)?;
162 let s = s.strip_prefix(':')?;
163 let (minute, s) = digits(s, 2)?;
164 let (second, fraction, millis, s) = match s.strip_prefix(':') {
165 Some(rest) => {
166 let (second, rest) = digits(rest, 2)?;
167 match rest.strip_prefix('.').or_else(|| rest.strip_prefix(',')) {
168 Some(after) => {
169 let n = after.bytes().take_while(u8::is_ascii_digit).count();
170 if !(1..=9).contains(&n) {
171 return None;
172 }
173 let frac = &after[..n];
174 let mut three = String::from(&frac[..n.min(3)]);
175 while three.len() < 3 {
176 three.push('0');
177 }
178 (second, true, three.parse::<i64>().ok()?, &after[n..])
179 }
180 None => (second, false, 0, rest),
181 }
182 }
183 None => (0, false, 0, s),
184 };
185 if hour > 23 || minute > 59 || second > 59 {
186 return None;
187 }
188 let mut offset = 0;
189 let s = if let Some(rest) = s.strip_prefix('Z').or_else(|| s.strip_prefix('z')) {
190 rest
191 } else if let Some(sign) = s.chars().next().filter(|c| *c == '+' || *c == '-') {
192 let rest = &s[1..];
193 let (oh, rest) = digits(rest, 2)?;
194 let rest = rest.strip_prefix(':').unwrap_or(rest);
195 let (om, rest) = digits(rest, 2)?;
196 if oh > 23 || om > 59 {
197 return None;
198 }
199 offset = (oh * 60 + om) * if sign == '-' { -1 } else { 1 };
200 rest
201 } else {
202 s
203 };
204 if !s.is_empty() {
205 return None;
206 }
207 let local = utc_ms(year, month, day, hour, minute, second, millis);
208 Some(DateValue {
209 ms: local - offset * MINUTE_MS,
210 offset,
211 date_only: false,
212 fraction,
213 })
214}
215
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219pub struct Fields {
220 pub year: i64,
221 pub month: i64,
222 pub day: i64,
223 pub hour: i64,
224 pub minute: i64,
225 pub second: i64,
226 pub millis: i64,
227 pub weekday: i64,
228}
229
230#[must_use]
232pub fn fields_of(d: &DateValue) -> Fields {
233 let t = d.ms + d.offset * MINUTE_MS;
234 let days = t.div_euclid(DAY_MS);
235 let rem = t.rem_euclid(DAY_MS);
236 let (year, month, day) = civil_from_days(days);
237 Fields {
238 year,
239 month,
240 day,
241 hour: rem / 3_600_000,
242 minute: rem % 3_600_000 / MINUTE_MS,
243 second: rem % MINUTE_MS / 1000,
244 millis: rem % 1000,
245 weekday: (dow_of_days(days) + 6) % 7 + 1,
246 }
247}
248
249#[must_use]
254pub fn render_date(d: &DateValue) -> String {
255 let f = fields_of(d);
256 let ymd = format!("{:04}-{:02}-{:02}", f.year, f.month, f.day);
257 if d.date_only {
258 return ymd;
259 }
260 let mut s = format!("{ymd}T{:02}:{:02}:{:02}", f.hour, f.minute, f.second);
261 if d.fraction || f.millis != 0 {
262 s.push_str(&format!(".{:03}", f.millis));
263 }
264 if d.offset == 0 {
265 s.push('Z');
266 } else {
267 let a = d.offset.abs();
268 s.push_str(&format!(
269 "{}{:02}:{:02}",
270 if d.offset < 0 { '-' } else { '+' },
271 a / 60,
272 a % 60
273 ));
274 }
275 s
276}
277
278#[must_use]
280pub fn canonical_date(x: &Value) -> Option<String> {
281 parse_date(x).as_ref().map(render_date)
282}
283
284pub const NAMED_POINTS: [&str; 10] = [
288 "now",
289 "today",
290 "yesterday",
291 "tomorrow",
292 "sow",
293 "eow",
294 "som",
295 "eom",
296 "soy",
297 "eoy",
298];
299
300const fn date_only(ms: i64) -> DateValue {
301 DateValue {
302 ms,
303 offset: 0,
304 date_only: true,
305 fraction: false,
306 }
307}
308
309#[must_use]
315pub fn named_point(name: &str, now_ms: i64) -> Option<DateValue> {
316 let days = now_ms.div_euclid(DAY_MS);
317 let (y, mo, _) = civil_from_days(days);
318 let today = days * DAY_MS;
319 let dow = dow_of_days(days);
320 Some(match name {
321 "now" => DateValue {
322 ms: now_ms,
323 offset: 0,
324 date_only: false,
325 fraction: true,
326 },
327 "today" => date_only(today),
328 "yesterday" => date_only(today - DAY_MS),
329 "tomorrow" => date_only(today + DAY_MS),
330 "sow" => date_only(today - ((dow + 6) % 7) * DAY_MS),
331 "eow" => date_only(today + (6 - (dow + 6) % 7) * DAY_MS),
332 "som" => date_only(utc_ms(y, mo, 1, 0, 0, 0, 0)),
333 "eom" => date_only(utc_ms(y, mo, days_in_month(y, mo), 0, 0, 0, 0)),
334 "soy" => date_only(utc_ms(y, 1, 1, 0, 0, 0, 0)),
335 "eoy" => date_only(utc_ms(y, 12, 31, 0, 0, 0, 0)),
336 _ => return None,
337 })
338}
339
340#[must_use]
342pub fn date_of(x: &Value, now_ms: i64) -> Option<String> {
343 let Value::Str(s) = x else { return None };
344 let key = s.trim().to_lowercase();
345 if NAMED_POINTS.contains(&key.as_str()) {
346 return named_point(&key, now_ms).as_ref().map(render_date);
347 }
348 canonical_date(x)
349}
350
351#[derive(Clone, Copy, Debug, PartialEq)]
357pub struct Duration {
358 pub years: f64,
359 pub months: f64,
360 pub seconds: f64,
361}
362
363pub const MONTH_SECONDS: f64 = 30.0 * 86_400.0;
365pub const YEAR_SECONDS: f64 = 365.0 * 86_400.0;
366
367fn unit_seconds(unit: &str) -> Option<f64> {
368 Some(match unit {
369 "w" | "wk" | "wks" | "week" | "weeks" => 604_800.0,
370 "d" | "day" | "days" => 86_400.0,
371 "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0,
372 "m" | "min" | "mins" | "minute" | "minutes" => 60.0,
373 "s" | "sec" | "secs" | "second" | "seconds" => 1.0,
374 _ => return None,
375 })
376}
377
378fn is_year_unit(unit: &str) -> bool {
379 matches!(unit, "y" | "yr" | "yrs" | "year" | "years")
380}
381
382fn is_month_unit(unit: &str) -> bool {
383 matches!(unit, "mo" | "mos" | "month" | "months")
384}
385
386fn take_number(s: &str) -> Option<(f64, &str)> {
388 let int = s.bytes().take_while(u8::is_ascii_digit).count();
389 if int == 0 {
390 return None;
391 }
392 let mut end = int;
393 if s[end..].starts_with('.') {
394 let frac = s[end + 1..].bytes().take_while(u8::is_ascii_digit).count();
395 if frac > 0 {
396 end += 1 + frac;
397 }
398 }
399 Some((s[..end].parse().ok()?, &s[end..]))
400}
401
402fn iso_component(s: &str, designator: char) -> Option<(f64, &str)> {
406 let (n, rest) = take_number(s)?;
407 let c = rest.chars().next()?;
408 if c.eq_ignore_ascii_case(&designator) {
409 Some((n, &rest[1..]))
410 } else {
411 None
412 }
413}
414
415fn parse_iso_duration(s: &str) -> Result<Option<Duration>, ()> {
420 let (sign, rest) = match s.strip_prefix('-') {
421 Some(r) => (-1.0, r),
422 None => (1.0, s.strip_prefix('+').unwrap_or(s)),
423 };
424 let mut rest = rest
425 .strip_prefix('P')
426 .or_else(|| rest.strip_prefix('p'))
427 .ok_or(())?;
428 let mut parts = [None; 7];
429 for (i, designator) in ['Y', 'M', 'W', 'D'].into_iter().enumerate() {
430 if let Some((n, r)) = iso_component(rest, designator) {
431 parts[i] = Some(n);
432 rest = r;
433 }
434 }
435 if let Some(r) = rest.strip_prefix('T').or_else(|| rest.strip_prefix('t')) {
436 rest = r;
437 for (i, designator) in ['H', 'M', 'S'].into_iter().enumerate() {
438 if let Some((n, r)) = iso_component(rest, designator) {
439 parts[4 + i] = Some(n);
440 rest = r;
441 }
442 }
443 }
444 if !rest.is_empty() {
445 return Err(());
446 }
447 if parts.iter().all(Option::is_none) {
448 return Ok(None);
449 }
450 let n = |i: usize| parts[i].unwrap_or(0.0);
451 Ok(Some(Duration {
452 years: sign * n(0),
453 months: sign * n(1),
454 seconds: sign * (n(2) * 604_800.0 + n(3) * 86_400.0 + n(4) * 3600.0 + n(5) * 60.0 + n(6)),
455 }))
456}
457
458#[must_use]
466pub fn parse_duration(x: &Value) -> Option<Duration> {
467 let Value::Str(s) = x else { return None };
468 let s = s.trim();
469 if s.is_empty() {
470 return None;
471 }
472 if let Ok(iso) = parse_iso_duration(s) {
473 return iso;
474 }
475 let mut rest = s;
476 let mut d = Duration {
477 years: 0.0,
478 months: 0.0,
479 seconds: 0.0,
480 };
481 loop {
482 let (sign, after_sign) = match rest.strip_prefix('-') {
484 Some(r) => (-1.0, r),
485 None => (1.0, rest),
486 };
487 let (n, after_num) = take_number(after_sign)?;
488 let after_space = after_num.trim_start();
489 let unit_len = after_space
490 .bytes()
491 .take_while(u8::is_ascii_alphabetic)
492 .count();
493 if unit_len == 0 {
494 return None;
495 }
496 let unit = after_space[..unit_len].to_ascii_lowercase();
497 let n = sign * n;
498 if let Some(secs) = unit_seconds(&unit) {
499 d.seconds += n * secs;
500 } else if is_month_unit(&unit) {
501 d.months += n;
502 } else if is_year_unit(&unit) {
503 d.years += n;
504 } else {
505 return None;
506 }
507 rest = &after_space[unit_len..];
508 if rest.is_empty() {
509 break;
510 }
511 rest = rest.trim_start();
513 rest = rest.strip_prefix(',').unwrap_or(rest).trim_start();
514 if rest.is_empty() {
515 return None;
516 }
517 }
518 Some(d)
519}
520
521#[must_use]
523pub fn duration_seconds(d: &Duration) -> f64 {
524 d.years * YEAR_SECONDS + d.months * MONTH_SECONDS + d.seconds
525}
526
527#[must_use]
529pub fn dur_of(x: &Value) -> Option<f64> {
530 match x {
531 Value::Number(n) => n.is_finite().then_some(*n),
532 other => parse_duration(other).as_ref().map(duration_seconds),
533 }
534}
535
536fn js_round(x: f64) -> f64 {
540 (x + 0.5).floor()
541}
542
543#[must_use]
549pub fn shift_date(d: &DateValue, months: i64, seconds: f64) -> DateValue {
550 let mut ms = d.ms;
551 if months != 0 {
552 let f = fields_of(d);
553 let total = f.year * 12 + (f.month - 1) + months;
554 let year = total.div_euclid(12);
555 let month = total.rem_euclid(12) + 1;
556 let day = f.day.min(days_in_month(year, month));
557 ms = utc_ms(year, month, day, f.hour, f.minute, f.second, f.millis) - d.offset * MINUTE_MS;
558 }
559 let delta_ms = js_round(seconds * 1000.0) as i64;
561 ms += delta_ms;
562 DateValue {
563 ms,
564 offset: d.offset,
565 date_only: d.date_only && delta_ms % DAY_MS == 0,
566 fraction: d.fraction,
567 }
568}
569
570fn duration_arg(x: &Value) -> Option<Duration> {
573 match x {
574 Value::Number(n) => n.is_finite().then_some(Duration {
575 years: 0.0,
576 months: 0.0,
577 seconds: *n,
578 }),
579 other => parse_duration(other),
580 }
581}
582
583#[must_use]
587pub fn calendar_parts(dur: &Duration) -> (i64, f64) {
588 let wy = dur.years.trunc();
589 let wm = dur.months.trunc();
590 (
591 (wy * 12.0 + wm) as i64,
592 dur.seconds + (dur.years - wy) * YEAR_SECONDS + (dur.months - wm) * MONTH_SECONDS,
593 )
594}
595
596#[must_use]
598pub fn plus_minus(recv: &Value, arg: &Value, sign: i64) -> Option<String> {
599 let d = parse_date(recv)?;
600 let dur = duration_arg(arg)?;
601 let (months, seconds) = calendar_parts(&dur);
602 Some(render_date(&shift_date(
603 &d,
604 sign * months,
605 sign as f64 * seconds,
606 )))
607}
608
609#[must_use]
611pub fn until(a: &Value, b: &Value) -> Option<f64> {
612 let da = parse_date(a)?;
613 let db = parse_date(b)?;
614 Some((db.ms - da.ms) as f64 / 1000.0)
615}
616
617const MONTHS: [&str; 12] = [
620 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
621];
622const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
623const FORMAT_TOKENS: [&str; 8] = ["yyyy", "MMM", "EEE", "MM", "dd", "HH", "mm", "ss"];
625
626#[must_use]
632pub fn format_date(recv: &Value, pattern: &Value) -> Option<String> {
633 let d = parse_date(recv)?;
634 let Value::Str(pattern) = pattern else {
635 return None;
636 };
637 let f = fields_of(&d);
638 let mut out = String::new();
639 let mut rest = pattern.as_str();
640 while !rest.is_empty() {
641 match FORMAT_TOKENS.iter().find(|t| rest.starts_with(**t)) {
642 Some(tok) => {
643 match *tok {
644 "yyyy" => out.push_str(&format!("{:04}", f.year)),
645 "MMM" => out.push_str(MONTHS[(f.month - 1) as usize]),
646 "EEE" => out.push_str(WEEKDAYS[(f.weekday - 1) as usize]),
647 "MM" => out.push_str(&format!("{:02}", f.month)),
648 "dd" => out.push_str(&format!("{:02}", f.day)),
649 "HH" => out.push_str(&format!("{:02}", f.hour)),
650 "mm" => out.push_str(&format!("{:02}", f.minute)),
651 _ => out.push_str(&format!("{:02}", f.second)),
652 }
653 rest = &rest[tok.len()..];
654 }
655 None => {
656 let c = rest.chars().next()?;
657 out.push(c);
658 rest = &rest[c.len_utf8()..];
659 }
660 }
661 }
662 Some(out)
663}
664
665struct Split {
668 neg: bool,
669 d: f64,
670 h: f64,
671 m: f64,
672 s: f64,
673}
674
675fn split_seconds(n: f64) -> Split {
676 let neg = n < 0.0;
677 let a = js_round(n.abs() * 1000.0) / 1000.0;
678 Split {
679 neg,
680 d: (a / 86_400.0).floor(),
681 h: (a % 86_400.0 / 3600.0).floor(),
682 m: (a % 3600.0 / 60.0).floor(),
683 s: js_round(a % 60.0 * 1000.0) / 1000.0,
684 }
685}
686
687fn num(n: f64) -> String {
689 Value::Number(n).to_string()
690}
691
692#[must_use]
696pub fn dur_text(n: &Value) -> Option<String> {
697 let Value::Number(n) = n else { return None };
698 if !n.is_finite() {
699 return None;
700 }
701 let p = split_seconds(*n);
702 let mut parts = Vec::new();
703 if p.d != 0.0 {
704 parts.push(format!("{}d", num(p.d)));
705 }
706 if p.h != 0.0 {
707 parts.push(format!("{}h", num(p.h)));
708 }
709 if p.m != 0.0 {
710 parts.push(format!("{}m", num(p.m)));
711 }
712 if p.s != 0.0 {
713 parts.push(format!("{}s", num(p.s)));
714 }
715 if parts.is_empty() {
716 return Some("0s".to_owned());
717 }
718 Some(format!(
719 "{}{}",
720 if p.neg { "-" } else { "" },
721 parts.join(" ")
722 ))
723}
724
725#[must_use]
729pub fn dur_iso(n: &Value) -> Option<String> {
730 let Value::Number(n) = n else { return None };
731 if !n.is_finite() {
732 return None;
733 }
734 let p = split_seconds(*n);
735 let mut out = String::from("P");
736 if p.d != 0.0 {
737 out.push_str(&format!("{}D", num(p.d)));
738 }
739 if p.h != 0.0 || p.m != 0.0 || p.s != 0.0 {
740 out.push('T');
741 if p.h != 0.0 {
742 out.push_str(&format!("{}H", num(p.h)));
743 }
744 if p.m != 0.0 {
745 out.push_str(&format!("{}M", num(p.m)));
746 }
747 if p.s != 0.0 {
748 out.push_str(&format!("{}S", num(p.s)));
749 }
750 }
751 if out == "P" {
752 out = "PT0S".to_owned();
753 }
754 Some(format!("{}{out}", if p.neg { "-" } else { "" }))
755}
756
757#[must_use]
762pub fn wall_clock_ms() -> i64 {
763 std::time::SystemTime::now()
764 .duration_since(std::time::UNIX_EPOCH)
765 .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
766 .unwrap_or(0)
767}
768
769#[must_use]
773pub fn stamp_ms(ts: &str) -> i64 {
774 parse_date(&Value::Str(ts.to_owned())).map_or_else(wall_clock_ms, |d| d.ms)
775}
776
777pub const DATE_FUNCTIONS: [(&str, usize); 2] = [("date", 1), ("dur", 1)];
781pub const DATE_METHODS: [(&str, usize); 14] = [
783 ("plus", 1),
784 ("minus", 1),
785 ("until", 1),
786 ("format", 1),
787 ("year", 0),
788 ("month", 0),
789 ("day", 0),
790 ("weekday", 0),
791 ("hour", 0),
792 ("minute", 0),
793 ("second", 0),
794 ("date", 0),
795 ("dur_text", 0),
796 ("dur_iso", 0),
797];
798
799#[must_use]
801pub fn date_function_arity(name: &str) -> Option<usize> {
802 DATE_FUNCTIONS
803 .iter()
804 .find(|(n, _)| *n == name)
805 .map(|(_, a)| *a)
806}
807
808#[must_use]
810pub fn date_method_arity(name: &str) -> Option<usize> {
811 DATE_METHODS
812 .iter()
813 .find(|(n, _)| *n == name)
814 .map(|(_, a)| *a)
815}
816
817fn arity(spelling: &str, want: usize, got: usize) -> Result<(), OqxError> {
818 if got == want {
819 Ok(())
820 } else {
821 Err(OqxError::eval(format!(
822 "{spelling} takes exactly {want} argument{}, got {got}",
823 if want == 1 { "" } else { "s" }
824 )))
825 }
826}
827
828fn str_or_null(s: Option<String>) -> Value {
829 s.map_or(Value::Null, Value::Str)
830}
831
832fn num_or_null(n: Option<f64>) -> Value {
833 n.map_or(Value::Null, Value::Number)
834}
835
836#[must_use]
838pub fn date_function(name: &str, args: &[Value], now_ms: i64) -> Option<oqx::Result<Value>> {
839 let want = date_function_arity(name)?;
840 if let Err(e) = arity(&format!("{name}()"), want, args.len()) {
841 return Some(Err(e));
842 }
843 let x = args.first().unwrap_or(&Value::Undefined);
844 Some(Ok(if name == "date" {
845 str_or_null(date_of(x, now_ms))
846 } else {
847 num_or_null(dur_of(x))
848 }))
849}
850
851#[must_use]
855pub fn date_method(name: &str, recv: &Value, args: &[Value]) -> Option<oqx::Result<Value>> {
856 let want = date_method_arity(name)?;
857 if let Err(e) = arity(&format!(".{name}()"), want, args.len()) {
858 return Some(Err(e));
859 }
860 let arg = args.first().unwrap_or(&Value::Undefined);
861 let field = |pick: fn(&Fields) -> i64| {
862 num_or_null(parse_date(recv).map(|d| pick(&fields_of(&d)) as f64))
863 };
864 Some(Ok(match name {
865 "plus" => str_or_null(plus_minus(recv, arg, 1)),
866 "minus" => str_or_null(plus_minus(recv, arg, -1)),
867 "until" => num_or_null(until(recv, arg)),
868 "format" => str_or_null(format_date(recv, arg)),
869 "year" => field(|f| f.year),
870 "month" => field(|f| f.month),
871 "day" => field(|f| f.day),
872 "weekday" => field(|f| f.weekday),
873 "hour" => field(|f| f.hour),
874 "minute" => field(|f| f.minute),
875 "second" => field(|f| f.second),
876 "date" => str_or_null(parse_date(recv).map(|d| {
877 render_date(&DateValue {
878 date_only: true,
879 ..d
880 })
881 })),
882 "dur_text" => str_or_null(dur_text(recv)),
883 _ => str_or_null(dur_iso(recv)),
884 }))
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
892
893 const NOW: i64 = 1_790_467_200_000;
895
896 fn s(x: &str) -> Value {
897 Value::Str(x.to_owned())
898 }
899 fn n(x: f64) -> Value {
900 Value::Number(x)
901 }
902 fn canon(x: &str) -> Option<String> {
903 canonical_date(&s(x))
904 }
905 fn utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, sec: i64, ms: i64) -> i64 {
906 utc_ms(y, mo, d, h, mi, sec, ms)
907 }
908
909 #[test]
910 fn corpus_clock_constant_is_the_instant() {
911 assert_eq!(NOW, utc(2026, 9, 27, 0, 0, 0, 0));
912 assert_eq!(stamp_ms("2026-09-27T00:00:00.000Z"), NOW);
913 }
914
915 #[test]
918 fn date_only_stays_date_only_and_a_datetime_is_canonical() {
919 assert_eq!(canon("2026-10-15").as_deref(), Some("2026-10-15"));
920 assert_eq!(
921 canon("2026-10-15T09:00:00Z").as_deref(),
922 Some("2026-10-15T09:00:00Z")
923 );
924 assert_eq!(
925 canon("2026-10-15T09:00:00+00:00").as_deref(),
926 Some("2026-10-15T09:00:00Z")
927 );
928 assert_eq!(
929 canon("2026-10-15T09:00:00+02:00").as_deref(),
930 Some("2026-10-15T09:00:00+02:00")
931 );
932 assert_eq!(
933 canon("2026-10-15T09:00:00-0500").as_deref(),
934 Some("2026-10-15T09:00:00-05:00")
935 );
936 assert_eq!(
937 canon("2026-10-15T09:00").as_deref(),
938 Some("2026-10-15T09:00:00Z")
939 );
940 assert_eq!(
941 canon("2026-10-15 09:00:00").as_deref(),
942 Some("2026-10-15T09:00:00Z")
943 );
944 assert_eq!(
945 canon("2026-10-15t09:00:00z").as_deref(),
946 Some("2026-10-15T09:00:00Z")
947 );
948 assert_eq!(
949 canon("2026-10-15T09:00:00.5Z").as_deref(),
950 Some("2026-10-15T09:00:00.500Z")
951 );
952 assert_eq!(
953 canon("2026-10-15T09:00:00.123456Z").as_deref(),
954 Some("2026-10-15T09:00:00.123Z")
955 );
956 assert_eq!(
957 canon("2026-09-27T00:00:00.000Z").as_deref(),
958 Some("2026-09-27T00:00:00.000Z")
959 );
960 assert_eq!(canon(" 2026-10-15 ").as_deref(), Some("2026-10-15"));
961 }
962
963 #[test]
964 fn rejects_what_is_not_an_iso_date() {
965 for bad in [
966 "15/10/2026",
967 "2026-13-01",
968 "2026-02-30",
969 "2026-00-10",
970 "2026-10-15T24:00:00Z",
971 "2026-10-15T09:60:00Z",
972 "2026-10-15T09:00:60Z",
973 "2026-10-15T09:00:00+24:00",
974 "",
975 "today",
976 "2026-1-5",
977 "20261015",
978 "2026-10-15T09:00:00.Z",
979 "2026-10-15T09:00:00.1234567890Z",
980 ] {
981 assert!(parse_date(&s(bad)).is_none(), "{bad}");
982 }
983 assert!(parse_date(&n(5.0)).is_none());
984 assert!(parse_date(&Value::Null).is_none());
985 assert!(parse_date(&Value::Undefined).is_none());
986 assert!(parse_date(&Value::Bool(true)).is_none());
987 }
988
989 #[test]
990 fn leap_days_parse_in_leap_years_only() {
991 assert_eq!(canon("2024-02-29").as_deref(), Some("2024-02-29"));
992 assert_eq!(canon("2026-02-29"), None);
993 assert_eq!(canon("2000-02-29").as_deref(), Some("2000-02-29"));
994 assert_eq!(canon("1900-02-29"), None);
995 }
996
997 #[test]
998 fn keeps_the_written_offset_as_the_values_fields() {
999 let d = parse_date(&s("2026-10-15T23:30:00-05:00")).unwrap();
1000 assert_eq!(d.offset, -300);
1001 assert_eq!(d.ms, utc(2026, 10, 16, 4, 30, 0, 0));
1002 assert_eq!(render_date(&d), "2026-10-15T23:30:00-05:00");
1003 }
1004
1005 #[test]
1008 fn resolves_every_point_at_the_corpus_clock() {
1009 let at = |name: &str| render_date(&named_point(name, NOW).unwrap());
1010 assert_eq!(at("now"), "2026-09-27T00:00:00.000Z");
1011 assert_eq!(at("today"), "2026-09-27");
1012 assert_eq!(at("yesterday"), "2026-09-26");
1013 assert_eq!(at("tomorrow"), "2026-09-28");
1014 assert_eq!(at("sow"), "2026-09-21");
1015 assert_eq!(at("eow"), "2026-09-27");
1016 assert_eq!(at("som"), "2026-09-01");
1017 assert_eq!(at("eom"), "2026-09-30");
1018 assert_eq!(at("soy"), "2026-01-01");
1019 assert_eq!(at("eoy"), "2026-12-31");
1020 assert!(NAMED_POINTS.iter().all(|p| named_point(p, NOW).is_some()));
1021 assert!(named_point("noon", NOW).is_none());
1022 }
1023
1024 #[test]
1025 fn week_and_month_edges_on_other_instants() {
1026 let wed = utc(2026, 10, 14, 15, 0, 0, 0); assert_eq!(render_date(&named_point("sow", wed).unwrap()), "2026-10-12");
1028 assert_eq!(render_date(&named_point("eow", wed).unwrap()), "2026-10-18");
1029 let mon = utc(2026, 10, 12, 0, 0, 0, 0);
1030 assert_eq!(render_date(&named_point("sow", mon).unwrap()), "2026-10-12");
1031 assert_eq!(
1032 render_date(&named_point("eom", utc(2024, 2, 10, 0, 0, 0, 0)).unwrap()),
1033 "2024-02-29"
1034 );
1035 assert_eq!(
1036 render_date(&named_point("eom", utc(2026, 12, 10, 0, 0, 0, 0)).unwrap()),
1037 "2026-12-31"
1038 );
1039 assert_eq!(
1040 render_date(&named_point("now", utc(2026, 10, 14, 15, 0, 7, 250)).unwrap()),
1041 "2026-10-14T15:00:07.250Z"
1042 );
1043 }
1044
1045 #[test]
1046 fn date_of_a_named_point_trimmed_case_insensitive_else_canonical_else_none() {
1047 assert_eq!(date_of(&s("Today"), NOW).as_deref(), Some("2026-09-27"));
1048 assert_eq!(
1049 date_of(&s(" now "), NOW).as_deref(),
1050 Some("2026-09-27T00:00:00.000Z")
1051 );
1052 assert_eq!(
1053 date_of(&s("2026-01-05"), NOW).as_deref(),
1054 Some("2026-01-05")
1055 );
1056 assert_eq!(date_of(&s("nope"), NOW), None);
1057 assert_eq!(date_of(&n(5.0), NOW), None);
1058 assert_eq!(date_of(&Value::Undefined, NOW), None);
1059 }
1060
1061 fn dur(x: &str) -> Option<f64> {
1064 dur_of(&s(x))
1065 }
1066
1067 #[test]
1068 fn dataview_pairs_spaces_commas_no_spaces_every_unit_case_decimals() {
1069 assert_eq!(dur("1h30m"), Some(5400.0));
1070 assert_eq!(dur("1 hour 30 minutes"), Some(5400.0));
1071 assert_eq!(dur("2 days, 3 hours"), Some(2.0 * 86400.0 + 3.0 * 3600.0));
1072 assert_eq!(dur("1 week"), Some(604_800.0));
1073 assert_eq!(dur("1 month"), Some(30.0 * 86400.0));
1074 assert_eq!(dur("1 year"), Some(365.0 * 86400.0));
1075 assert_eq!(dur("90 seconds"), Some(90.0));
1076 assert_eq!(dur("1.5h"), Some(5400.0));
1077 assert_eq!(dur("2 YRS"), Some(2.0 * 365.0 * 86400.0));
1078 assert_eq!(dur("3 wks 2 d"), Some(3.0 * 604_800.0 + 2.0 * 86400.0));
1079 assert_eq!(dur("2mo"), Some(60.0 * 86400.0));
1080 assert_eq!(dur("5 mins, 10 secs"), Some(310.0));
1081 assert_eq!(
1082 dur("1y 1mo 1w 1d 1h 1m 1s"),
1083 Some(365.0 * 86400.0 + 30.0 * 86400.0 + 604_800.0 + 86400.0 + 3600.0 + 61.0)
1084 );
1085 assert_eq!(dur("-1 day"), Some(-86400.0));
1086 }
1087
1088 #[test]
1089 fn iso_8601_durations_and_numbers() {
1090 assert_eq!(dur("PT1H30M"), Some(5400.0));
1091 assert_eq!(dur("P1DT2H"), Some(86400.0 + 7200.0));
1092 assert_eq!(dur("P2W"), Some(2.0 * 604_800.0));
1093 assert_eq!(dur("P1M"), Some(30.0 * 86400.0));
1094 assert_eq!(
1095 dur("P1Y2M3DT4H5M6S"),
1096 Some(
1097 365.0 * 86400.0 + 60.0 * 86400.0 + 3.0 * 86400.0 + 4.0 * 3600.0 + 5.0 * 60.0 + 6.0
1098 )
1099 );
1100 assert_eq!(dur("PT0S"), Some(0.0));
1101 assert_eq!(dur("pt90m"), Some(5400.0));
1102 assert_eq!(dur("-PT1H"), Some(-3600.0));
1103 assert_eq!(dur_of(&n(90.0)), Some(90.0));
1104 assert_eq!(dur_of(&n(1.5)), Some(1.5));
1105 assert_eq!(dur_of(&n(-60.0)), Some(-60.0));
1106 assert_eq!(dur_of(&n(f64::NAN)), None);
1107 assert_eq!(dur_of(&n(f64::INFINITY)), None);
1108 }
1109
1110 #[test]
1111 fn keeps_calendar_months_apart_from_seconds() {
1112 assert_eq!(
1113 parse_duration(&s("1 year 2 months 3 days")),
1114 Some(Duration {
1115 years: 1.0,
1116 months: 2.0,
1117 seconds: 3.0 * 86400.0
1118 })
1119 );
1120 assert_eq!(
1121 parse_duration(&s("P1Y2M")),
1122 Some(Duration {
1123 years: 1.0,
1124 months: 2.0,
1125 seconds: 0.0
1126 })
1127 );
1128 assert_eq!(
1129 duration_seconds(&Duration {
1130 years: 1.0,
1131 months: 1.0,
1132 seconds: 1.0
1133 }),
1134 365.0 * 86400.0 + 30.0 * 86400.0 + 1.0
1135 );
1136 }
1137
1138 #[test]
1139 fn rejects_what_is_not_a_duration() {
1140 for bad in [
1141 "",
1142 " ",
1143 "abc",
1144 "3 fortnights",
1145 "90",
1146 "P",
1147 "PT",
1148 "1h 2x",
1149 "h1",
1150 "1 h,",
1151 ",1h",
1152 "1h,,2m",
1153 "1,5h",
1154 "one hour",
1155 "P1X",
1156 "1.",
1157 ] {
1158 assert!(parse_duration(&s(bad)).is_none(), "{bad:?}");
1159 }
1160 assert_eq!(dur_of(&Value::Null), None);
1161 assert_eq!(dur_of(&Value::Bool(true)), None);
1162 assert_eq!(dur_of(&Value::Undefined), None);
1163 assert_eq!(dur_of(&Value::Object(oqx::Object::default())), None);
1164 }
1165
1166 fn pm(recv: &str, arg: Value, sign: i64) -> Option<String> {
1169 plus_minus(&s(recv), &arg, sign)
1170 }
1171
1172 #[test]
1173 fn seconds_date_only_stays_date_only_for_whole_days_else_a_utc_datetime() {
1174 assert_eq!(
1175 pm("2026-01-31", n(86400.0), 1).as_deref(),
1176 Some("2026-02-01")
1177 );
1178 assert_eq!(
1179 pm("2026-01-31", n(2.0 * 86400.0), 1).as_deref(),
1180 Some("2026-02-02")
1181 );
1182 assert_eq!(
1183 pm("2026-01-31", n(3600.0), 1).as_deref(),
1184 Some("2026-01-31T01:00:00Z")
1185 );
1186 assert_eq!(
1187 pm("2026-01-31", n(30.0 * 86400.0), 1).as_deref(),
1188 Some("2026-03-02")
1189 ); assert_eq!(pm("2026-01-31", n(0.0), 1).as_deref(), Some("2026-01-31"));
1191 assert_eq!(
1192 pm("2026-01-01", n(86400.0), -1).as_deref(),
1193 Some("2025-12-31")
1194 );
1195 assert_eq!(
1196 pm("2026-01-01", n(-86400.0), 1).as_deref(),
1197 Some("2025-12-31")
1198 );
1199 }
1200
1201 #[test]
1202 fn a_duration_string_is_calendar_aware_in_the_month_year_part_clamping_the_day() {
1203 assert_eq!(
1204 pm("2026-01-31", s("1 month"), 1).as_deref(),
1205 Some("2026-02-28")
1206 );
1207 assert_eq!(
1208 pm("2024-01-31", s("1 month"), 1).as_deref(),
1209 Some("2024-02-29")
1210 );
1211 assert_eq!(
1212 pm("2026-03-31", s("1 month"), -1).as_deref(),
1213 Some("2026-02-28")
1214 );
1215 assert_eq!(
1216 pm("2026-02-28", s("1 year"), 1).as_deref(),
1217 Some("2027-02-28")
1218 );
1219 assert_eq!(
1220 pm("2024-02-29", s("1 year"), 1).as_deref(),
1221 Some("2025-02-28")
1222 );
1223 assert_eq!(
1224 pm("2026-01-31", s("1 month 1 day"), 1).as_deref(),
1225 Some("2026-03-01")
1226 );
1227 assert_eq!(pm("2026-01-31", s("P1M"), 1).as_deref(), Some("2026-02-28"));
1228 assert_eq!(
1229 pm("2026-11-15", s("3 months"), 1).as_deref(),
1230 Some("2027-02-15")
1231 );
1232 assert_eq!(
1233 pm("2026-01-15", s("13 months"), -1).as_deref(),
1234 Some("2024-12-15")
1235 );
1236 assert_eq!(
1237 pm("2026-01-31T09:00:00Z", s("1 month"), 1).as_deref(),
1238 Some("2026-02-28T09:00:00Z")
1239 );
1240 assert_eq!(
1241 pm("2026-01-31T09:00:00+02:00", s("1 month"), 1).as_deref(),
1242 Some("2026-02-28T09:00:00+02:00")
1243 );
1244 assert_eq!(
1245 pm("2026-01-31", s("-1 month"), 1).as_deref(),
1246 Some("2025-12-31")
1247 );
1248 assert_eq!(
1250 pm("2026-01-01", s("0.5 months"), 1).as_deref(),
1251 Some("2026-01-16")
1252 );
1253 assert_eq!(
1254 pm("2026-01-01", s("1.5 years"), 1).as_deref(),
1255 Some("2027-07-02T12:00:00Z")
1256 ); assert_eq!(
1258 pm("2026-01-01", s("1.5 years"), 1),
1259 pm("2027-01-01", n(182.5 * 86400.0), 1)
1260 );
1261 }
1262
1263 #[test]
1264 fn a_datetime_keeps_its_offset_and_shows_a_fraction_when_one_appears() {
1265 assert_eq!(
1266 pm("2026-10-15T09:00:00Z", n(5400.0), 1).as_deref(),
1267 Some("2026-10-15T10:30:00Z")
1268 );
1269 assert_eq!(
1270 pm("2026-10-15T09:00:00+02:00", n(86400.0), 1).as_deref(),
1271 Some("2026-10-16T09:00:00+02:00")
1272 );
1273 assert_eq!(
1274 pm("2026-10-15T23:30:00Z", n(3600.0), 1).as_deref(),
1275 Some("2026-10-16T00:30:00Z")
1276 );
1277 assert_eq!(
1278 pm("2026-10-15T09:00:00Z", n(0.5), 1).as_deref(),
1279 Some("2026-10-15T09:00:00.500Z")
1280 );
1281 assert_eq!(
1282 pm("2026-10-15T09:00:00.000Z", n(60.0), 1).as_deref(),
1283 Some("2026-10-15T09:01:00.000Z")
1284 );
1285 assert_eq!(
1286 pm("2026-10-15T09:00:00Z", n(1.0), -1).as_deref(),
1287 Some("2026-10-15T08:59:59Z")
1288 );
1289 assert_eq!(
1290 pm("2026-01-01", n(0.5), 1).as_deref(),
1291 Some("2026-01-01T00:00:00.500Z")
1292 );
1293 }
1294
1295 #[test]
1296 fn is_total_absent_on_a_non_date_receiver_or_a_non_duration_argument() {
1297 assert_eq!(pm("x", n(1.0), 1), None);
1298 assert_eq!(pm("2026-01-01", s("bogus"), 1), None);
1299 assert_eq!(pm("2026-01-01", Value::Null, 1), None);
1300 assert_eq!(plus_minus(&Value::Undefined, &n(1.0), 1), None);
1301 assert_eq!(plus_minus(&n(5.0), &n(1.0), 1), None);
1302 assert_eq!(pm("2026-01-01", n(f64::NAN), 1), None);
1303 }
1304
1305 #[test]
1306 fn shift_date_keeps_the_receivers_fraction_flag() {
1307 let d = parse_date(&s("2026-10-15T09:00:00.000Z")).unwrap();
1308 assert_eq!(
1309 render_date(&shift_date(&d, 0, 60.0)),
1310 "2026-10-15T09:01:00.000Z"
1311 );
1312 assert_eq!(
1313 render_date(&shift_date(
1314 &parse_date(&s("2026-10-15T09:00:00Z")).unwrap(),
1315 1,
1316 0.0
1317 )),
1318 "2026-11-15T09:00:00Z"
1319 );
1320 }
1321
1322 #[test]
1325 fn until_is_seconds_by_instant_signed_fractional_absent_propagating() {
1326 let u = |a: &str, b: &str| until(&s(a), &s(b));
1327 assert_eq!(u("2026-01-01", "2026-01-02"), Some(86400.0));
1328 assert_eq!(u("2026-01-02", "2026-01-01"), Some(-86400.0));
1329 assert_eq!(u("2026-01-01", "2026-01-01"), Some(0.0));
1330 assert_eq!(
1331 u("2026-10-15T09:00:00Z", "2026-10-15T10:30:00Z"),
1332 Some(5400.0)
1333 );
1334 assert_eq!(
1335 u("2026-01-01T10:00:00+02:00", "2026-01-01T10:00:00Z"),
1336 Some(7200.0)
1337 );
1338 assert_eq!(u("2026-01-01", "2026-01-01T06:00:00Z"), Some(21600.0));
1339 assert_eq!(
1340 u("2026-01-01T00:00:00Z", "2026-01-01T00:00:00.500Z"),
1341 Some(0.5)
1342 );
1343 assert_eq!(until(&s("2026-01-01"), &Value::Undefined), None);
1344 assert_eq!(until(&Value::Undefined, &s("2026-01-01")), None);
1345 assert_eq!(u("x", "2026-01-01"), None);
1346 }
1347
1348 #[test]
1349 fn fields_read_the_written_offset_weekday_is_1_monday_to_7_sunday() {
1350 let f = |name: &str, recv: Value| date_method(name, &recv, &[]).unwrap().unwrap();
1351 let d = "2026-10-15T09:05:07Z";
1352 assert_eq!(
1353 [
1354 f("year", s(d)),
1355 f("month", s(d)),
1356 f("day", s(d)),
1357 f("weekday", s(d)),
1358 f("hour", s(d)),
1359 f("minute", s(d)),
1360 f("second", s(d))
1361 ],
1362 [n(2026.0), n(10.0), n(15.0), n(4.0), n(9.0), n(5.0), n(7.0)]
1363 );
1364 assert_eq!(f("hour", s("2026-10-15")), n(0.0));
1365 assert_eq!(f("weekday", s("2026-09-21")), n(1.0));
1366 assert_eq!(f("weekday", s("2026-09-27")), n(7.0));
1367 assert_eq!(f("date", s(d)), s("2026-10-15"));
1368 assert_eq!(f("date", s("2026-10-15")), s("2026-10-15"));
1369 assert_eq!(f("date", s("2026-10-15T23:30:00-05:00")), s("2026-10-15"));
1370 assert_eq!(f("hour", s("2026-10-15T23:30:00-05:00")), n(23.0));
1371 assert_eq!(f("second", s("2026-10-15T09:05:07.900Z")), n(7.0));
1372 assert_eq!(f("year", Value::Undefined), Value::Null);
1373 assert_eq!(f("year", s("abc")), Value::Null);
1374 assert_eq!(f("year", n(5.0)), Value::Null);
1375 assert_eq!(f("date", s("2026-13-01")), Value::Null);
1376 }
1377
1378 #[test]
1379 fn format_the_token_set_longest_first_everything_else_literal() {
1380 let fmt = |recv: &str, p: &str| format_date(&s(recv), &s(p));
1381 let d = "2026-10-15T09:05:07Z";
1382 assert_eq!(
1383 fmt(d, "yyyy-MM-dd HH:mm:ss").as_deref(),
1384 Some("2026-10-15 09:05:07")
1385 );
1386 assert_eq!(
1387 fmt(d, "EEE dd MMM yyyy").as_deref(),
1388 Some("Thu 15 Oct 2026")
1389 );
1390 assert_eq!(fmt(d, "dd/MM/yyyy").as_deref(), Some("15/10/2026"));
1391 assert_eq!(fmt(d, "MMMM").as_deref(), Some("OctM"));
1392 assert_eq!(fmt(d, "yyyy 'at' HH").as_deref(), Some("2026 'at' 09"));
1393 assert_eq!(fmt(d, "").as_deref(), Some(""));
1394 assert_eq!(fmt(d, "yyyyyyyy").as_deref(), Some("20262026"));
1395 assert_eq!(fmt("2026-01-05", "EEE").as_deref(), Some("Mon"));
1396 assert_eq!(fmt("2026-01-05", "HH:mm").as_deref(), Some("00:00"));
1397 assert_eq!(
1398 fmt("2026-10-15T23:30:00-05:00", "dd HH").as_deref(),
1399 Some("15 23")
1400 );
1401 assert_eq!(fmt("2026-12-31", "MMM EEE").as_deref(), Some("Dec Thu"));
1402 assert_eq!(fmt(d, "année → yyyy").as_deref(), Some("année → 2026"));
1403 assert_eq!(format_date(&s(d), &n(5.0)), None);
1404 assert_eq!(format_date(&Value::Undefined, &s("yyyy")), None);
1405 assert_eq!(fmt("x", "yyyy"), None);
1406 }
1407
1408 #[test]
1411 fn dur_text_renders() {
1412 let t = |x: f64| dur_text(&n(x));
1413 assert_eq!(t(5400.0).as_deref(), Some("1h 30m"));
1414 assert_eq!(t(2.0 * 86400.0 + 3.0 * 3600.0).as_deref(), Some("2d 3h"));
1415 assert_eq!(t(45.0).as_deref(), Some("45s"));
1416 assert_eq!(t(0.0).as_deref(), Some("0s"));
1417 assert_eq!(t(-90.0).as_deref(), Some("-1m 30s"));
1418 assert_eq!(t(1.5).as_deref(), Some("1.5s"));
1419 assert_eq!(t(604_800.0).as_deref(), Some("7d"));
1420 assert_eq!(t(86401.0).as_deref(), Some("1d 1s"));
1421 assert_eq!(t(0.0004).as_deref(), Some("0s"));
1422 assert_eq!(dur_text(&s("x")), None);
1423 assert_eq!(dur_text(&Value::Undefined), None);
1424 assert_eq!(t(f64::NAN), None);
1425 }
1426
1427 #[test]
1428 fn dur_iso_renders() {
1429 let t = |x: f64| dur_iso(&n(x));
1430 assert_eq!(t(5400.0).as_deref(), Some("PT1H30M"));
1431 assert_eq!(t(2.0 * 86400.0 + 3.0 * 3600.0).as_deref(), Some("P2DT3H"));
1432 assert_eq!(t(45.0).as_deref(), Some("PT45S"));
1433 assert_eq!(t(0.0).as_deref(), Some("PT0S"));
1434 assert_eq!(t(-90.0).as_deref(), Some("-PT1M30S"));
1435 assert_eq!(t(30.0 * 86400.0).as_deref(), Some("P30D"));
1436 assert_eq!(t(86400.0).as_deref(), Some("P1D"));
1437 assert_eq!(t(1.5).as_deref(), Some("PT1.5S"));
1438 assert_eq!(dur_iso(&s("x")), None);
1439 assert_eq!(dur_iso(&Value::Undefined), None);
1440 }
1441
1442 #[test]
1443 fn round_trips_through_dur() {
1444 for x in [
1445 0.0,
1446 1.0,
1447 45.0,
1448 5400.0,
1449 86401.0,
1450 2.0 * 86400.0 + 3.0 * 3600.0,
1451 1.5,
1452 ] {
1453 assert_eq!(dur_of(&s(&dur_iso(&n(x)).unwrap())), Some(x));
1454 assert_eq!(dur_of(&s(&dur_text(&n(x)).unwrap())), Some(x));
1455 }
1456 }
1457
1458 fn msg(r: Option<oqx::Result<Value>>) -> String {
1461 r.unwrap().unwrap_err().to_string()
1462 }
1463
1464 #[test]
1465 fn date_function_date_dur_handled_anything_else_deferred_arity_is_the_one_error() {
1466 assert_eq!(
1467 date_function("date", &[s("2026-01-05")], NOW),
1468 Some(Ok(s("2026-01-05")))
1469 );
1470 assert_eq!(
1471 date_function("date", &[s("today")], NOW),
1472 Some(Ok(s("2026-09-27")))
1473 );
1474 assert_eq!(date_function("dur", &[s("1h")], NOW), Some(Ok(n(3600.0))));
1475 assert!(date_function("size", &[Value::Array(vec![])], NOW).is_none());
1476 assert!(date_function("date", &[], NOW).unwrap().is_err());
1477 assert!(
1478 msg(date_function("date", &[s("a"), s("b")], NOW))
1479 .contains("date() takes exactly 1 argument, got 2")
1480 );
1481 assert!(
1482 msg(date_function("dur", &[], NOW)).contains("dur() takes exactly 1 argument, got 0")
1483 );
1484 }
1485
1486 #[test]
1487 fn date_method_every_method_handled_on_its_receiver_kind_total_on_others_arity_checked() {
1488 assert_eq!(
1489 date_method("plus", &s("2026-01-01"), &[n(86400.0)]),
1490 Some(Ok(s("2026-01-02")))
1491 );
1492 assert_eq!(
1493 date_method("minus", &s("2026-01-01"), &[s("1 day")]),
1494 Some(Ok(s("2025-12-31")))
1495 );
1496 assert_eq!(
1497 date_method("until", &s("2026-01-01"), &[s("2026-01-02")]),
1498 Some(Ok(n(86400.0)))
1499 );
1500 assert_eq!(
1501 date_method("format", &s("2026-01-01"), &[s("yyyy")]),
1502 Some(Ok(s("2026")))
1503 );
1504 assert_eq!(
1505 date_method("dur_text", &n(5400.0), &[]),
1506 Some(Ok(s("1h 30m")))
1507 );
1508 assert_eq!(
1509 date_method("dur_iso", &n(5400.0), &[]),
1510 Some(Ok(s("PT1H30M")))
1511 );
1512 assert_eq!(
1513 date_method("dur_text", &s("2026-01-01"), &[]),
1514 Some(Ok(Value::Null))
1515 );
1516 assert_eq!(
1517 date_method("plus", &n(5.0), &[n(1.0)]),
1518 Some(Ok(Value::Null))
1519 );
1520 assert!(date_method("lower", &s("X"), &[]).is_none());
1521 assert!(
1522 msg(date_method("plus", &s("2026-01-01"), &[]))
1523 .contains(".plus() takes exactly 1 argument, got 0")
1524 );
1525 assert!(
1526 msg(date_method("dur_text", &n(1.0), &[n(1.0)]))
1527 .contains(".dur_text() takes exactly 0 arguments, got 1")
1528 );
1529 assert!(
1530 date_method("year", &s("2026-01-01"), &[n(1.0)])
1531 .unwrap()
1532 .is_err()
1533 );
1534 for (name, arity) in DATE_METHODS {
1535 let args = vec![n(1.0); arity];
1536 assert!(
1537 date_method(name, &Value::Undefined, &args).is_some(),
1538 "{name}"
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn civil_arithmetic_round_trips_across_eras() {
1545 for days in [-719_468, -1, 0, 1, 10_957, 20_724, 2_932_896] {
1546 let (y, m, d) = civil_from_days(days);
1547 assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
1548 }
1549 assert_eq!(civil_from_days(0), (1970, 1, 1));
1550 assert_eq!(dow_of_days(0), 4); assert_eq!(civil_from_days(days_from_civil(2026, 9, 27)), (2026, 9, 27));
1552 }
1553}