Skip to main content

sup_xml_core/xpath/exslt/
date.rs

1//! EXSLT date family — https://exslt.org/date/
2//!
3//! All functions live in the `http://exslt.org/dates-and-times`
4//! namespace.  The implementation pairs a small hand-written XSD
5//! lexical parser/formatter (sized to the EXSLT 1.0 subset:
6//! dateTime / date / time / duration only) with `chrono` for the
7//! actual calendar arithmetic — leap years, day-clipping when
8//! adding months, day-of-week computation, etc.  Chrono is doing
9//! the work that's been spec-debugged across millions of users;
10//! we just bridge XSD↔chrono and dispatch by function name.
11//!
12//! Functions are *defensive*: malformed input → empty string
13//! (not an error), matching libexslt's behaviour and the EXSLT
14//! spec's "if the argument is not a valid xs:dateTime, returns an
15//! empty string" pattern.  Missing-arg-uses-current-time is the
16//! other defensive trick the spec uses.
17
18use chrono::{
19    DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime,
20    TimeZone, Timelike, Utc,
21};
22
23use crate::error::{ErrorDomain, ErrorLevel, XmlError};
24use crate::xpath::eval::{Numeric, Value, value_to_string};
25use crate::xpath::index::DocIndexLike;
26
27use super::Result;
28
29fn err(msg: impl Into<String>) -> XmlError {
30    XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, msg)
31}
32
33// ── EXSLT-internal datetime representation ────────────────────────
34//
35// XSD dateTime is "civil time plus an optional offset."  Chrono's
36// `DateTime<FixedOffset>` carries the offset always; for values
37// where XSD lets the offset be absent we use `NaiveDateTime` plus
38// `None`.  Carrying the difference lets `format_*` emit the right
39// shape when round-tripping (an XSD value without a timezone must
40// not be re-emitted with one).
41
42#[derive(Clone, Copy, Debug)]
43struct Dt {
44    naive:  NaiveDateTime,
45    offset: Option<FixedOffset>,
46}
47
48impl Dt {
49    /// UTC-anchor for cross-zone math.  Untimezoned values are
50    /// treated as UTC (libexslt convention, EXSLT spec says
51    /// implementation-defined).
52    fn to_utc(self) -> DateTime<Utc> {
53        let off = self.offset.unwrap_or(FixedOffset::east_opt(0).unwrap());
54        // Build DateTime<FixedOffset>, then convert.
55        off.from_local_datetime(&self.naive).single()
56            .map(|dt| dt.with_timezone(&Utc))
57            .unwrap_or_else(|| Utc.from_utc_datetime(&self.naive))
58    }
59}
60
61// ── XSD lexical parsers ──────────────────────────────────────────
62
63/// `xs:dateTime` lexical form:
64///   `(-)? YYYY [Y*] - MM - DD T hh : mm : ss [. frac] [ Z | [+-]hh:mm ]?`
65fn parse_xsd_datetime(s: &str) -> Option<Dt> {
66    let (s, neg) = strip_neg(s);
67    let (year_part, rest) = take_year(s)?;
68    let rest = expect_char(rest, '-')?;
69    let (month, rest) = take_n_digits(rest, 2)?;
70    let rest = expect_char(rest, '-')?;
71    let (day, rest) = take_n_digits(rest, 2)?;
72    let rest = expect_char(rest, 'T')?;
73    let (hour, rest) = take_n_digits(rest, 2)?;
74    let rest = expect_char(rest, ':')?;
75    let (minute, rest) = take_n_digits(rest, 2)?;
76    let rest = expect_char(rest, ':')?;
77    let (second, rest) = take_n_digits(rest, 2)?;
78    let (nano, rest) = parse_optional_fraction(rest);
79    let (offset, rest) = parse_optional_tz(rest);
80    if !rest.is_empty() { return None; }
81
82    let year = signed(year_part, neg)?;
83    let date = NaiveDate::from_ymd_opt(year, month as u32, day as u32)?;
84    let time = NaiveTime::from_hms_nano_opt(hour as u32, minute as u32, second as u32, nano)?;
85    Some(Dt { naive: date.and_time(time), offset })
86}
87
88/// `xs:date` lexical form:  `(-)? YYYY [Y*] - MM - DD [TZ]?`
89fn parse_xsd_date(s: &str) -> Option<Dt> {
90    let (s, neg) = strip_neg(s);
91    let (year_part, rest) = take_year(s)?;
92    let rest = expect_char(rest, '-')?;
93    let (month, rest) = take_n_digits(rest, 2)?;
94    let rest = expect_char(rest, '-')?;
95    let (day, rest) = take_n_digits(rest, 2)?;
96    let (offset, rest) = parse_optional_tz(rest);
97    if !rest.is_empty() { return None; }
98    let year = signed(year_part, neg)?;
99    let date = NaiveDate::from_ymd_opt(year, month as u32, day as u32)?;
100    Some(Dt { naive: date.and_time(NaiveTime::MIN), offset })
101}
102
103/// `xs:time` lexical form: `hh:mm:ss[.frac][TZ]?`
104fn parse_xsd_time(s: &str) -> Option<Dt> {
105    let (hour, rest) = take_n_digits(s, 2)?;
106    let rest = expect_char(rest, ':')?;
107    let (minute, rest) = take_n_digits(rest, 2)?;
108    let rest = expect_char(rest, ':')?;
109    let (second, rest) = take_n_digits(rest, 2)?;
110    let (nano, rest) = parse_optional_fraction(rest);
111    let (offset, rest) = parse_optional_tz(rest);
112    if !rest.is_empty() { return None; }
113    let time = NaiveTime::from_hms_nano_opt(hour as u32, minute as u32, second as u32, nano)?;
114    // Anchor time-only values to 1970-01-01 — EXSLT only cares
115    // about the time fields; the date is never re-emitted from a
116    // time-only parse.
117    let date = NaiveDate::from_ymd_opt(1970, 1, 1)?;
118    Some(Dt { naive: date.and_time(time), offset })
119}
120
121/// Try every XSD lexical shape we support, in order of specificity.
122fn parse_any(s: &str) -> Option<Dt> {
123    parse_xsd_datetime(s).or_else(|| parse_xsd_date(s)).or_else(|| parse_xsd_time(s))
124}
125
126// ── duration ──────────────────────────────────────────────────────
127//
128// XSD duration is a hybrid: (years, months) live in calendar
129// space, (days, hours, minutes, seconds) live in fixed-elapsed
130// time.  We can't normalise across the boundary without a
131// reference date (e.g. "1 month" is 28-31 days).  Store both
132// halves as i64 with seconds carrying the sub-second fractional
133// part as nanos.
134
135#[derive(Clone, Copy, Debug)]
136struct XsdDuration {
137    /// Sign applies uniformly across all components per XSD §3.2.6.
138    negative: bool,
139    months:   i64,   // years are folded into months at parse time
140    days:     i64,
141    seconds:  i64,
142    nanos:    i64,
143}
144
145impl XsdDuration {
146    /// `chrono::Months` for the calendar-month portion.
147    fn months_part(&self) -> i64 {
148        if self.negative { -self.months } else { self.months }
149    }
150    /// `chrono::Duration` for the day+time portion (fixed elapsed).
151    fn duration_part(&self) -> chrono::Duration {
152        let total_nanos =
153            (self.days as i128) * 86_400 * 1_000_000_000
154            + (self.seconds as i128) * 1_000_000_000
155            + self.nanos as i128;
156        let signed = if self.negative { -total_nanos } else { total_nanos };
157        chrono::Duration::nanoseconds(signed.clamp(i64::MIN as i128, i64::MAX as i128) as i64)
158    }
159}
160
161/// `xs:duration` lexical form:
162///   `(-)? P (nY)? (nM)? (nD)? (T (nH)? (nM)? (nS | n.fracS))?`
163fn parse_xsd_duration(s: &str) -> Option<XsdDuration> {
164    let (mut s, neg) = strip_neg(s);
165    s = s.strip_prefix('P')?;
166    let mut years = 0i64;
167    let mut months = 0i64;
168    let mut days = 0i64;
169    let mut hours = 0i64;
170    let mut minutes = 0i64;
171    let mut seconds = 0i64;
172    let mut nanos = 0i64;
173    let mut any = false;
174
175    // Date portion: years, months, days.
176    while let Some(c) = s.chars().next() {
177        if c == 'T' { break; }
178        let (n, rest) = take_unsigned_int(s)?;
179        let unit = rest.chars().next()?;
180        s = &rest[unit.len_utf8()..];
181        match unit {
182            'Y' => years = n as i64,
183            'M' => months = n as i64,
184            'D' => days = n as i64,
185            _   => return None,
186        }
187        any = true;
188    }
189    // Time portion.
190    if let Some(rest) = s.strip_prefix('T') {
191        s = rest;
192        if s.is_empty() { return None; }
193        loop {
194            if s.is_empty() { break; }
195            let (n, mut rest) = take_unsigned_int(s)?;
196            // Seconds can carry a fraction.
197            let mut frac_nanos = 0i64;
198            if let Some(after_dot) = rest.strip_prefix('.') {
199                let (digits, after) = take_digits(after_dot);
200                if digits.is_empty() { return None; }
201                frac_nanos = digits_to_nanos(digits);
202                rest = after;
203            }
204            let unit = rest.chars().next()?;
205            s = &rest[unit.len_utf8()..];
206            match unit {
207                'H' => hours = n as i64,
208                'M' => minutes = n as i64,
209                'S' => { seconds = n as i64; nanos = frac_nanos; }
210                _   => return None,
211            }
212            any = true;
213        }
214    }
215    if !any || !s.is_empty() { return None; }
216    let total_months = years.checked_mul(12)?.checked_add(months)?;
217    let total_seconds = hours.checked_mul(3600)?
218        .checked_add(minutes.checked_mul(60)?)?
219        .checked_add(seconds)?;
220    Some(XsdDuration {
221        negative: neg, months: total_months, days, seconds: total_seconds, nanos,
222    })
223}
224
225// ── XSD formatters ───────────────────────────────────────────────
226
227fn format_xsd_datetime(dt: &Dt) -> String {
228    let mut s = format_year(dt.naive.year());
229    s.push_str(&format!(
230        "-{:02}-{:02}T{:02}:{:02}:{:02}",
231        dt.naive.month(), dt.naive.day(),
232        dt.naive.hour(), dt.naive.minute(), dt.naive.second(),
233    ));
234    let nanos = dt.naive.nanosecond();
235    if nanos != 0 {
236        let frac = format!(".{:09}", nanos);
237        s.push_str(frac.trim_end_matches('0'));
238    }
239    if let Some(off) = dt.offset { s.push_str(&format_offset(off)); }
240    s
241}
242
243fn format_xsd_date(dt: &Dt) -> String {
244    let mut s = format_year(dt.naive.year());
245    s.push_str(&format!("-{:02}-{:02}", dt.naive.month(), dt.naive.day()));
246    if let Some(off) = dt.offset { s.push_str(&format_offset(off)); }
247    s
248}
249
250fn format_xsd_time(dt: &Dt) -> String {
251    let mut s = format!(
252        "{:02}:{:02}:{:02}",
253        dt.naive.hour(), dt.naive.minute(), dt.naive.second(),
254    );
255    let nanos = dt.naive.nanosecond();
256    if nanos != 0 {
257        let frac = format!(".{:09}", nanos);
258        s.push_str(frac.trim_end_matches('0'));
259    }
260    if let Some(off) = dt.offset { s.push_str(&format_offset(off)); }
261    s
262}
263
264fn format_year(y: i32) -> String {
265    if y < 0 { format!("-{:04}", -y) } else { format!("{:04}", y) }
266}
267
268fn format_offset(off: FixedOffset) -> String {
269    let secs = off.local_minus_utc();
270    if secs == 0 { return "Z".to_string(); }
271    let (sign, mag) = if secs >= 0 { ('+', secs) } else { ('-', -secs) };
272    format!("{sign}{:02}:{:02}", mag / 3600, (mag % 3600) / 60)
273}
274
275fn format_xsd_duration(d: &XsdDuration) -> String {
276    // XSD §3.2.6: "PnYnMnDTnHnMnS" — components with value 0 are
277    // omitted; if all are 0, the duration is "PT0S".
278    let mut s = String::new();
279    if d.negative { s.push('-'); }
280    s.push('P');
281    let mut any_date = false;
282    let years = d.months / 12;
283    let months = d.months % 12;
284    if years  != 0 { s.push_str(&format!("{years}Y"));  any_date = true; }
285    if months != 0 { s.push_str(&format!("{months}M")); any_date = true; }
286    if d.days != 0 { s.push_str(&format!("{}D", d.days)); any_date = true; }
287    let has_time = d.seconds != 0 || d.nanos != 0;
288    if has_time {
289        s.push('T');
290        let hours   = d.seconds / 3600;
291        let minutes = (d.seconds % 3600) / 60;
292        let secs    = d.seconds % 60;
293        if hours   != 0 { s.push_str(&format!("{hours}H"));   }
294        if minutes != 0 { s.push_str(&format!("{minutes}M")); }
295        if secs != 0 || d.nanos != 0 {
296            if d.nanos == 0 {
297                s.push_str(&format!("{secs}S"));
298            } else {
299                let frac = format!(".{:09}", d.nanos);
300                s.push_str(&format!("{secs}{}S", frac.trim_end_matches('0')));
301            }
302        }
303    } else if !any_date {
304        s.push_str("PT0S".strip_prefix('P').unwrap());
305    }
306    s
307}
308
309// ── parser primitives ────────────────────────────────────────────
310
311fn strip_neg(s: &str) -> (&str, bool) {
312    match s.strip_prefix('-') { Some(rest) => (rest, true), None => (s, false) }
313}
314
315/// XSD year is 4+ digits, no leading zeros once past the first 4.
316fn take_year(s: &str) -> Option<(&str, &str)> {
317    let bytes = s.as_bytes();
318    let mut i = 0;
319    while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
320    if i < 4 { return None; }
321    Some((&s[..i], &s[i..]))
322}
323
324fn take_n_digits(s: &str, n: usize) -> Option<(u32, &str)> {
325    if s.len() < n { return None; }
326    let head = &s[..n];
327    if !head.bytes().all(|b| b.is_ascii_digit()) { return None; }
328    Some((head.parse().ok()?, &s[n..]))
329}
330
331fn take_digits(s: &str) -> (&str, &str) {
332    let bytes = s.as_bytes();
333    let mut i = 0;
334    while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
335    (&s[..i], &s[i..])
336}
337
338fn take_unsigned_int(s: &str) -> Option<(u64, &str)> {
339    let (head, rest) = take_digits(s);
340    if head.is_empty() { return None; }
341    Some((head.parse().ok()?, rest))
342}
343
344fn expect_char(s: &str, c: char) -> Option<&str> {
345    s.strip_prefix(c)
346}
347
348fn signed(year_part: &str, neg: bool) -> Option<i32> {
349    let y: i32 = year_part.parse().ok()?;
350    if y == 0 { return None; }   // XSD: year 0 illegal
351    Some(if neg { -y } else { y })
352}
353
354fn parse_optional_fraction(s: &str) -> (u32, &str) {
355    match s.strip_prefix('.') {
356        Some(rest) => {
357            let (digits, after) = take_digits(rest);
358            if digits.is_empty() { return (0, s); }
359            (digits_to_nanos(digits) as u32, after)
360        }
361        None => (0, s),
362    }
363}
364
365/// Convert a fractional-second digit string to nanoseconds.
366/// `"5"` → 500_000_000, `"123456789"` → 123_456_789, `"1234567890"`
367/// truncates to nanos.
368fn digits_to_nanos(digits: &str) -> i64 {
369    let mut buf = String::with_capacity(9);
370    for (i, c) in digits.chars().enumerate() {
371        if i >= 9 { break; }
372        buf.push(c);
373    }
374    while buf.len() < 9 { buf.push('0'); }
375    buf.parse().unwrap_or(0)
376}
377
378/// `Z` | `+hh:mm` | `-hh:mm` | (nothing).  Returns the offset and
379/// remainder string.
380fn parse_optional_tz(s: &str) -> (Option<FixedOffset>, &str) {
381    if let Some(rest) = s.strip_prefix('Z') {
382        return (FixedOffset::east_opt(0), rest);
383    }
384    let sign = match s.as_bytes().first() {
385        Some(b'+') => 1,
386        Some(b'-') => -1,
387        _ => return (None, s),
388    };
389    let rest = &s[1..];
390    let Some((h, r)) = take_n_digits(rest, 2) else { return (None, s); };
391    let Some(r) = expect_char(r, ':') else { return (None, s); };
392    let Some((m, r)) = take_n_digits(r, 2) else { return (None, s); };
393    let total = sign * ((h as i32) * 3600 + (m as i32) * 60);
394    (FixedOffset::east_opt(total), r)
395}
396
397// ── EXSLT functions ──────────────────────────────────────────────
398
399fn now_dt() -> Dt {
400    // `std::time::SystemTime` → `DateTime<Utc>` without pulling in
401    // `iana-time-zone` (the chrono `clock` feature does pull it in).
402    let dur = std::time::SystemTime::now()
403        .duration_since(std::time::UNIX_EPOCH)
404        .unwrap_or_default();
405    let secs  = dur.as_secs() as i64;
406    let nanos = dur.subsec_nanos();
407    let dt = DateTime::<Utc>::from_timestamp(secs, nanos)
408        .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap());
409    Dt {
410        naive: dt.naive_utc(),
411        offset: Some(FixedOffset::east_opt(0).unwrap()),
412    }
413}
414
415fn arg_str<I: DocIndexLike>(args: &[Value], n: usize, idx: &I) -> Option<String> {
416    args.get(n).map(|v| value_to_string(v, idx))
417}
418
419/// Many EXSLT functions accept an optional dateTime argument and
420/// fall back to "now" if absent.  Returns the parsed value or None
421/// if the supplied string was unparseable (caller emits "").
422fn dt_arg_or_now<I: DocIndexLike>(args: &[Value], idx: &I) -> Option<Dt> {
423    if args.is_empty() {
424        return Some(now_dt());
425    }
426    parse_any(&arg_str(args, 0, idx)?)
427}
428
429pub fn dispatch<I: DocIndexLike>(
430    name: &str, args: Vec<Value>, idx: &I,
431) -> Option<Result<Value>> {
432    let r: Result<Value> = match name {
433        "date-time" => {
434            if !args.is_empty() {
435                return Some(Err(err("date:date-time takes no arguments")));
436            }
437            Ok(Value::String(format_xsd_datetime(&now_dt())))
438        }
439        "date" => Ok(extract_part(&args, idx, |dt| format_xsd_date(&dt))),
440        "time" => Ok(extract_part(&args, idx, |dt| format_xsd_time(&dt))),
441
442        "year"            => Ok(num_field(&args, idx, |dt| dt.naive.year() as f64)),
443        "month-in-year"   => Ok(num_field(&args, idx, |dt| dt.naive.month() as f64)),
444        "day-in-month"    => Ok(num_field(&args, idx, |dt| dt.naive.day() as f64)),
445        "hour-in-day"     => Ok(num_field(&args, idx, |dt| dt.naive.hour() as f64)),
446        "minute-in-hour"  => Ok(num_field(&args, idx, |dt| dt.naive.minute() as f64)),
447        "second-in-minute"=> Ok(num_field(&args, idx, |dt| {
448            // Include fractional seconds (EXSLT spec).
449            dt.naive.second() as f64 + dt.naive.nanosecond() as f64 / 1e9
450        })),
451
452        // Days-of-week: EXSLT numbers Sunday=1..Saturday=7.
453        "day-in-week" => Ok(num_field(&args, idx, |dt| {
454            // chrono's Weekday::number_from_sunday() returns 1..7
455            // with Sunday=1 — exactly EXSLT's numbering.
456            dt.naive.weekday().number_from_sunday() as f64
457        })),
458        "day-name" => Ok(str_field(&args, idx, |dt| match dt.naive.weekday() {
459            chrono::Weekday::Mon => "Monday",   chrono::Weekday::Tue => "Tuesday",
460            chrono::Weekday::Wed => "Wednesday",chrono::Weekday::Thu => "Thursday",
461            chrono::Weekday::Fri => "Friday",   chrono::Weekday::Sat => "Saturday",
462            chrono::Weekday::Sun => "Sunday",
463        })),
464        "day-abbreviation" => Ok(str_field(&args, idx, |dt| match dt.naive.weekday() {
465            chrono::Weekday::Mon => "Mon", chrono::Weekday::Tue => "Tue",
466            chrono::Weekday::Wed => "Wed", chrono::Weekday::Thu => "Thu",
467            chrono::Weekday::Fri => "Fri", chrono::Weekday::Sat => "Sat",
468            chrono::Weekday::Sun => "Sun",
469        })),
470        "month-name" => Ok(str_field(&args, idx, |dt| MONTH_NAMES[(dt.naive.month() - 1) as usize])),
471        "month-abbreviation" => Ok(str_field(&args, idx, |dt| MONTH_ABBR[(dt.naive.month() - 1) as usize])),
472
473        // "Which occurrence in the month is this weekday?"  Day 1-7
474        // is #1, 8-14 is #2, … 22-28 is #4, 29-31 is #5.
475        "day-of-week-in-month" => Ok(num_field(&args, idx, |dt| {
476            ((dt.naive.day() - 1) / 7 + 1) as f64
477        })),
478
479        // Julian day-of-year, 1-based.  Matches chrono's ordinal()
480        // and ISO 8601 day numbering (Jan 1 → 1, Dec 31 → 365/366).
481        "day-in-year"  => Ok(num_field(&args, idx, |dt| dt.naive.ordinal() as f64)),
482        // ISO 8601 week number (1..53).  Weeks start on Monday and
483        // belong to the year that contains the week's Thursday — so
484        // early-Jan dates may report the previous year's last week.
485        "week-in-year" => Ok(num_field(&args, idx, |dt| dt.naive.iso_week().week() as f64)),
486        "leap-year"    => Ok(bool_field(&args, idx, |dt| {
487            // Gregorian leap-year rule: divisible by 4, except
488            // century years which must also be divisible by 400.
489            let y = dt.naive.year();
490            (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
491        })),
492        // EXSLT date:seconds — return seconds-since-Unix-epoch for a
493        // date/dateTime, or total seconds for a duration.  No arg →
494        // now.  Bad input → NaN, mirroring the other num_field
495        // accessors (libexslt returns empty string which coerces to
496        // NaN under XPath number()).
497        "seconds" => Ok(seconds_fn(&args, idx)),
498
499        "add" => Ok(add_fn(&args, idx)),
500        "add-duration" => Ok(add_duration_fn(&args, idx)),
501        "difference" => Ok(difference_fn(&args, idx)),
502        "duration" => Ok(duration_from_seconds(&args, idx)),
503
504        _ => return None,
505    };
506    Some(r)
507}
508
509const MONTH_NAMES: [&str; 12] = [
510    "January", "February", "March", "April", "May", "June",
511    "July", "August", "September", "October", "November", "December",
512];
513const MONTH_ABBR: [&str; 12] = [
514    "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
515];
516
517/// "If the argument is not present, returns the current xs:date /
518/// xs:time"-style helpers.  Bad input → empty string, per spec.
519fn extract_part<I: DocIndexLike>(
520    args: &[Value], idx: &I, fmt: impl FnOnce(Dt) -> String,
521) -> Value {
522    Value::String(dt_arg_or_now(args, idx).map(fmt).unwrap_or_default())
523}
524
525fn num_field<I: DocIndexLike>(
526    args: &[Value], idx: &I, f: impl FnOnce(Dt) -> f64,
527) -> Value {
528    Value::Number(Numeric::Double(dt_arg_or_now(args, idx).map(f).unwrap_or(f64::NAN)))
529}
530
531fn str_field<I: DocIndexLike>(
532    args: &[Value], idx: &I, f: impl FnOnce(Dt) -> &'static str,
533) -> Value {
534    Value::String(dt_arg_or_now(args, idx).map(f).unwrap_or("").to_string())
535}
536
537fn bool_field<I: DocIndexLike>(
538    args: &[Value], idx: &I, f: impl FnOnce(Dt) -> bool,
539) -> Value {
540    Value::Boolean(dt_arg_or_now(args, idx).map(f).unwrap_or(false))
541}
542
543/// `date:seconds(string?)` — seconds as a double.
544///
545/// * Date / dateTime → seconds since `1970-01-01T00:00:00Z` (negative
546///   for pre-epoch values).
547/// * Duration → total seconds (negative when the duration is signed
548///   negative).
549/// * No arg → seconds since epoch for the current dateTime.
550/// * Anything else (unparseable) → NaN, mirroring the other
551///   numeric accessors.
552///
553/// Duration-to-seconds approximation: per EXSLT spec, year and
554/// month components in an `xs:duration` are *imprecise* — they
555/// have no fixed length in seconds.  libexslt uses
556/// `1 year = 365.2422 days` and `1 month = 30.4368 days`.  We
557/// match those constants so the numbers agree on durations
558/// containing year/month parts.
559fn seconds_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Value {
560    if args.is_empty() {
561        return Value::Number(Numeric::Double(now_dt().to_utc().timestamp() as f64));
562    }
563    let s = value_to_string(&args[0], idx);
564    if let Some(dt) = parse_any(&s) {
565        let utc = dt.to_utc();
566        return Value::Number(Numeric::Double(
567            utc.timestamp() as f64 + utc.timestamp_subsec_nanos() as f64 / 1e9
568        ));
569    }
570    if let Some(dur) = parse_xsd_duration(&s) {
571        // Year+month components have no fixed length in seconds.
572        // libexslt approximates with `1 month = 30.4368 days`
573        // (matches `365.2422 / 12` — the mean tropical year over the
574        // Gregorian cycle).  We match those constants so cross-
575        // implementation results agree on year/month durations.
576        const SECONDS_PER_DAY: f64 = 86400.0;
577        const DAYS_PER_MONTH:  f64 = 30.4368;
578        let total =
579              dur.months  as f64 * DAYS_PER_MONTH * SECONDS_PER_DAY
580            + dur.days    as f64 * SECONDS_PER_DAY
581            + dur.seconds as f64
582            + dur.nanos   as f64 / 1e9;
583        return Value::Number(Numeric::Double(if dur.negative { -total } else { total }));
584    }
585    Value::Number(Numeric::Double(f64::NAN))
586}
587
588/// `date:add(date, duration)` — return the date offset by duration.
589fn add_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Value {
590    if args.len() != 2 { return Value::String(String::new()); }
591    let Some(date) = parse_any(&value_to_string(&args[0], idx)) else {
592        return Value::String(String::new());
593    };
594    let Some(dur)  = parse_xsd_duration(&value_to_string(&args[1], idx)) else {
595        return Value::String(String::new());
596    };
597    let m = dur.months_part();
598    // chrono::Months::new takes u32; sign handled separately.
599    let mut nd = date.naive;
600    if m != 0 {
601        let months = chrono::Months::new(m.unsigned_abs() as u32);
602        nd = if m > 0 {
603            nd.checked_add_months(months)
604        } else {
605            nd.checked_sub_months(months)
606        }
607        .unwrap_or(nd);   // overflow → keep input date (libexslt fallback)
608    }
609    let new_dt = nd.checked_add_signed(dur.duration_part()).unwrap_or(nd);
610    let result = Dt { naive: new_dt, offset: date.offset };
611    // Re-emit in the input's lexical shape — heuristic: if the
612    // input parses as xs:date (no T), emit xs:date; else xs:dateTime.
613    let s = value_to_string(&args[0], idx);
614    let out = if parse_xsd_date(&s).is_some() && !s.contains('T') {
615        format_xsd_date(&result)
616    } else {
617        format_xsd_datetime(&result)
618    };
619    Value::String(out)
620}
621
622/// `date:add-duration(d1, d2)`.  XSD §E adding-duration-to-duration:
623/// only valid when both have the same calendar/fixed split (i.e.
624/// you can't sensibly add "1 month" to "30 days" without a ref date)
625/// — EXSLT just adds component-wise and lets the user worry about it.
626fn add_duration_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Value {
627    if args.len() != 2 { return Value::String(String::new()); }
628    let Some(a) = parse_xsd_duration(&value_to_string(&args[0], idx)) else {
629        return Value::String(String::new());
630    };
631    let Some(b) = parse_xsd_duration(&value_to_string(&args[1], idx)) else {
632        return Value::String(String::new());
633    };
634    let sa = if a.negative { -1i64 } else { 1 };
635    let sb = if b.negative { -1i64 } else { 1 };
636    let months   = sa * a.months   + sb * b.months;
637    let days     = sa * a.days     + sb * b.days;
638    let seconds  = sa * a.seconds  + sb * b.seconds;
639    let nanos    = sa * a.nanos    + sb * b.nanos;
640    let negative = months < 0 || (months == 0 && (days < 0 || (days == 0 && (seconds < 0 || nanos < 0))));
641    let abs_months  = months.abs();
642    let abs_days    = days.abs();
643    let abs_seconds = seconds.abs();
644    let abs_nanos   = nanos.abs();
645    Value::String(format_xsd_duration(&XsdDuration {
646        negative, months: abs_months, days: abs_days, seconds: abs_seconds, nanos: abs_nanos,
647    }))
648}
649
650/// `date:difference(start, end)` — returns duration (end − start)
651/// as xs:duration.  Cross-zone safe via UTC normalisation.
652fn difference_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Value {
653    if args.len() != 2 { return Value::String(String::new()); }
654    let Some(a) = parse_any(&value_to_string(&args[0], idx)) else {
655        return Value::String(String::new());
656    };
657    let Some(b) = parse_any(&value_to_string(&args[1], idx)) else {
658        return Value::String(String::new());
659    };
660    let delta_secs = b.to_utc().signed_duration_since(a.to_utc()).num_seconds();
661    let negative = delta_secs < 0;
662    Value::String(format_xsd_duration(&XsdDuration {
663        negative,
664        months: 0,
665        days:    delta_secs.unsigned_abs() as i64 / 86_400,
666        seconds: delta_secs.unsigned_abs() as i64 % 86_400,
667        nanos:   0,
668    }))
669}
670
671/// `date:duration(seconds)` — turn a numeric second count into an
672/// xs:duration string.  Negative is allowed; fractional is allowed.
673fn duration_from_seconds<I: DocIndexLike>(args: &[Value], idx: &I) -> Value {
674    if args.len() != 1 { return Value::String(String::new()); }
675    let n = crate::xpath::eval::value_to_number(&args[0], idx);
676    if !n.is_finite() { return Value::String(String::new()); }
677    let neg  = n < 0.0;
678    let abs  = n.abs();
679    let secs = abs.trunc() as i64;
680    let nanos = (abs.fract() * 1e9).round() as i64;
681    Value::String(format_xsd_duration(&XsdDuration {
682        negative: neg,
683        months: 0,
684        days:    secs / 86_400,
685        seconds: secs % 86_400,
686        nanos,
687    }))
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::xpath::XPathContext;
694    use crate::{parse_str, ParseOptions};
695
696    fn tiny() -> sup_xml_tree::dom::Document {
697        parse_str("<r/>", &ParseOptions::default()).unwrap()
698    }
699    fn s(v: &Value) -> String {
700        if let Value::String(s) = v { s.clone() } else { panic!("expected string, got {v:?}") }
701    }
702    fn n(v: &Value) -> f64 {
703        if let Value::Number(n) = v { n.as_f64() } else { panic!("expected number, got {v:?}") }
704    }
705    fn b(v: &Value) -> bool {
706        if let Value::Boolean(b) = v { *b } else { panic!("expected boolean, got {v:?}") }
707    }
708
709    // ── parser round-trips ──────────────────────────────────────
710
711    #[test]
712    fn datetime_parse_basic() {
713        let dt = parse_xsd_datetime("2024-03-15T14:30:45Z").unwrap();
714        assert_eq!(dt.naive.year(), 2024);
715        assert_eq!(dt.naive.month(), 3);
716        assert_eq!(dt.naive.day(), 15);
717        assert_eq!(dt.naive.hour(), 14);
718        assert_eq!(dt.offset, Some(FixedOffset::east_opt(0).unwrap()));
719    }
720
721    #[test]
722    fn datetime_parse_with_offset() {
723        let dt = parse_xsd_datetime("2024-03-15T14:30:45+05:30").unwrap();
724        assert_eq!(dt.offset.unwrap().local_minus_utc(), 5*3600 + 30*60);
725    }
726
727    #[test]
728    fn datetime_parse_fractional_seconds() {
729        let dt = parse_xsd_datetime("2024-03-15T14:30:45.5Z").unwrap();
730        assert_eq!(dt.naive.nanosecond(), 500_000_000);
731    }
732
733    #[test]
734    fn datetime_rejects_year_zero() {
735        assert!(parse_xsd_datetime("0000-01-01T00:00:00Z").is_none());
736    }
737
738    #[test]
739    fn date_parse_negative_year() {
740        let dt = parse_xsd_date("-0044-03-15").unwrap();
741        assert_eq!(dt.naive.year(), -44);
742    }
743
744    #[test]
745    fn duration_parse_full() {
746        let d = parse_xsd_duration("P1Y2M3DT4H5M6S").unwrap();
747        assert_eq!(d.months, 14);
748        assert_eq!(d.days,   3);
749        assert_eq!(d.seconds, 4*3600 + 5*60 + 6);
750    }
751
752    #[test]
753    fn duration_parse_negative_with_fraction() {
754        let d = parse_xsd_duration("-PT1.5S").unwrap();
755        assert!(d.negative);
756        assert_eq!(d.seconds, 1);
757        assert_eq!(d.nanos, 500_000_000);
758    }
759
760    #[test]
761    fn duration_rejects_empty() {
762        assert!(parse_xsd_duration("P").is_none());
763        assert!(parse_xsd_duration("PT").is_none());
764    }
765
766    // ── dispatch ────────────────────────────────────────────────
767
768    #[test]
769    fn year_extracts_year_field() {
770        let doc = tiny();
771        let ctx = XPathContext::new(&doc);
772        let v = dispatch("year",
773            vec![Value::String("2024-03-15T00:00:00Z".into())],
774            &ctx.index).unwrap().unwrap();
775        assert_eq!(n(&v), 2024.0);
776    }
777
778    #[test]
779    fn day_in_week_uses_sunday_one_convention() {
780        let doc = tiny();
781        let ctx = XPathContext::new(&doc);
782        // 2024-03-17 is a Sunday → 1.
783        let v = dispatch("day-in-week",
784            vec![Value::String("2024-03-17".into())], &ctx.index).unwrap().unwrap();
785        assert_eq!(n(&v), 1.0);
786        // 2024-03-16 is a Saturday → 7.
787        let v = dispatch("day-in-week",
788            vec![Value::String("2024-03-16".into())], &ctx.index).unwrap().unwrap();
789        assert_eq!(n(&v), 7.0);
790    }
791
792    #[test]
793    fn day_name_returns_full_name() {
794        let doc = tiny();
795        let ctx = XPathContext::new(&doc);
796        let v = dispatch("day-name",
797            vec![Value::String("2024-03-15".into())], &ctx.index).unwrap().unwrap();
798        assert_eq!(s(&v), "Friday");
799    }
800
801    #[test]
802    fn month_name_returns_full_name() {
803        let doc = tiny();
804        let ctx = XPathContext::new(&doc);
805        let v = dispatch("month-name",
806            vec![Value::String("2024-03-15".into())], &ctx.index).unwrap().unwrap();
807        assert_eq!(s(&v), "March");
808    }
809
810    // ── day-in-year / week-in-year / leap-year / seconds ───────
811
812    #[test]
813    fn day_in_year_jan_first_is_one() {
814        let doc = tiny();
815        let ctx = XPathContext::new(&doc);
816        let v = dispatch("day-in-year",
817            vec![Value::String("2024-01-01".into())], &ctx.index).unwrap().unwrap();
818        assert_eq!(n(&v), 1.0);
819    }
820
821    #[test]
822    fn day_in_year_leap_year_dec_31_is_366() {
823        let doc = tiny();
824        let ctx = XPathContext::new(&doc);
825        let v = dispatch("day-in-year",
826            vec![Value::String("2024-12-31".into())], &ctx.index).unwrap().unwrap();
827        assert_eq!(n(&v), 366.0);
828        let v = dispatch("day-in-year",
829            vec![Value::String("2023-12-31".into())], &ctx.index).unwrap().unwrap();
830        assert_eq!(n(&v), 365.0);
831    }
832
833    #[test]
834    fn week_in_year_iso_8601_numbering() {
835        let doc = tiny();
836        let ctx = XPathContext::new(&doc);
837        // 2024-01-01 is a Monday → week 1 of 2024.
838        let v = dispatch("week-in-year",
839            vec![Value::String("2024-01-01".into())], &ctx.index).unwrap().unwrap();
840        assert_eq!(n(&v), 1.0);
841        // 2023-01-01 is a Sunday → ISO week 52 of 2022.
842        let v = dispatch("week-in-year",
843            vec![Value::String("2023-01-01".into())], &ctx.index).unwrap().unwrap();
844        assert_eq!(n(&v), 52.0);
845    }
846
847    #[test]
848    fn leap_year_handles_century_exception() {
849        let doc = tiny();
850        let ctx = XPathContext::new(&doc);
851        let v = dispatch("leap-year",
852            vec![Value::String("2024-06-15".into())], &ctx.index).unwrap().unwrap();
853        assert!(b(&v));
854        let v = dispatch("leap-year",
855            vec![Value::String("2023-06-15".into())], &ctx.index).unwrap().unwrap();
856        assert!(!b(&v));
857        // 1900 divisible by 100 but not 400 → not leap.
858        let v = dispatch("leap-year",
859            vec![Value::String("1900-06-15".into())], &ctx.index).unwrap().unwrap();
860        assert!(!b(&v));
861        // 2000 divisible by 400 → leap.
862        let v = dispatch("leap-year",
863            vec![Value::String("2000-06-15".into())], &ctx.index).unwrap().unwrap();
864        assert!(b(&v));
865    }
866
867    #[test]
868    fn seconds_from_datetime_is_unix_epoch_seconds() {
869        let doc = tiny();
870        let ctx = XPathContext::new(&doc);
871        let v = dispatch("seconds",
872            vec![Value::String("1970-01-01T00:00:00Z".into())], &ctx.index).unwrap().unwrap();
873        assert_eq!(n(&v), 0.0);
874        let v = dispatch("seconds",
875            vec![Value::String("1970-01-01T00:01:30Z".into())], &ctx.index).unwrap().unwrap();
876        assert_eq!(n(&v), 90.0);
877    }
878
879    #[test]
880    fn seconds_from_date_treats_as_midnight_utc() {
881        let doc = tiny();
882        let ctx = XPathContext::new(&doc);
883        let v = dispatch("seconds",
884            vec![Value::String("1970-01-02".into())], &ctx.index).unwrap().unwrap();
885        assert_eq!(n(&v), 86400.0);
886    }
887
888    #[test]
889    fn seconds_from_duration_sums_components() {
890        let doc = tiny();
891        let ctx = XPathContext::new(&doc);
892        let v = dispatch("seconds",
893            vec![Value::String("PT1H30M".into())], &ctx.index).unwrap().unwrap();
894        assert_eq!(n(&v), 5400.0);
895        let v = dispatch("seconds",
896            vec![Value::String("-PT10S".into())], &ctx.index).unwrap().unwrap();
897        assert_eq!(n(&v), -10.0);
898    }
899
900    #[test]
901    fn seconds_bad_input_is_nan() {
902        let doc = tiny();
903        let ctx = XPathContext::new(&doc);
904        let v = dispatch("seconds",
905            vec![Value::String("not a date".into())], &ctx.index).unwrap().unwrap();
906        assert!(n(&v).is_nan());
907    }
908
909    // ── arithmetic ─────────────────────────────────────────────
910
911    #[test]
912    fn add_one_month_to_jan_31_clips_to_feb_29_in_leap_year() {
913        let doc = tiny();
914        let ctx = XPathContext::new(&doc);
915        let v = dispatch("add",
916            vec![Value::String("2024-01-31".into()), Value::String("P1M".into())],
917            &ctx.index).unwrap().unwrap();
918        assert_eq!(s(&v), "2024-02-29");
919    }
920
921    #[test]
922    fn add_one_month_to_jan_31_clips_to_feb_28_in_non_leap_year() {
923        let doc = tiny();
924        let ctx = XPathContext::new(&doc);
925        let v = dispatch("add",
926            vec![Value::String("2023-01-31".into()), Value::String("P1M".into())],
927            &ctx.index).unwrap().unwrap();
928        assert_eq!(s(&v), "2023-02-28");
929    }
930
931    #[test]
932    fn add_negative_duration_subtracts() {
933        let doc = tiny();
934        let ctx = XPathContext::new(&doc);
935        let v = dispatch("add",
936            vec![Value::String("2024-03-15".into()), Value::String("-P1D".into())],
937            &ctx.index).unwrap().unwrap();
938        assert_eq!(s(&v), "2024-03-14");
939    }
940
941    #[test]
942    fn difference_returns_xsd_duration() {
943        let doc = tiny();
944        let ctx = XPathContext::new(&doc);
945        let v = dispatch("difference",
946            vec![
947                Value::String("2024-01-01T00:00:00Z".into()),
948                Value::String("2024-01-02T00:00:00Z".into()),
949            ], &ctx.index).unwrap().unwrap();
950        assert_eq!(s(&v), "P1D");
951    }
952
953    #[test]
954    fn duration_from_seconds_formats_correctly() {
955        let doc = tiny();
956        let ctx = XPathContext::new(&doc);
957        let v = dispatch("duration",
958            vec![Value::Number(Numeric::Double(90061.5))], &ctx.index).unwrap().unwrap();
959        // 90061.5s = 1 day + 1 hour + 1 minute + 1.5 second.
960        assert_eq!(s(&v), "P1DT1H1M1.5S");
961    }
962
963    // ── defensive behaviour ────────────────────────────────────
964
965    #[test]
966    fn malformed_input_returns_empty_not_error() {
967        let doc = tiny();
968        let ctx = XPathContext::new(&doc);
969        let v = dispatch("year",
970            vec![Value::String("not-a-date".into())], &ctx.index).unwrap().unwrap();
971        // Spec: bad input → NaN (for number-returning) / "" (for string-returning).
972        assert!(n(&v).is_nan());
973    }
974
975    #[test]
976    fn unknown_function_returns_none() {
977        let doc = tiny();
978        let ctx = XPathContext::new(&doc);
979        assert!(dispatch("nonsense-name", vec![], &ctx.index).is_none());
980    }
981
982    // ── parse_xsd_time ──────────────────────────────────────────────
983
984    #[test]
985    fn parse_xsd_time_basic() {
986        let dt = parse_xsd_time("14:30:45Z").unwrap();
987        assert_eq!(dt.naive.hour(), 14);
988        assert_eq!(dt.naive.minute(), 30);
989        assert_eq!(dt.naive.second(), 45);
990        // Date defaults to 1970-01-01.
991        assert_eq!(dt.naive.year(), 1970);
992        assert_eq!(dt.naive.month(), 1);
993        assert_eq!(dt.naive.day(), 1);
994    }
995
996    #[test]
997    fn parse_xsd_time_with_fractional_and_offset() {
998        let dt = parse_xsd_time("14:30:45.25+05:30").unwrap();
999        assert_eq!(dt.naive.nanosecond(), 250_000_000);
1000        assert_eq!(dt.offset.unwrap().local_minus_utc(), 5*3600 + 30*60);
1001    }
1002
1003    #[test]
1004    fn parse_xsd_time_no_offset() {
1005        let dt = parse_xsd_time("14:30:45").unwrap();
1006        assert!(dt.offset.is_none());
1007    }
1008
1009    #[test]
1010    fn parse_xsd_time_rejects_garbage() {
1011        assert!(parse_xsd_time("14:30").is_none());
1012        assert!(parse_xsd_time("14:30:45Zextra").is_none());
1013        assert!(parse_xsd_time("99:30:45").is_none());
1014    }
1015
1016    // ── duration parser error paths ─────────────────────────────────
1017
1018    #[test]
1019    fn parse_xsd_duration_rejects_bad_designator_in_date_part() {
1020        // 'P' part allows only Y/M/D — any other letter → None.
1021        assert!(parse_xsd_duration("P1X").is_none());
1022    }
1023
1024    #[test]
1025    fn parse_xsd_duration_rejects_bad_designator_in_time_part() {
1026        // 'T' part allows H/M/S — anything else → None.
1027        assert!(parse_xsd_duration("PT1X").is_none());
1028    }
1029
1030    // ── formatters ──────────────────────────────────────────────────
1031
1032    #[test]
1033    fn format_xsd_datetime_round_trip() {
1034        // No fractional seconds, Z offset.
1035        let dt = parse_xsd_datetime("2024-03-15T14:30:45Z").unwrap();
1036        assert_eq!(format_xsd_datetime(&dt), "2024-03-15T14:30:45Z");
1037    }
1038
1039    #[test]
1040    fn format_xsd_datetime_with_fraction_and_offset() {
1041        let dt = parse_xsd_datetime("2024-03-15T14:30:45.5+05:30").unwrap();
1042        let out = format_xsd_datetime(&dt);
1043        assert!(out.starts_with("2024-03-15T14:30:45.5"));
1044        assert!(out.ends_with("+05:30"));
1045    }
1046
1047    #[test]
1048    fn format_xsd_datetime_no_offset() {
1049        let dt = parse_xsd_datetime("2024-03-15T14:30:45").unwrap();
1050        // No offset → no Z/sign at the end.
1051        let out = format_xsd_datetime(&dt);
1052        assert_eq!(out, "2024-03-15T14:30:45");
1053    }
1054
1055    #[test]
1056    fn format_xsd_time_via_dispatch() {
1057        let doc = tiny();
1058        let ctx = XPathContext::new(&doc);
1059        let v = dispatch("time",
1060            vec![Value::String("2024-03-15T14:30:45.5Z".into())],
1061            &ctx.index).unwrap().unwrap();
1062        let out = s(&v);
1063        assert!(out.starts_with("14:30:45"));
1064        assert!(out.contains('Z'));
1065    }
1066
1067    #[test]
1068    fn format_offset_zero_is_z() {
1069        let off = FixedOffset::east_opt(0).unwrap();
1070        assert_eq!(format_offset(off), "Z");
1071    }
1072
1073    #[test]
1074    fn format_offset_negative() {
1075        let off = FixedOffset::west_opt(5 * 3600).unwrap();
1076        assert_eq!(format_offset(off), "-05:00");
1077    }
1078
1079    #[test]
1080    fn format_offset_positive_non_aligned() {
1081        let off = FixedOffset::east_opt(5 * 3600 + 30 * 60).unwrap();
1082        assert_eq!(format_offset(off), "+05:30");
1083    }
1084
1085    #[test]
1086    fn format_xsd_duration_zero_emits_pt0s() {
1087        let d = XsdDuration {
1088            negative: false, months: 0, days: 0, seconds: 0, nanos: 0,
1089        };
1090        let out = format_xsd_duration(&d);
1091        assert_eq!(out, "PT0S");
1092    }
1093
1094    #[test]
1095    fn format_xsd_duration_seconds_only() {
1096        let d = XsdDuration {
1097            negative: false, months: 0, days: 0, seconds: 45, nanos: 0,
1098        };
1099        assert_eq!(format_xsd_duration(&d), "PT45S");
1100    }
1101
1102    // ── TZ parser: negative offset ──────────────────────────────────
1103
1104    #[test]
1105    fn parse_optional_tz_negative_offset() {
1106        let dt = parse_xsd_datetime("2024-03-15T14:30:45-08:00").unwrap();
1107        assert_eq!(dt.offset.unwrap().local_minus_utc(), -8 * 3600);
1108    }
1109
1110    // ── now_dt / date-time dispatch ─────────────────────────────────
1111
1112    #[test]
1113    fn date_time_dispatch_with_no_args_returns_current_time() {
1114        let doc = tiny();
1115        let ctx = XPathContext::new(&doc);
1116        let v = dispatch("date-time", vec![], &ctx.index).unwrap().unwrap();
1117        let out = s(&v);
1118        // Smoke test: must parse back as a valid xs:dateTime.
1119        assert!(parse_xsd_datetime(&out).is_some(), "got {out:?}");
1120    }
1121
1122    #[test]
1123    fn date_time_dispatch_rejects_args() {
1124        let doc = tiny();
1125        let ctx = XPathContext::new(&doc);
1126        let r = dispatch("date-time",
1127            vec![Value::String("ignored".into())], &ctx.index).unwrap();
1128        assert!(r.is_err());
1129    }
1130
1131    // ── num_field/str_field without args use now_dt ─────────────────
1132
1133    #[test]
1134    fn year_with_no_args_returns_current_year() {
1135        let doc = tiny();
1136        let ctx = XPathContext::new(&doc);
1137        let v = dispatch("year", vec![], &ctx.index).unwrap().unwrap();
1138        let y = n(&v);
1139        // Should be a plausible year, not NaN.
1140        assert!(y >= 2020.0 && y <= 3000.0, "got {y}");
1141    }
1142
1143    // ── second-in-minute includes fractional seconds ────────────────
1144
1145    #[test]
1146    fn second_in_minute_includes_fraction() {
1147        let doc = tiny();
1148        let ctx = XPathContext::new(&doc);
1149        let v = dispatch("second-in-minute",
1150            vec![Value::String("2024-03-15T14:30:45.25Z".into())],
1151            &ctx.index).unwrap().unwrap();
1152        let got = n(&v);
1153        assert!((got - 45.25).abs() < 1e-9, "got {got}");
1154    }
1155
1156    // ── all weekday names + abbreviations ───────────────────────────
1157
1158    #[test]
1159    fn all_weekday_names() {
1160        let doc = tiny();
1161        let ctx = XPathContext::new(&doc);
1162        // 2024-03-11 = Monday → 2024-03-17 = Sunday.
1163        let cases = [
1164            ("2024-03-11", "Monday",    "Mon"),
1165            ("2024-03-12", "Tuesday",   "Tue"),
1166            ("2024-03-13", "Wednesday", "Wed"),
1167            ("2024-03-14", "Thursday",  "Thu"),
1168            ("2024-03-15", "Friday",    "Fri"),
1169            ("2024-03-16", "Saturday",  "Sat"),
1170            ("2024-03-17", "Sunday",    "Sun"),
1171        ];
1172        for (date, full, abbr) in cases {
1173            let v = dispatch("day-name",
1174                vec![Value::String(date.into())], &ctx.index).unwrap().unwrap();
1175            assert_eq!(s(&v), full, "day-name for {date}");
1176            let v = dispatch("day-abbreviation",
1177                vec![Value::String(date.into())], &ctx.index).unwrap().unwrap();
1178            assert_eq!(s(&v), abbr, "day-abbreviation for {date}");
1179        }
1180    }
1181
1182    #[test]
1183    fn all_month_names() {
1184        let doc = tiny();
1185        let ctx = XPathContext::new(&doc);
1186        let names = ["January","February","March","April","May","June",
1187                     "July","August","September","October","November","December"];
1188        let abbrs = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
1189        for (i, (name, abbr)) in names.iter().zip(abbrs.iter()).enumerate() {
1190            let date = format!("2024-{:02}-15", i + 1);
1191            let v = dispatch("month-name",
1192                vec![Value::String(date.clone())], &ctx.index).unwrap().unwrap();
1193            assert_eq!(s(&v), *name, "month {} ({date})", i + 1);
1194            let v = dispatch("month-abbreviation",
1195                vec![Value::String(date)], &ctx.index).unwrap().unwrap();
1196            assert_eq!(s(&v), *abbr);
1197        }
1198    }
1199
1200    // ── day-of-week-in-month ────────────────────────────────────────
1201
1202    #[test]
1203    fn day_of_week_in_month_counts_weekday_occurrences() {
1204        let doc = tiny();
1205        let ctx = XPathContext::new(&doc);
1206        let cases = [
1207            ("2024-03-01", 1.0),    // day 1
1208            ("2024-03-07", 1.0),    // day 7
1209            ("2024-03-08", 2.0),    // day 8
1210            ("2024-03-15", 3.0),    // day 15
1211            ("2024-03-22", 4.0),    // day 22
1212            ("2024-03-29", 5.0),    // day 29
1213        ];
1214        for (date, expected) in cases {
1215            let v = dispatch("day-of-week-in-month",
1216                vec![Value::String(date.into())], &ctx.index).unwrap().unwrap();
1217            assert_eq!(n(&v), expected, "{date}");
1218        }
1219    }
1220
1221    // ── add-duration ────────────────────────────────────────────────
1222
1223    #[test]
1224    fn add_duration_combines_two_positive_durations() {
1225        let doc = tiny();
1226        let ctx = XPathContext::new(&doc);
1227        let v = dispatch("add-duration",
1228            vec![Value::String("P1Y".into()), Value::String("P6M".into())],
1229            &ctx.index).unwrap().unwrap();
1230        assert_eq!(s(&v), "P1Y6M");
1231    }
1232
1233    #[test]
1234    fn add_duration_with_negative_yields_partial_cancellation() {
1235        let doc = tiny();
1236        let ctx = XPathContext::new(&doc);
1237        // P1Y + (-P6M) = P6M
1238        let v = dispatch("add-duration",
1239            vec![Value::String("P1Y".into()), Value::String("-P6M".into())],
1240            &ctx.index).unwrap().unwrap();
1241        assert_eq!(s(&v), "P6M");
1242    }
1243
1244    #[test]
1245    fn add_duration_rejects_non_duration_args() {
1246        let doc = tiny();
1247        let ctx = XPathContext::new(&doc);
1248        let v = dispatch("add-duration",
1249            vec![Value::String("not-a-duration".into()), Value::String("P1M".into())],
1250            &ctx.index).unwrap().unwrap();
1251        assert_eq!(s(&v), "");
1252    }
1253
1254    #[test]
1255    fn add_duration_wrong_argc_returns_empty() {
1256        let doc = tiny();
1257        let ctx = XPathContext::new(&doc);
1258        let v = dispatch("add-duration",
1259            vec![Value::String("P1Y".into())], &ctx.index).unwrap().unwrap();
1260        assert_eq!(s(&v), "");
1261    }
1262
1263    // ── add edge cases ──────────────────────────────────────────────
1264
1265    #[test]
1266    fn add_emits_datetime_when_input_has_time_part() {
1267        let doc = tiny();
1268        let ctx = XPathContext::new(&doc);
1269        let v = dispatch("add",
1270            vec![Value::String("2024-03-15T12:00:00Z".into()),
1271                 Value::String("P1D".into())],
1272            &ctx.index).unwrap().unwrap();
1273        let out = s(&v);
1274        assert!(out.contains('T'), "got {out}");
1275    }
1276
1277    #[test]
1278    fn add_wrong_argc_returns_empty() {
1279        let doc = tiny();
1280        let ctx = XPathContext::new(&doc);
1281        let v = dispatch("add",
1282            vec![Value::String("2024-01-01".into())], &ctx.index).unwrap().unwrap();
1283        assert_eq!(s(&v), "");
1284    }
1285
1286    #[test]
1287    fn add_with_malformed_date_returns_empty() {
1288        let doc = tiny();
1289        let ctx = XPathContext::new(&doc);
1290        let v = dispatch("add",
1291            vec![Value::String("not-a-date".into()),
1292                 Value::String("P1D".into())],
1293            &ctx.index).unwrap().unwrap();
1294        assert_eq!(s(&v), "");
1295    }
1296
1297    #[test]
1298    fn add_with_malformed_duration_returns_empty() {
1299        let doc = tiny();
1300        let ctx = XPathContext::new(&doc);
1301        let v = dispatch("add",
1302            vec![Value::String("2024-01-01".into()),
1303                 Value::String("not-a-duration".into())],
1304            &ctx.index).unwrap().unwrap();
1305        assert_eq!(s(&v), "");
1306    }
1307
1308    // ── difference edge cases ───────────────────────────────────────
1309
1310    #[test]
1311    fn difference_negative_when_end_before_start() {
1312        let doc = tiny();
1313        let ctx = XPathContext::new(&doc);
1314        let v = dispatch("difference",
1315            vec![Value::String("2024-01-02T00:00:00Z".into()),
1316                 Value::String("2024-01-01T00:00:00Z".into())],
1317            &ctx.index).unwrap().unwrap();
1318        assert!(s(&v).starts_with('-'));
1319    }
1320
1321    #[test]
1322    fn difference_wrong_argc_returns_empty() {
1323        let doc = tiny();
1324        let ctx = XPathContext::new(&doc);
1325        let v = dispatch("difference",
1326            vec![Value::String("2024-01-01".into())], &ctx.index).unwrap().unwrap();
1327        assert_eq!(s(&v), "");
1328    }
1329
1330    #[test]
1331    fn difference_malformed_args_return_empty() {
1332        let doc = tiny();
1333        let ctx = XPathContext::new(&doc);
1334        let v = dispatch("difference",
1335            vec![Value::String("oops".into()),
1336                 Value::String("2024-01-01T00:00:00Z".into())],
1337            &ctx.index).unwrap().unwrap();
1338        assert_eq!(s(&v), "");
1339        let v = dispatch("difference",
1340            vec![Value::String("2024-01-01T00:00:00Z".into()),
1341                 Value::String("oops".into())],
1342            &ctx.index).unwrap().unwrap();
1343        assert_eq!(s(&v), "");
1344    }
1345
1346    // ── duration() edge cases ───────────────────────────────────────
1347
1348    #[test]
1349    fn duration_negative_seconds() {
1350        let doc = tiny();
1351        let ctx = XPathContext::new(&doc);
1352        let v = dispatch("duration",
1353            vec![Value::Number(Numeric::Double(-90.0))], &ctx.index).unwrap().unwrap();
1354        assert!(s(&v).starts_with('-'));
1355    }
1356
1357    #[test]
1358    fn duration_wrong_argc_returns_empty() {
1359        let doc = tiny();
1360        let ctx = XPathContext::new(&doc);
1361        let v = dispatch("duration", vec![], &ctx.index).unwrap().unwrap();
1362        assert_eq!(s(&v), "");
1363    }
1364
1365    #[test]
1366    fn duration_non_finite_returns_empty() {
1367        let doc = tiny();
1368        let ctx = XPathContext::new(&doc);
1369        let v = dispatch("duration",
1370            vec![Value::Number(Numeric::Double(f64::NAN))], &ctx.index).unwrap().unwrap();
1371        assert_eq!(s(&v), "");
1372        let v = dispatch("duration",
1373            vec![Value::Number(Numeric::Double(f64::INFINITY))], &ctx.index).unwrap().unwrap();
1374        assert_eq!(s(&v), "");
1375    }
1376
1377    // ── month-in-year, day-in-month, hour-in-day, minute-in-hour ────
1378
1379    #[test]
1380    fn extract_individual_fields() {
1381        let doc = tiny();
1382        let ctx = XPathContext::new(&doc);
1383        let dt = "2024-03-15T14:30:45Z";
1384        assert_eq!(n(&dispatch("month-in-year",
1385            vec![Value::String(dt.into())], &ctx.index).unwrap().unwrap()), 3.0);
1386        assert_eq!(n(&dispatch("day-in-month",
1387            vec![Value::String(dt.into())], &ctx.index).unwrap().unwrap()), 15.0);
1388        assert_eq!(n(&dispatch("hour-in-day",
1389            vec![Value::String(dt.into())], &ctx.index).unwrap().unwrap()), 14.0);
1390        assert_eq!(n(&dispatch("minute-in-hour",
1391            vec![Value::String(dt.into())], &ctx.index).unwrap().unwrap()), 30.0);
1392    }
1393
1394    // ── date dispatch from a dateTime input ─────────────────────────
1395
1396    #[test]
1397    fn date_dispatch_extracts_date_part_from_datetime() {
1398        let doc = tiny();
1399        let ctx = XPathContext::new(&doc);
1400        let v = dispatch("date",
1401            vec![Value::String("2024-03-15T14:30:45Z".into())],
1402            &ctx.index).unwrap().unwrap();
1403        assert!(s(&v).starts_with("2024-03-15"));
1404    }
1405}