Skip to main content

xsd_schema/xpath/functions/
datetime.rs

1//! XPath 2.0 datetime functions.
2//!
3//! This module implements datetime/duration functions from the XPath 2.0 specification:
4//! - Current time functions (current-dateTime, current-date, current-time, implicit-timezone)
5//! - Duration component extraction (years/months/days/hours/minutes/seconds-from-duration)
6//! - DateTime component extraction (year/month/day/hours/minutes/seconds/timezone-from-dateTime)
7//! - Date component extraction (year/month/day/timezone-from-date)
8//! - Time component extraction (hours/minutes/seconds/timezone-from-time)
9//! - DateTime constructor (fn:dateTime)
10//! - Timezone adjustment (adjust-dateTime/date/time-to-timezone)
11
12use chrono::Local;
13use num_bigint::BigInt;
14use rust_decimal::prelude::ToPrimitive;
15use rust_decimal::Decimal;
16
17use crate::types::value::{
18    DateTimeValue, DateValue, DayTimeDurationValue, DurationValue, TimeValue, TimezoneOffset,
19    XmlAtomicValue, XmlValue, XmlValueKind, YearMonthDurationValue,
20};
21use crate::types::XmlTypeCode;
22use crate::xpath::context::DynamicContext;
23use crate::xpath::error::XPathError;
24use crate::xpath::DomNavigator;
25
26use super::{atomize_to_single_opt, XPathValue};
27
28// ============================================================================
29// Helper Functions
30// ============================================================================
31
32/// Get the implicit timezone offset from the system.
33fn get_implicit_timezone_offset() -> TimezoneOffset {
34    let seconds = Local::now().offset().local_minus_utc();
35    TimezoneOffset((seconds / 60) as i16)
36}
37
38/// Convert a TimezoneOffset to a DayTimeDurationValue.
39fn timezone_to_day_time_duration(tz: TimezoneOffset) -> DayTimeDurationValue {
40    let total_minutes = tz.0.unsigned_abs() as u32;
41    let hours = total_minutes / 60;
42    let minutes = total_minutes % 60;
43    DayTimeDurationValue {
44        negative: tz.0 < 0,
45        days: 0,
46        hours,
47        minutes,
48        seconds: Decimal::ZERO,
49    }
50}
51
52/// Extract a DayTimeDurationValue as a TimezoneOffset.
53/// Returns FODT0003 if:
54/// - The offset is outside the valid range (+-14:00)
55/// - The duration has non-zero day components
56/// - The duration has non-zero or fractional seconds
57fn day_time_duration_to_timezone(dur: &DayTimeDurationValue) -> Result<TimezoneOffset, XPathError> {
58    // XPath 2.0 spec: timezone must be an integral number of minutes with no days
59    // and must be in the range -PT14H to PT14H inclusive.
60
61    // Reject durations with days component
62    if dur.days != 0 {
63        return Err(XPathError::FODT0003 {
64            value: format_day_time_duration(dur),
65        });
66    }
67
68    // Reject durations with non-zero seconds (including fractional)
69    if dur.seconds != Decimal::ZERO {
70        return Err(XPathError::FODT0003 {
71            value: format_day_time_duration(dur),
72        });
73    }
74
75    // Calculate total minutes (now we know there are no days or seconds)
76    let total_minutes = dur.hours as i64 * 60 + dur.minutes as i64;
77    let signed_minutes = if dur.negative {
78        -total_minutes
79    } else {
80        total_minutes
81    };
82
83    // Validate range: must be within +-14:00 (+-840 minutes)
84    if !(-840..=840).contains(&signed_minutes) {
85        return Err(XPathError::FODT0003 {
86            value: format_day_time_duration(dur),
87        });
88    }
89
90    Ok(TimezoneOffset(signed_minutes as i16))
91}
92
93/// Format a DayTimeDurationValue for error messages.
94fn format_day_time_duration(dur: &DayTimeDurationValue) -> String {
95    let mut s = String::new();
96    if dur.negative {
97        s.push('-');
98    }
99    s.push_str("PT");
100    if dur.days != 0 {
101        s.push_str(&format!("{}D", dur.days));
102    }
103    if dur.hours != 0 {
104        s.push_str(&format!("{}H", dur.hours));
105    }
106    if dur.minutes != 0 {
107        s.push_str(&format!("{}M", dur.minutes));
108    }
109    if dur.seconds != Decimal::ZERO {
110        s.push_str(&format!("{}S", dur.seconds));
111    }
112    if s.len() == 2 || (s.len() == 3 && s.starts_with('-')) {
113        s.push_str("0S");
114    }
115    s
116}
117
118/// Validate that a timezone offset is in the valid range (+-14:00).
119fn validate_timezone_offset(minutes: i16) -> Result<(), XPathError> {
120    if !(-840..=840).contains(&minutes) {
121        return Err(XPathError::FODT0003 {
122            value: format!("{}:{:02}", minutes / 60, (minutes % 60).abs()),
123        });
124    }
125    Ok(())
126}
127
128/// Extract datetime value from XmlValue.
129fn as_datetime(value: &XmlValue) -> Option<&DateTimeValue> {
130    match &value.value {
131        XmlValueKind::Atomic(XmlAtomicValue::DateTime(v)) => Some(v),
132        _ => None,
133    }
134}
135
136/// Extract date value from XmlValue.
137fn as_date(value: &XmlValue) -> Option<&DateValue> {
138    match &value.value {
139        XmlValueKind::Atomic(XmlAtomicValue::Date(v)) => Some(v),
140        _ => None,
141    }
142}
143
144/// Extract time value from XmlValue.
145fn as_time(value: &XmlValue) -> Option<&TimeValue> {
146    match &value.value {
147        XmlValueKind::Atomic(XmlAtomicValue::Time(v)) => Some(v),
148        _ => None,
149    }
150}
151
152/// Extract duration value from XmlValue (xs:duration).
153fn as_duration(value: &XmlValue) -> Option<&DurationValue> {
154    match &value.value {
155        XmlValueKind::Atomic(XmlAtomicValue::Duration(v)) => Some(v),
156        _ => None,
157    }
158}
159
160/// Extract yearMonthDuration value from XmlValue.
161fn as_year_month_duration(value: &XmlValue) -> Option<&YearMonthDurationValue> {
162    match &value.value {
163        XmlValueKind::Atomic(XmlAtomicValue::YearMonthDuration(v)) => Some(v),
164        _ => None,
165    }
166}
167
168/// Extract dayTimeDuration value from XmlValue.
169fn as_day_time_duration(value: &XmlValue) -> Option<&DayTimeDurationValue> {
170    match &value.value {
171        XmlValueKind::Atomic(XmlAtomicValue::DayTimeDuration(v)) => Some(v),
172        _ => None,
173    }
174}
175
176/// Check if value is a duration type (duration, yearMonthDuration, or dayTimeDuration).
177fn is_duration_type(code: XmlTypeCode) -> bool {
178    matches!(
179        code,
180        XmlTypeCode::Duration | XmlTypeCode::YearMonthDuration | XmlTypeCode::DayTimeDuration
181    )
182}
183
184/// Create an XmlValue containing an integer.
185fn xml_integer(i: i64) -> XmlValue {
186    XmlValue {
187        type_code: XmlTypeCode::Integer,
188        schema_type: None,
189        value: XmlValueKind::Atomic(XmlAtomicValue::Integer(BigInt::from(i))),
190    }
191}
192
193/// Create an XmlValue containing a decimal.
194fn xml_decimal(d: Decimal) -> XmlValue {
195    XmlValue {
196        type_code: XmlTypeCode::Decimal,
197        schema_type: None,
198        value: XmlValueKind::Atomic(XmlAtomicValue::Decimal(d)),
199    }
200}
201
202/// Create an XmlValue containing a dayTimeDuration.
203fn xml_day_time_duration(value: DayTimeDurationValue) -> XmlValue {
204    XmlValue {
205        type_code: XmlTypeCode::DayTimeDuration,
206        schema_type: None,
207        value: XmlValueKind::Atomic(XmlAtomicValue::DayTimeDuration(value)),
208    }
209}
210
211/// Create an XmlValue containing a dateTime.
212fn xml_datetime(value: DateTimeValue) -> XmlValue {
213    XmlValue {
214        type_code: XmlTypeCode::DateTime,
215        schema_type: None,
216        value: XmlValueKind::Atomic(XmlAtomicValue::DateTime(value)),
217    }
218}
219
220/// Create an XmlValue containing a date.
221fn xml_date(value: DateValue) -> XmlValue {
222    XmlValue {
223        type_code: XmlTypeCode::Date,
224        schema_type: None,
225        value: XmlValueKind::Atomic(XmlAtomicValue::Date(value)),
226    }
227}
228
229/// Create an XmlValue containing a time.
230fn xml_time(value: TimeValue) -> XmlValue {
231    XmlValue {
232        type_code: XmlTypeCode::Time,
233        schema_type: None,
234        value: XmlValueKind::Atomic(XmlAtomicValue::Time(value)),
235    }
236}
237
238// ============================================================================
239// A. Current Time Functions (4 functions)
240// ============================================================================
241
242/// Implements fn:current-dateTime() - returns the current date and time.
243///
244/// The value is cached in the dynamic context for the duration of the query.
245/// Uses the implicit timezone from context if set, otherwise uses local timezone.
246/// Preserves fractional seconds from the system clock.
247pub fn current_datetime<N: DomNavigator>(
248    context: &mut DynamicContext<'_, N>,
249    args: Vec<XPathValue<N>>,
250) -> Result<XPathValue<N>, XPathError> {
251    if !args.is_empty() {
252        return Err(XPathError::wrong_number_of_arguments(
253            "current-dateTime",
254            0,
255            args.len(),
256        ));
257    }
258
259    // Use cached value or create new one
260    let dt = if let Some(ref cached) = context.current_datetime {
261        cached.clone()
262    } else {
263        let now = Local::now();
264
265        // Use context implicit_timezone if set, otherwise use local timezone
266        let tz_offset = context
267            .implicit_timezone
268            .unwrap_or_else(get_implicit_timezone_offset);
269
270        // Extract seconds with fractional part
271        // chrono's timestamp_subsec_nanos gives nanoseconds within the second
272        let secs = now.format("%S").to_string().parse::<u32>().unwrap_or(0);
273        let nanos = now.timestamp_subsec_nanos();
274        // Convert to Decimal: seconds + nanoseconds/1_000_000_000
275        let second = Decimal::from(secs) + Decimal::from(nanos) / Decimal::from(1_000_000_000u64);
276
277        let dt = DateTimeValue {
278            year: now.format("%Y").to_string().parse().unwrap_or(2000),
279            month: now.format("%m").to_string().parse().unwrap_or(1),
280            day: now.format("%d").to_string().parse().unwrap_or(1),
281            hour: now.format("%H").to_string().parse().unwrap_or(0),
282            minute: now.format("%M").to_string().parse().unwrap_or(0),
283            second,
284            timezone: Some(tz_offset),
285        };
286        context.current_datetime = Some(dt.clone());
287        dt
288    };
289
290    Ok(XPathValue::from_atomic(xml_datetime(dt)))
291}
292
293/// Implements fn:current-date() - returns the current date.
294pub fn current_date<N: DomNavigator>(
295    context: &mut DynamicContext<'_, N>,
296    args: Vec<XPathValue<N>>,
297) -> Result<XPathValue<N>, XPathError> {
298    if !args.is_empty() {
299        return Err(XPathError::wrong_number_of_arguments(
300            "current-date",
301            0,
302            args.len(),
303        ));
304    }
305
306    // Get current-dateTime and extract date portion
307    let dt_value = current_datetime(context, vec![])?;
308    let dt = match dt_value {
309        XPathValue::Item(item) => {
310            if let crate::xpath::iterator::XmlItem::Atomic(v) = item {
311                as_datetime(&v).cloned()
312            } else {
313                None
314            }
315        }
316        _ => None,
317    };
318
319    let dt = dt.ok_or_else(|| XPathError::internal("Failed to get current dateTime"))?;
320
321    let date = DateValue {
322        year: dt.year,
323        month: dt.month,
324        day: dt.day,
325        timezone: dt.timezone,
326    };
327
328    Ok(XPathValue::from_atomic(xml_date(date)))
329}
330
331/// Implements fn:current-time() - returns the current time.
332pub fn current_time<N: DomNavigator>(
333    context: &mut DynamicContext<'_, N>,
334    args: Vec<XPathValue<N>>,
335) -> Result<XPathValue<N>, XPathError> {
336    if !args.is_empty() {
337        return Err(XPathError::wrong_number_of_arguments(
338            "current-time",
339            0,
340            args.len(),
341        ));
342    }
343
344    // Get current-dateTime and extract time portion
345    let dt_value = current_datetime(context, vec![])?;
346    let dt = match dt_value {
347        XPathValue::Item(item) => {
348            if let crate::xpath::iterator::XmlItem::Atomic(v) = item {
349                as_datetime(&v).cloned()
350            } else {
351                None
352            }
353        }
354        _ => None,
355    };
356
357    let dt = dt.ok_or_else(|| XPathError::internal("Failed to get current dateTime"))?;
358
359    let time = TimeValue {
360        hour: dt.hour,
361        minute: dt.minute,
362        second: dt.second,
363        timezone: dt.timezone,
364    };
365
366    Ok(XPathValue::from_atomic(xml_time(time)))
367}
368
369/// Implements fn:implicit-timezone() - returns the implicit timezone as a dayTimeDuration.
370pub fn implicit_timezone<N: DomNavigator>(
371    context: &mut DynamicContext<'_, N>,
372    args: Vec<XPathValue<N>>,
373) -> Result<XPathValue<N>, XPathError> {
374    if !args.is_empty() {
375        return Err(XPathError::wrong_number_of_arguments(
376            "implicit-timezone",
377            0,
378            args.len(),
379        ));
380    }
381
382    let tz = context
383        .implicit_timezone
384        .unwrap_or_else(get_implicit_timezone_offset);
385
386    let duration = timezone_to_day_time_duration(tz);
387    Ok(XPathValue::from_atomic(xml_day_time_duration(duration)))
388}
389
390// ============================================================================
391// B. Duration Component Extraction (6 functions)
392// ============================================================================
393
394/// Helper: Normalize year-month duration to total months, then extract years component.
395/// The years-from-duration function computes total_months / 12 for normalized result.
396fn normalized_years_from_ym(years: u32, months: u32, negative: bool) -> i64 {
397    let total_months = years as i64 * 12 + months as i64;
398    let years_component = total_months / 12;
399    if negative {
400        -years_component
401    } else {
402        years_component
403    }
404}
405
406/// Helper: Normalize year-month duration to total months, then extract months component.
407/// The months-from-duration function computes total_months % 12 for normalized result.
408fn normalized_months_from_ym(years: u32, months: u32, negative: bool) -> i64 {
409    let total_months = years as i64 * 12 + months as i64;
410    let months_component = total_months % 12;
411    if negative {
412        -months_component
413    } else {
414        months_component
415    }
416}
417
418/// Helper: Normalize day-time duration to total seconds, then extract days component.
419fn normalized_days_from_dt(
420    days: u32,
421    hours: u32,
422    minutes: u32,
423    seconds: Decimal,
424    negative: bool,
425) -> i64 {
426    let total_seconds = Decimal::from(days) * Decimal::from(86400)
427        + Decimal::from(hours) * Decimal::from(3600)
428        + Decimal::from(minutes) * Decimal::from(60)
429        + seconds;
430    // Integer division for days
431    let days_component = (total_seconds / Decimal::from(86400))
432        .floor()
433        .to_i64()
434        .unwrap_or(0);
435    if negative {
436        -days_component
437    } else {
438        days_component
439    }
440}
441
442/// Helper: Normalize day-time duration to total seconds, then extract hours component (0-23).
443fn normalized_hours_from_dt(
444    days: u32,
445    hours: u32,
446    minutes: u32,
447    seconds: Decimal,
448    negative: bool,
449) -> i64 {
450    let total_seconds = Decimal::from(days) * Decimal::from(86400)
451        + Decimal::from(hours) * Decimal::from(3600)
452        + Decimal::from(minutes) * Decimal::from(60)
453        + seconds;
454    // Remainder after removing days, then integer divide by 3600
455    let remainder_after_days = total_seconds % Decimal::from(86400);
456    let hours_component = (remainder_after_days / Decimal::from(3600))
457        .floor()
458        .to_i64()
459        .unwrap_or(0);
460    if negative {
461        -hours_component
462    } else {
463        hours_component
464    }
465}
466
467/// Helper: Normalize day-time duration to total seconds, then extract minutes component (0-59).
468fn normalized_minutes_from_dt(
469    days: u32,
470    hours: u32,
471    minutes: u32,
472    seconds: Decimal,
473    negative: bool,
474) -> i64 {
475    let total_seconds = Decimal::from(days) * Decimal::from(86400)
476        + Decimal::from(hours) * Decimal::from(3600)
477        + Decimal::from(minutes) * Decimal::from(60)
478        + seconds;
479    // Remainder after removing days and hours, then integer divide by 60
480    let remainder_after_hours = total_seconds % Decimal::from(3600);
481    let minutes_component = (remainder_after_hours / Decimal::from(60))
482        .floor()
483        .to_i64()
484        .unwrap_or(0);
485    if negative {
486        -minutes_component
487    } else {
488        minutes_component
489    }
490}
491
492/// Helper: Normalize day-time duration to total seconds, then extract seconds component (0-59.xxx).
493fn normalized_seconds_from_dt(
494    days: u32,
495    hours: u32,
496    minutes: u32,
497    seconds: Decimal,
498    negative: bool,
499) -> Decimal {
500    let total_seconds = Decimal::from(days) * Decimal::from(86400)
501        + Decimal::from(hours) * Decimal::from(3600)
502        + Decimal::from(minutes) * Decimal::from(60)
503        + seconds;
504    // Remainder after removing minutes (keep fractional part)
505    let seconds_component = total_seconds % Decimal::from(60);
506    if negative {
507        -seconds_component
508    } else {
509        seconds_component
510    }
511}
512
513/// Implements fn:years-from-duration($arg) - extracts years from a duration.
514pub fn years_from_duration<N: DomNavigator>(
515    _context: &mut DynamicContext<'_, N>,
516    mut args: Vec<XPathValue<N>>,
517) -> Result<XPathValue<N>, XPathError> {
518    if args.len() != 1 {
519        return Err(XPathError::wrong_number_of_arguments(
520            "years-from-duration",
521            1,
522            args.len(),
523        ));
524    }
525
526    let arg = args.remove(0);
527    let value = match atomize_to_single_opt(arg)? {
528        None => return Ok(XPathValue::Empty),
529        Some(v) => v,
530    };
531
532    if !is_duration_type(value.type_code) {
533        return Err(XPathError::XPTY0004 {
534            expected: "xs:duration".to_string(),
535            found: format!("{:?}", value.type_code),
536        });
537    }
538
539    // Extract years component with normalization
540    let result = if let Some(dur) = as_duration(&value) {
541        // For xs:duration, use the year-month portion normalized
542        normalized_years_from_ym(dur.years, dur.months, dur.negative)
543    } else if let Some(ymd) = as_year_month_duration(&value) {
544        normalized_years_from_ym(ymd.years, ymd.months, ymd.negative)
545    } else if as_day_time_duration(&value).is_some() {
546        // dayTimeDuration has no years component
547        0
548    } else {
549        return Err(XPathError::internal("Unexpected duration type"));
550    };
551
552    Ok(XPathValue::from_atomic(xml_integer(result)))
553}
554
555/// Implements fn:months-from-duration($arg) - extracts months from a duration.
556pub fn months_from_duration<N: DomNavigator>(
557    _context: &mut DynamicContext<'_, N>,
558    mut args: Vec<XPathValue<N>>,
559) -> Result<XPathValue<N>, XPathError> {
560    if args.len() != 1 {
561        return Err(XPathError::wrong_number_of_arguments(
562            "months-from-duration",
563            1,
564            args.len(),
565        ));
566    }
567
568    let arg = args.remove(0);
569    let value = match atomize_to_single_opt(arg)? {
570        None => return Ok(XPathValue::Empty),
571        Some(v) => v,
572    };
573
574    if !is_duration_type(value.type_code) {
575        return Err(XPathError::XPTY0004 {
576            expected: "xs:duration".to_string(),
577            found: format!("{:?}", value.type_code),
578        });
579    }
580
581    // Extract months component (0-11) with normalization
582    let result = if let Some(dur) = as_duration(&value) {
583        normalized_months_from_ym(dur.years, dur.months, dur.negative)
584    } else if let Some(ymd) = as_year_month_duration(&value) {
585        normalized_months_from_ym(ymd.years, ymd.months, ymd.negative)
586    } else if as_day_time_duration(&value).is_some() {
587        // dayTimeDuration has no months component
588        0
589    } else {
590        return Err(XPathError::internal("Unexpected duration type"));
591    };
592
593    Ok(XPathValue::from_atomic(xml_integer(result)))
594}
595
596/// Implements fn:days-from-duration($arg) - extracts days from a duration.
597pub fn days_from_duration<N: DomNavigator>(
598    _context: &mut DynamicContext<'_, N>,
599    mut args: Vec<XPathValue<N>>,
600) -> Result<XPathValue<N>, XPathError> {
601    if args.len() != 1 {
602        return Err(XPathError::wrong_number_of_arguments(
603            "days-from-duration",
604            1,
605            args.len(),
606        ));
607    }
608
609    let arg = args.remove(0);
610    let value = match atomize_to_single_opt(arg)? {
611        None => return Ok(XPathValue::Empty),
612        Some(v) => v,
613    };
614
615    if !is_duration_type(value.type_code) {
616        return Err(XPathError::XPTY0004 {
617            expected: "xs:duration".to_string(),
618            found: format!("{:?}", value.type_code),
619        });
620    }
621
622    // Extract days component from day-time portion with normalization
623    let result = if let Some(dur) = as_duration(&value) {
624        normalized_days_from_dt(dur.days, dur.hours, dur.minutes, dur.seconds, dur.negative)
625    } else if as_year_month_duration(&value).is_some() {
626        // yearMonthDuration has no days component
627        0
628    } else if let Some(dtd) = as_day_time_duration(&value) {
629        normalized_days_from_dt(dtd.days, dtd.hours, dtd.minutes, dtd.seconds, dtd.negative)
630    } else {
631        return Err(XPathError::internal("Unexpected duration type"));
632    };
633
634    Ok(XPathValue::from_atomic(xml_integer(result)))
635}
636
637/// Implements fn:hours-from-duration($arg) - extracts hours from a duration.
638pub fn hours_from_duration<N: DomNavigator>(
639    _context: &mut DynamicContext<'_, N>,
640    mut args: Vec<XPathValue<N>>,
641) -> Result<XPathValue<N>, XPathError> {
642    if args.len() != 1 {
643        return Err(XPathError::wrong_number_of_arguments(
644            "hours-from-duration",
645            1,
646            args.len(),
647        ));
648    }
649
650    let arg = args.remove(0);
651    let value = match atomize_to_single_opt(arg)? {
652        None => return Ok(XPathValue::Empty),
653        Some(v) => v,
654    };
655
656    if !is_duration_type(value.type_code) {
657        return Err(XPathError::XPTY0004 {
658            expected: "xs:duration".to_string(),
659            found: format!("{:?}", value.type_code),
660        });
661    }
662
663    // Extract hours component (0-23) with normalization
664    let result = if let Some(dur) = as_duration(&value) {
665        normalized_hours_from_dt(dur.days, dur.hours, dur.minutes, dur.seconds, dur.negative)
666    } else if as_year_month_duration(&value).is_some() {
667        // yearMonthDuration has no hours component
668        0
669    } else if let Some(dtd) = as_day_time_duration(&value) {
670        normalized_hours_from_dt(dtd.days, dtd.hours, dtd.minutes, dtd.seconds, dtd.negative)
671    } else {
672        return Err(XPathError::internal("Unexpected duration type"));
673    };
674
675    Ok(XPathValue::from_atomic(xml_integer(result)))
676}
677
678/// Implements fn:minutes-from-duration($arg) - extracts minutes from a duration.
679pub fn minutes_from_duration<N: DomNavigator>(
680    _context: &mut DynamicContext<'_, N>,
681    mut args: Vec<XPathValue<N>>,
682) -> Result<XPathValue<N>, XPathError> {
683    if args.len() != 1 {
684        return Err(XPathError::wrong_number_of_arguments(
685            "minutes-from-duration",
686            1,
687            args.len(),
688        ));
689    }
690
691    let arg = args.remove(0);
692    let value = match atomize_to_single_opt(arg)? {
693        None => return Ok(XPathValue::Empty),
694        Some(v) => v,
695    };
696
697    if !is_duration_type(value.type_code) {
698        return Err(XPathError::XPTY0004 {
699            expected: "xs:duration".to_string(),
700            found: format!("{:?}", value.type_code),
701        });
702    }
703
704    // Extract minutes component (0-59) with normalization
705    let result = if let Some(dur) = as_duration(&value) {
706        normalized_minutes_from_dt(dur.days, dur.hours, dur.minutes, dur.seconds, dur.negative)
707    } else if as_year_month_duration(&value).is_some() {
708        // yearMonthDuration has no minutes component
709        0
710    } else if let Some(dtd) = as_day_time_duration(&value) {
711        normalized_minutes_from_dt(dtd.days, dtd.hours, dtd.minutes, dtd.seconds, dtd.negative)
712    } else {
713        return Err(XPathError::internal("Unexpected duration type"));
714    };
715
716    Ok(XPathValue::from_atomic(xml_integer(result)))
717}
718
719/// Implements fn:seconds-from-duration($arg) - extracts seconds from a duration.
720pub fn seconds_from_duration<N: DomNavigator>(
721    _context: &mut DynamicContext<'_, N>,
722    mut args: Vec<XPathValue<N>>,
723) -> Result<XPathValue<N>, XPathError> {
724    if args.len() != 1 {
725        return Err(XPathError::wrong_number_of_arguments(
726            "seconds-from-duration",
727            1,
728            args.len(),
729        ));
730    }
731
732    let arg = args.remove(0);
733    let value = match atomize_to_single_opt(arg)? {
734        None => return Ok(XPathValue::Empty),
735        Some(v) => v,
736    };
737
738    if !is_duration_type(value.type_code) {
739        return Err(XPathError::XPTY0004 {
740            expected: "xs:duration".to_string(),
741            found: format!("{:?}", value.type_code),
742        });
743    }
744
745    // Extract seconds component (0-59.xxx) with normalization
746    let result = if let Some(dur) = as_duration(&value) {
747        normalized_seconds_from_dt(dur.days, dur.hours, dur.minutes, dur.seconds, dur.negative)
748    } else if as_year_month_duration(&value).is_some() {
749        // yearMonthDuration has no seconds component
750        Decimal::ZERO
751    } else if let Some(dtd) = as_day_time_duration(&value) {
752        normalized_seconds_from_dt(dtd.days, dtd.hours, dtd.minutes, dtd.seconds, dtd.negative)
753    } else {
754        return Err(XPathError::internal("Unexpected duration type"));
755    };
756
757    Ok(XPathValue::from_atomic(xml_decimal(result)))
758}
759
760// ============================================================================
761// C. DateTime Component Extraction (7 functions)
762// ============================================================================
763
764/// Implements fn:year-from-dateTime($arg) - extracts year from a dateTime.
765pub fn year_from_datetime<N: DomNavigator>(
766    _context: &mut DynamicContext<'_, N>,
767    mut args: Vec<XPathValue<N>>,
768) -> Result<XPathValue<N>, XPathError> {
769    if args.len() != 1 {
770        return Err(XPathError::wrong_number_of_arguments(
771            "year-from-dateTime",
772            1,
773            args.len(),
774        ));
775    }
776
777    let arg = args.remove(0);
778    let value = match atomize_to_single_opt(arg)? {
779        None => return Ok(XPathValue::Empty),
780        Some(v) => v,
781    };
782
783    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
784        expected: "xs:dateTime".to_string(),
785        found: format!("{:?}", value.type_code),
786    })?;
787
788    Ok(XPathValue::from_atomic(xml_integer(dt.year as i64)))
789}
790
791/// Implements fn:month-from-dateTime($arg) - extracts month from a dateTime.
792pub fn month_from_datetime<N: DomNavigator>(
793    _context: &mut DynamicContext<'_, N>,
794    mut args: Vec<XPathValue<N>>,
795) -> Result<XPathValue<N>, XPathError> {
796    if args.len() != 1 {
797        return Err(XPathError::wrong_number_of_arguments(
798            "month-from-dateTime",
799            1,
800            args.len(),
801        ));
802    }
803
804    let arg = args.remove(0);
805    let value = match atomize_to_single_opt(arg)? {
806        None => return Ok(XPathValue::Empty),
807        Some(v) => v,
808    };
809
810    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
811        expected: "xs:dateTime".to_string(),
812        found: format!("{:?}", value.type_code),
813    })?;
814
815    Ok(XPathValue::from_atomic(xml_integer(dt.month as i64)))
816}
817
818/// Implements fn:day-from-dateTime($arg) - extracts day from a dateTime.
819pub fn day_from_datetime<N: DomNavigator>(
820    _context: &mut DynamicContext<'_, N>,
821    mut args: Vec<XPathValue<N>>,
822) -> Result<XPathValue<N>, XPathError> {
823    if args.len() != 1 {
824        return Err(XPathError::wrong_number_of_arguments(
825            "day-from-dateTime",
826            1,
827            args.len(),
828        ));
829    }
830
831    let arg = args.remove(0);
832    let value = match atomize_to_single_opt(arg)? {
833        None => return Ok(XPathValue::Empty),
834        Some(v) => v,
835    };
836
837    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
838        expected: "xs:dateTime".to_string(),
839        found: format!("{:?}", value.type_code),
840    })?;
841
842    Ok(XPathValue::from_atomic(xml_integer(dt.day as i64)))
843}
844
845/// Implements fn:hours-from-dateTime($arg) - extracts hours from a dateTime.
846pub fn hours_from_datetime<N: DomNavigator>(
847    _context: &mut DynamicContext<'_, N>,
848    mut args: Vec<XPathValue<N>>,
849) -> Result<XPathValue<N>, XPathError> {
850    if args.len() != 1 {
851        return Err(XPathError::wrong_number_of_arguments(
852            "hours-from-dateTime",
853            1,
854            args.len(),
855        ));
856    }
857
858    let arg = args.remove(0);
859    let value = match atomize_to_single_opt(arg)? {
860        None => return Ok(XPathValue::Empty),
861        Some(v) => v,
862    };
863
864    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
865        expected: "xs:dateTime".to_string(),
866        found: format!("{:?}", value.type_code),
867    })?;
868
869    Ok(XPathValue::from_atomic(xml_integer(dt.hour as i64)))
870}
871
872/// Implements fn:minutes-from-dateTime($arg) - extracts minutes from a dateTime.
873pub fn minutes_from_datetime<N: DomNavigator>(
874    _context: &mut DynamicContext<'_, N>,
875    mut args: Vec<XPathValue<N>>,
876) -> Result<XPathValue<N>, XPathError> {
877    if args.len() != 1 {
878        return Err(XPathError::wrong_number_of_arguments(
879            "minutes-from-dateTime",
880            1,
881            args.len(),
882        ));
883    }
884
885    let arg = args.remove(0);
886    let value = match atomize_to_single_opt(arg)? {
887        None => return Ok(XPathValue::Empty),
888        Some(v) => v,
889    };
890
891    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
892        expected: "xs:dateTime".to_string(),
893        found: format!("{:?}", value.type_code),
894    })?;
895
896    Ok(XPathValue::from_atomic(xml_integer(dt.minute as i64)))
897}
898
899/// Implements fn:seconds-from-dateTime($arg) - extracts seconds from a dateTime.
900pub fn seconds_from_datetime<N: DomNavigator>(
901    _context: &mut DynamicContext<'_, N>,
902    mut args: Vec<XPathValue<N>>,
903) -> Result<XPathValue<N>, XPathError> {
904    if args.len() != 1 {
905        return Err(XPathError::wrong_number_of_arguments(
906            "seconds-from-dateTime",
907            1,
908            args.len(),
909        ));
910    }
911
912    let arg = args.remove(0);
913    let value = match atomize_to_single_opt(arg)? {
914        None => return Ok(XPathValue::Empty),
915        Some(v) => v,
916    };
917
918    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
919        expected: "xs:dateTime".to_string(),
920        found: format!("{:?}", value.type_code),
921    })?;
922
923    Ok(XPathValue::from_atomic(xml_decimal(dt.second)))
924}
925
926/// Implements fn:timezone-from-dateTime($arg) - extracts timezone from a dateTime.
927pub fn timezone_from_datetime<N: DomNavigator>(
928    _context: &mut DynamicContext<'_, N>,
929    mut args: Vec<XPathValue<N>>,
930) -> Result<XPathValue<N>, XPathError> {
931    if args.len() != 1 {
932        return Err(XPathError::wrong_number_of_arguments(
933            "timezone-from-dateTime",
934            1,
935            args.len(),
936        ));
937    }
938
939    let arg = args.remove(0);
940    let value = match atomize_to_single_opt(arg)? {
941        None => return Ok(XPathValue::Empty),
942        Some(v) => v,
943    };
944
945    let dt = as_datetime(&value).ok_or_else(|| XPathError::XPTY0004 {
946        expected: "xs:dateTime".to_string(),
947        found: format!("{:?}", value.type_code),
948    })?;
949
950    match dt.timezone {
951        Some(tz) => {
952            let duration = timezone_to_day_time_duration(tz);
953            Ok(XPathValue::from_atomic(xml_day_time_duration(duration)))
954        }
955        None => Ok(XPathValue::Empty),
956    }
957}
958
959// ============================================================================
960// D. Date Component Extraction (4 functions)
961// ============================================================================
962
963/// Implements fn:year-from-date($arg) - extracts year from a date.
964pub fn year_from_date<N: DomNavigator>(
965    _context: &mut DynamicContext<'_, N>,
966    mut args: Vec<XPathValue<N>>,
967) -> Result<XPathValue<N>, XPathError> {
968    if args.len() != 1 {
969        return Err(XPathError::wrong_number_of_arguments(
970            "year-from-date",
971            1,
972            args.len(),
973        ));
974    }
975
976    let arg = args.remove(0);
977    let value = match atomize_to_single_opt(arg)? {
978        None => return Ok(XPathValue::Empty),
979        Some(v) => v,
980    };
981
982    let date = as_date(&value).ok_or_else(|| XPathError::XPTY0004 {
983        expected: "xs:date".to_string(),
984        found: format!("{:?}", value.type_code),
985    })?;
986
987    Ok(XPathValue::from_atomic(xml_integer(date.year as i64)))
988}
989
990/// Implements fn:month-from-date($arg) - extracts month from a date.
991pub fn month_from_date<N: DomNavigator>(
992    _context: &mut DynamicContext<'_, N>,
993    mut args: Vec<XPathValue<N>>,
994) -> Result<XPathValue<N>, XPathError> {
995    if args.len() != 1 {
996        return Err(XPathError::wrong_number_of_arguments(
997            "month-from-date",
998            1,
999            args.len(),
1000        ));
1001    }
1002
1003    let arg = args.remove(0);
1004    let value = match atomize_to_single_opt(arg)? {
1005        None => return Ok(XPathValue::Empty),
1006        Some(v) => v,
1007    };
1008
1009    let date = as_date(&value).ok_or_else(|| XPathError::XPTY0004 {
1010        expected: "xs:date".to_string(),
1011        found: format!("{:?}", value.type_code),
1012    })?;
1013
1014    Ok(XPathValue::from_atomic(xml_integer(date.month as i64)))
1015}
1016
1017/// Implements fn:day-from-date($arg) - extracts day from a date.
1018pub fn day_from_date<N: DomNavigator>(
1019    _context: &mut DynamicContext<'_, N>,
1020    mut args: Vec<XPathValue<N>>,
1021) -> Result<XPathValue<N>, XPathError> {
1022    if args.len() != 1 {
1023        return Err(XPathError::wrong_number_of_arguments(
1024            "day-from-date",
1025            1,
1026            args.len(),
1027        ));
1028    }
1029
1030    let arg = args.remove(0);
1031    let value = match atomize_to_single_opt(arg)? {
1032        None => return Ok(XPathValue::Empty),
1033        Some(v) => v,
1034    };
1035
1036    let date = as_date(&value).ok_or_else(|| XPathError::XPTY0004 {
1037        expected: "xs:date".to_string(),
1038        found: format!("{:?}", value.type_code),
1039    })?;
1040
1041    Ok(XPathValue::from_atomic(xml_integer(date.day as i64)))
1042}
1043
1044/// Implements fn:timezone-from-date($arg) - extracts timezone from a date.
1045pub fn timezone_from_date<N: DomNavigator>(
1046    _context: &mut DynamicContext<'_, N>,
1047    mut args: Vec<XPathValue<N>>,
1048) -> Result<XPathValue<N>, XPathError> {
1049    if args.len() != 1 {
1050        return Err(XPathError::wrong_number_of_arguments(
1051            "timezone-from-date",
1052            1,
1053            args.len(),
1054        ));
1055    }
1056
1057    let arg = args.remove(0);
1058    let value = match atomize_to_single_opt(arg)? {
1059        None => return Ok(XPathValue::Empty),
1060        Some(v) => v,
1061    };
1062
1063    let date = as_date(&value).ok_or_else(|| XPathError::XPTY0004 {
1064        expected: "xs:date".to_string(),
1065        found: format!("{:?}", value.type_code),
1066    })?;
1067
1068    match date.timezone {
1069        Some(tz) => {
1070            let duration = timezone_to_day_time_duration(tz);
1071            Ok(XPathValue::from_atomic(xml_day_time_duration(duration)))
1072        }
1073        None => Ok(XPathValue::Empty),
1074    }
1075}
1076
1077// ============================================================================
1078// E. Time Component Extraction (4 functions)
1079// ============================================================================
1080
1081/// Implements fn:hours-from-time($arg) - extracts hours from a time.
1082pub fn hours_from_time<N: DomNavigator>(
1083    _context: &mut DynamicContext<'_, N>,
1084    mut args: Vec<XPathValue<N>>,
1085) -> Result<XPathValue<N>, XPathError> {
1086    if args.len() != 1 {
1087        return Err(XPathError::wrong_number_of_arguments(
1088            "hours-from-time",
1089            1,
1090            args.len(),
1091        ));
1092    }
1093
1094    let arg = args.remove(0);
1095    let value = match atomize_to_single_opt(arg)? {
1096        None => return Ok(XPathValue::Empty),
1097        Some(v) => v,
1098    };
1099
1100    let time = as_time(&value).ok_or_else(|| XPathError::XPTY0004 {
1101        expected: "xs:time".to_string(),
1102        found: format!("{:?}", value.type_code),
1103    })?;
1104
1105    Ok(XPathValue::from_atomic(xml_integer(time.hour as i64)))
1106}
1107
1108/// Implements fn:minutes-from-time($arg) - extracts minutes from a time.
1109pub fn minutes_from_time<N: DomNavigator>(
1110    _context: &mut DynamicContext<'_, N>,
1111    mut args: Vec<XPathValue<N>>,
1112) -> Result<XPathValue<N>, XPathError> {
1113    if args.len() != 1 {
1114        return Err(XPathError::wrong_number_of_arguments(
1115            "minutes-from-time",
1116            1,
1117            args.len(),
1118        ));
1119    }
1120
1121    let arg = args.remove(0);
1122    let value = match atomize_to_single_opt(arg)? {
1123        None => return Ok(XPathValue::Empty),
1124        Some(v) => v,
1125    };
1126
1127    let time = as_time(&value).ok_or_else(|| XPathError::XPTY0004 {
1128        expected: "xs:time".to_string(),
1129        found: format!("{:?}", value.type_code),
1130    })?;
1131
1132    Ok(XPathValue::from_atomic(xml_integer(time.minute as i64)))
1133}
1134
1135/// Implements fn:seconds-from-time($arg) - extracts seconds from a time.
1136pub fn seconds_from_time<N: DomNavigator>(
1137    _context: &mut DynamicContext<'_, N>,
1138    mut args: Vec<XPathValue<N>>,
1139) -> Result<XPathValue<N>, XPathError> {
1140    if args.len() != 1 {
1141        return Err(XPathError::wrong_number_of_arguments(
1142            "seconds-from-time",
1143            1,
1144            args.len(),
1145        ));
1146    }
1147
1148    let arg = args.remove(0);
1149    let value = match atomize_to_single_opt(arg)? {
1150        None => return Ok(XPathValue::Empty),
1151        Some(v) => v,
1152    };
1153
1154    let time = as_time(&value).ok_or_else(|| XPathError::XPTY0004 {
1155        expected: "xs:time".to_string(),
1156        found: format!("{:?}", value.type_code),
1157    })?;
1158
1159    Ok(XPathValue::from_atomic(xml_decimal(time.second)))
1160}
1161
1162/// Implements fn:timezone-from-time($arg) - extracts timezone from a time.
1163pub fn timezone_from_time<N: DomNavigator>(
1164    _context: &mut DynamicContext<'_, N>,
1165    mut args: Vec<XPathValue<N>>,
1166) -> Result<XPathValue<N>, XPathError> {
1167    if args.len() != 1 {
1168        return Err(XPathError::wrong_number_of_arguments(
1169            "timezone-from-time",
1170            1,
1171            args.len(),
1172        ));
1173    }
1174
1175    let arg = args.remove(0);
1176    let value = match atomize_to_single_opt(arg)? {
1177        None => return Ok(XPathValue::Empty),
1178        Some(v) => v,
1179    };
1180
1181    let time = as_time(&value).ok_or_else(|| XPathError::XPTY0004 {
1182        expected: "xs:time".to_string(),
1183        found: format!("{:?}", value.type_code),
1184    })?;
1185
1186    match time.timezone {
1187        Some(tz) => {
1188            let duration = timezone_to_day_time_duration(tz);
1189            Ok(XPathValue::from_atomic(xml_day_time_duration(duration)))
1190        }
1191        None => Ok(XPathValue::Empty),
1192    }
1193}
1194
1195// ============================================================================
1196// F. DateTime Constructor (1 function)
1197// ============================================================================
1198
1199/// Implements fn:dateTime($date, $time) - combines a date and time into a dateTime.
1200///
1201/// Error FORG0008 is raised if both arguments have timezones and they differ.
1202pub fn create_datetime<N: DomNavigator>(
1203    _context: &mut DynamicContext<'_, N>,
1204    mut args: Vec<XPathValue<N>>,
1205) -> Result<XPathValue<N>, XPathError> {
1206    if args.len() != 2 {
1207        return Err(XPathError::wrong_number_of_arguments(
1208            "dateTime",
1209            2,
1210            args.len(),
1211        ));
1212    }
1213
1214    let time_arg = args.remove(1);
1215    let date_arg = args.remove(0);
1216
1217    // Get date value
1218    let date_value = match atomize_to_single_opt(date_arg)? {
1219        None => return Ok(XPathValue::Empty),
1220        Some(v) => v,
1221    };
1222
1223    // Get time value
1224    let time_value = match atomize_to_single_opt(time_arg)? {
1225        None => return Ok(XPathValue::Empty),
1226        Some(v) => v,
1227    };
1228
1229    let date = as_date(&date_value).ok_or_else(|| XPathError::XPTY0004 {
1230        expected: "xs:date".to_string(),
1231        found: format!("{:?}", date_value.type_code),
1232    })?;
1233
1234    let time = as_time(&time_value).ok_or_else(|| XPathError::XPTY0004 {
1235        expected: "xs:time".to_string(),
1236        found: format!("{:?}", time_value.type_code),
1237    })?;
1238
1239    // Check timezone compatibility
1240    let timezone = match (date.timezone, time.timezone) {
1241        (Some(date_tz), Some(time_tz)) => {
1242            // Both have timezones - they must be equal
1243            if date_tz.0 != time_tz.0 {
1244                return Err(XPathError::FORG0008);
1245            }
1246            Some(date_tz)
1247        }
1248        (Some(tz), None) => Some(tz),
1249        (None, Some(tz)) => Some(tz),
1250        (None, None) => None,
1251    };
1252
1253    // Normalize 24:00:00 to 00:00:00 (per XQTS expected behavior)
1254    let hour = if time.hour == 24 { 0 } else { time.hour };
1255
1256    let result = DateTimeValue {
1257        year: date.year,
1258        month: date.month,
1259        day: date.day,
1260        hour,
1261        minute: time.minute,
1262        second: time.second,
1263        timezone,
1264    };
1265
1266    Ok(XPathValue::from_atomic(xml_datetime(result)))
1267}
1268
1269// ============================================================================
1270// G. Timezone Adjustment (3 functions with 1-arg and 2-arg variants)
1271// ============================================================================
1272
1273/// Implements fn:adjust-dateTime-to-timezone($arg, $timezone?) - adjusts a dateTime's timezone.
1274pub fn adjust_datetime_to_timezone<N: DomNavigator>(
1275    context: &mut DynamicContext<'_, N>,
1276    mut args: Vec<XPathValue<N>>,
1277) -> Result<XPathValue<N>, XPathError> {
1278    if args.is_empty() || args.len() > 2 {
1279        return Err(XPathError::wrong_number_of_arguments(
1280            "adjust-dateTime-to-timezone",
1281            1,
1282            args.len(),
1283        ));
1284    }
1285
1286    // Get timezone argument (if provided)
1287    let tz_arg = if args.len() == 2 {
1288        Some(args.remove(1))
1289    } else {
1290        None
1291    };
1292
1293    let dt_arg = args.remove(0);
1294    let dt_value = match atomize_to_single_opt(dt_arg)? {
1295        None => return Ok(XPathValue::Empty),
1296        Some(v) => v,
1297    };
1298
1299    let dt = as_datetime(&dt_value).ok_or_else(|| XPathError::XPTY0004 {
1300        expected: "xs:dateTime".to_string(),
1301        found: format!("{:?}", dt_value.type_code),
1302    })?;
1303
1304    // Determine target timezone
1305    let target_tz = if let Some(tz_val) = tz_arg {
1306        match atomize_to_single_opt(tz_val)? {
1307            None => {
1308                // Empty timezone argument - strip timezone
1309                let result = DateTimeValue {
1310                    year: dt.year,
1311                    month: dt.month,
1312                    day: dt.day,
1313                    hour: dt.hour,
1314                    minute: dt.minute,
1315                    second: dt.second,
1316                    timezone: None,
1317                };
1318                return Ok(XPathValue::from_atomic(xml_datetime(result)));
1319            }
1320            Some(v) => {
1321                let duration = as_day_time_duration(&v).ok_or_else(|| XPathError::XPTY0004 {
1322                    expected: "xs:dayTimeDuration".to_string(),
1323                    found: format!("{:?}", v.type_code),
1324                })?;
1325                day_time_duration_to_timezone(duration)?
1326            }
1327        }
1328    } else {
1329        // 1-arg form: use implicit timezone
1330        context
1331            .implicit_timezone
1332            .unwrap_or_else(get_implicit_timezone_offset)
1333    };
1334
1335    validate_timezone_offset(target_tz.0)?;
1336
1337    // Apply timezone adjustment
1338    let result = match dt.timezone {
1339        None => {
1340            // Input has no timezone - just attach the new one without shifting
1341            DateTimeValue {
1342                year: dt.year,
1343                month: dt.month,
1344                day: dt.day,
1345                hour: dt.hour,
1346                minute: dt.minute,
1347                second: dt.second,
1348                timezone: Some(target_tz),
1349            }
1350        }
1351        Some(source_tz) => {
1352            // Convert from source timezone to target timezone
1353            let offset_diff = target_tz.0 - source_tz.0;
1354            adjust_datetime_by_minutes(dt, offset_diff, target_tz)?
1355        }
1356    };
1357
1358    Ok(XPathValue::from_atomic(xml_datetime(result)))
1359}
1360
1361/// Implements fn:adjust-date-to-timezone($arg, $timezone?) - adjusts a date's timezone.
1362pub fn adjust_date_to_timezone<N: DomNavigator>(
1363    context: &mut DynamicContext<'_, N>,
1364    mut args: Vec<XPathValue<N>>,
1365) -> Result<XPathValue<N>, XPathError> {
1366    if args.is_empty() || args.len() > 2 {
1367        return Err(XPathError::wrong_number_of_arguments(
1368            "adjust-date-to-timezone",
1369            1,
1370            args.len(),
1371        ));
1372    }
1373
1374    // Get timezone argument (if provided)
1375    let tz_arg = if args.len() == 2 {
1376        Some(args.remove(1))
1377    } else {
1378        None
1379    };
1380
1381    let date_arg = args.remove(0);
1382    let date_value = match atomize_to_single_opt(date_arg)? {
1383        None => return Ok(XPathValue::Empty),
1384        Some(v) => v,
1385    };
1386
1387    let date = as_date(&date_value).ok_or_else(|| XPathError::XPTY0004 {
1388        expected: "xs:date".to_string(),
1389        found: format!("{:?}", date_value.type_code),
1390    })?;
1391
1392    // Determine target timezone
1393    let target_tz = if let Some(tz_val) = tz_arg {
1394        match atomize_to_single_opt(tz_val)? {
1395            None => {
1396                // Empty timezone argument - strip timezone
1397                let result = DateValue {
1398                    year: date.year,
1399                    month: date.month,
1400                    day: date.day,
1401                    timezone: None,
1402                };
1403                return Ok(XPathValue::from_atomic(xml_date(result)));
1404            }
1405            Some(v) => {
1406                let duration = as_day_time_duration(&v).ok_or_else(|| XPathError::XPTY0004 {
1407                    expected: "xs:dayTimeDuration".to_string(),
1408                    found: format!("{:?}", v.type_code),
1409                })?;
1410                day_time_duration_to_timezone(duration)?
1411            }
1412        }
1413    } else {
1414        // 1-arg form: use implicit timezone
1415        context
1416            .implicit_timezone
1417            .unwrap_or_else(get_implicit_timezone_offset)
1418    };
1419
1420    validate_timezone_offset(target_tz.0)?;
1421
1422    // Apply timezone adjustment
1423    let result = match date.timezone {
1424        None => {
1425            // Input has no timezone - just attach the new one without shifting
1426            DateValue {
1427                year: date.year,
1428                month: date.month,
1429                day: date.day,
1430                timezone: Some(target_tz),
1431            }
1432        }
1433        Some(source_tz) => {
1434            // Convert from source timezone to target timezone
1435            // For dates, we need to convert via dateTime at midnight
1436            let offset_diff = target_tz.0 - source_tz.0;
1437            adjust_date_by_minutes(date, offset_diff, target_tz)?
1438        }
1439    };
1440
1441    Ok(XPathValue::from_atomic(xml_date(result)))
1442}
1443
1444/// Implements fn:adjust-time-to-timezone($arg, $timezone?) - adjusts a time's timezone.
1445pub fn adjust_time_to_timezone<N: DomNavigator>(
1446    context: &mut DynamicContext<'_, N>,
1447    mut args: Vec<XPathValue<N>>,
1448) -> Result<XPathValue<N>, XPathError> {
1449    if args.is_empty() || args.len() > 2 {
1450        return Err(XPathError::wrong_number_of_arguments(
1451            "adjust-time-to-timezone",
1452            1,
1453            args.len(),
1454        ));
1455    }
1456
1457    // Get timezone argument (if provided)
1458    let tz_arg = if args.len() == 2 {
1459        Some(args.remove(1))
1460    } else {
1461        None
1462    };
1463
1464    let time_arg = args.remove(0);
1465    let time_value = match atomize_to_single_opt(time_arg)? {
1466        None => return Ok(XPathValue::Empty),
1467        Some(v) => v,
1468    };
1469
1470    let time = as_time(&time_value).ok_or_else(|| XPathError::XPTY0004 {
1471        expected: "xs:time".to_string(),
1472        found: format!("{:?}", time_value.type_code),
1473    })?;
1474
1475    // Determine target timezone
1476    let target_tz = if let Some(tz_val) = tz_arg {
1477        match atomize_to_single_opt(tz_val)? {
1478            None => {
1479                // Empty timezone argument - strip timezone
1480                let result = TimeValue {
1481                    hour: time.hour,
1482                    minute: time.minute,
1483                    second: time.second,
1484                    timezone: None,
1485                };
1486                return Ok(XPathValue::from_atomic(xml_time(result)));
1487            }
1488            Some(v) => {
1489                let duration = as_day_time_duration(&v).ok_or_else(|| XPathError::XPTY0004 {
1490                    expected: "xs:dayTimeDuration".to_string(),
1491                    found: format!("{:?}", v.type_code),
1492                })?;
1493                day_time_duration_to_timezone(duration)?
1494            }
1495        }
1496    } else {
1497        // 1-arg form: use implicit timezone
1498        context
1499            .implicit_timezone
1500            .unwrap_or_else(get_implicit_timezone_offset)
1501    };
1502
1503    validate_timezone_offset(target_tz.0)?;
1504
1505    // Apply timezone adjustment
1506    let result = match time.timezone {
1507        None => {
1508            // Input has no timezone - just attach the new one without shifting
1509            TimeValue {
1510                hour: time.hour,
1511                minute: time.minute,
1512                second: time.second,
1513                timezone: Some(target_tz),
1514            }
1515        }
1516        Some(source_tz) => {
1517            // Convert from source timezone to target timezone
1518            let offset_diff = target_tz.0 - source_tz.0;
1519            adjust_time_by_minutes(time, offset_diff, target_tz)?
1520        }
1521    };
1522
1523    Ok(XPathValue::from_atomic(xml_time(result)))
1524}
1525
1526// ============================================================================
1527// Timezone adjustment helpers
1528// ============================================================================
1529
1530/// Adjust a dateTime by a number of minutes offset.
1531fn adjust_datetime_by_minutes(
1532    dt: &DateTimeValue,
1533    offset_minutes: i16,
1534    target_tz: TimezoneOffset,
1535) -> Result<DateTimeValue, XPathError> {
1536    // Convert to total minutes from start of day
1537    let mut total_minutes = dt.hour as i32 * 60 + dt.minute as i32 + offset_minutes as i32;
1538
1539    // Normalize to 0-1439 range (minutes in a day)
1540    let mut day_delta = 0i32;
1541    while total_minutes < 0 {
1542        total_minutes += 1440;
1543        day_delta -= 1;
1544    }
1545    while total_minutes >= 1440 {
1546        total_minutes -= 1440;
1547        day_delta += 1;
1548    }
1549
1550    let new_hour = (total_minutes / 60) as u8;
1551    let new_minute = (total_minutes % 60) as u8;
1552
1553    // Adjust date if needed
1554    let (new_year, new_month, new_day) = add_days_to_date(dt.year, dt.month, dt.day, day_delta)?;
1555
1556    Ok(DateTimeValue {
1557        year: new_year,
1558        month: new_month,
1559        day: new_day,
1560        hour: new_hour,
1561        minute: new_minute,
1562        second: dt.second,
1563        timezone: Some(target_tz),
1564    })
1565}
1566
1567/// Adjust a date by a number of minutes offset.
1568/// For dates, midnight (00:00:00) is implied, so we check if we cross day boundaries.
1569/// The offset can be up to ±1680 minutes (±28 hours), requiring up to ±2 day shifts.
1570fn adjust_date_by_minutes(
1571    date: &DateValue,
1572    offset_minutes: i16,
1573    target_tz: TimezoneOffset,
1574) -> Result<DateValue, XPathError> {
1575    // A date is treated as starting at 00:00:00
1576    // When adjusting, we're converting from 00:00 in the source timezone to the target timezone
1577    // The resulting time determines if we cross day boundaries
1578
1579    let total_minutes = offset_minutes as i32;
1580
1581    // Calculate the resulting time of day (in minutes from midnight)
1582    // For dates starting at 00:00, the new time is just the offset
1583    let resulting_time = total_minutes;
1584
1585    // Calculate day delta based on how many full days we've shifted
1586    let day_delta = if resulting_time >= 0 {
1587        // Positive or zero offset: how many full days forward
1588        resulting_time / 1440
1589    } else {
1590        // Negative offset: ceiling division for days backward
1591        // e.g., -1 to -1440 = -1 day, -1441 to -2880 = -2 days
1592        (resulting_time - 1439) / 1440
1593    };
1594
1595    let (new_year, new_month, new_day) =
1596        add_days_to_date(date.year, date.month, date.day, day_delta)?;
1597
1598    Ok(DateValue {
1599        year: new_year,
1600        month: new_month,
1601        day: new_day,
1602        timezone: Some(target_tz),
1603    })
1604}
1605
1606/// Adjust a time by a number of minutes offset.
1607fn adjust_time_by_minutes(
1608    time: &TimeValue,
1609    offset_minutes: i16,
1610    target_tz: TimezoneOffset,
1611) -> Result<TimeValue, XPathError> {
1612    let mut total_minutes = time.hour as i32 * 60 + time.minute as i32 + offset_minutes as i32;
1613
1614    // Normalize to 0-1439 range (minutes in a day)
1615    while total_minutes < 0 {
1616        total_minutes += 1440;
1617    }
1618    while total_minutes >= 1440 {
1619        total_minutes -= 1440;
1620    }
1621
1622    let new_hour = (total_minutes / 60) as u8;
1623    let new_minute = (total_minutes % 60) as u8;
1624
1625    Ok(TimeValue {
1626        hour: new_hour,
1627        minute: new_minute,
1628        second: time.second,
1629        timezone: Some(target_tz),
1630    })
1631}
1632
1633/// Add days to a date, handling month/year rollovers.
1634fn add_days_to_date(
1635    year: i32,
1636    month: u8,
1637    day: u8,
1638    delta: i32,
1639) -> Result<(i32, u8, u8), XPathError> {
1640    if delta == 0 {
1641        return Ok((year, month, day));
1642    }
1643
1644    let mut y = year;
1645    let mut m = month as i32;
1646    let mut d = day as i32 + delta;
1647
1648    // Adjust for day overflow/underflow
1649    loop {
1650        let days_in_current_month = days_in_month(y, m as u8)?;
1651
1652        if d > days_in_current_month as i32 {
1653            d -= days_in_current_month as i32;
1654            m += 1;
1655            if m > 12 {
1656                m = 1;
1657                y += 1;
1658            }
1659        } else if d < 1 {
1660            m -= 1;
1661            if m < 1 {
1662                m = 12;
1663                y -= 1;
1664            }
1665            let days_in_prev_month = days_in_month(y, m as u8)?;
1666            d += days_in_prev_month as i32;
1667        } else {
1668            break;
1669        }
1670    }
1671
1672    Ok((y, m as u8, d as u8))
1673}
1674
1675/// Get the number of days in a month.
1676fn days_in_month(year: i32, month: u8) -> Result<u8, XPathError> {
1677    let days = match month {
1678        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1679        4 | 6 | 9 | 11 => 30,
1680        2 => {
1681            if is_leap_year(year) {
1682                29
1683            } else {
1684                28
1685            }
1686        }
1687        _ => return Err(XPathError::internal("Invalid month value")),
1688    };
1689    Ok(days)
1690}
1691
1692/// Check if a year is a leap year.
1693fn is_leap_year(year: i32) -> bool {
1694    let year = year as i64;
1695    year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
1696}
1697
1698// ============================================================================
1699// Tests
1700// ============================================================================
1701
1702#[cfg(test)]
1703mod tests {
1704    use super::*;
1705    use crate::namespace::table::NameTable;
1706    use crate::xpath::context::XPathContext;
1707    use crate::xpath::iterator::XmlItem;
1708    use crate::xpath::RoXmlNavigator;
1709
1710    fn make_context<'a>() -> DynamicContext<'a, RoXmlNavigator<'a>> {
1711        let table = Box::leak(Box::new(NameTable::new()));
1712        let xpath_ctx = Box::leak(Box::new(XPathContext::new(table)));
1713        DynamicContext::new(xpath_ctx, 0)
1714    }
1715
1716    fn make_datetime_value(
1717        year: i32,
1718        month: u8,
1719        day: u8,
1720        hour: u8,
1721        minute: u8,
1722        second: Decimal,
1723        timezone: Option<TimezoneOffset>,
1724    ) -> XPathValue<RoXmlNavigator<'static>> {
1725        let dt = DateTimeValue {
1726            year,
1727            month,
1728            day,
1729            hour,
1730            minute,
1731            second,
1732            timezone,
1733        };
1734        XPathValue::from_atomic(xml_datetime(dt))
1735    }
1736
1737    fn make_date_value(
1738        year: i32,
1739        month: u8,
1740        day: u8,
1741        timezone: Option<TimezoneOffset>,
1742    ) -> XPathValue<RoXmlNavigator<'static>> {
1743        let d = DateValue {
1744            year,
1745            month,
1746            day,
1747            timezone,
1748        };
1749        XPathValue::from_atomic(xml_date(d))
1750    }
1751
1752    fn make_time_value(
1753        hour: u8,
1754        minute: u8,
1755        second: Decimal,
1756        timezone: Option<TimezoneOffset>,
1757    ) -> XPathValue<RoXmlNavigator<'static>> {
1758        let t = TimeValue {
1759            hour,
1760            minute,
1761            second,
1762            timezone,
1763        };
1764        XPathValue::from_atomic(xml_time(t))
1765    }
1766
1767    fn make_duration_value(
1768        negative: bool,
1769        years: u32,
1770        months: u32,
1771        days: u32,
1772        hours: u32,
1773        minutes: u32,
1774        seconds: Decimal,
1775    ) -> XPathValue<RoXmlNavigator<'static>> {
1776        let d = DurationValue {
1777            negative,
1778            years,
1779            months,
1780            days,
1781            hours,
1782            minutes,
1783            seconds,
1784        };
1785        XPathValue::from_atomic(XmlValue {
1786            type_code: XmlTypeCode::Duration,
1787            schema_type: None,
1788            value: XmlValueKind::Atomic(XmlAtomicValue::Duration(d)),
1789        })
1790    }
1791
1792    fn make_year_month_duration(
1793        negative: bool,
1794        years: u32,
1795        months: u32,
1796    ) -> XPathValue<RoXmlNavigator<'static>> {
1797        let d = YearMonthDurationValue {
1798            negative,
1799            years,
1800            months,
1801        };
1802        XPathValue::from_atomic(XmlValue {
1803            type_code: XmlTypeCode::YearMonthDuration,
1804            schema_type: None,
1805            value: XmlValueKind::Atomic(XmlAtomicValue::YearMonthDuration(d)),
1806        })
1807    }
1808
1809    fn make_day_time_duration(
1810        negative: bool,
1811        days: u32,
1812        hours: u32,
1813        minutes: u32,
1814        seconds: Decimal,
1815    ) -> XPathValue<RoXmlNavigator<'static>> {
1816        let d = DayTimeDurationValue {
1817            negative,
1818            days,
1819            hours,
1820            minutes,
1821            seconds,
1822        };
1823        XPathValue::from_atomic(xml_day_time_duration(d))
1824    }
1825
1826    fn get_integer_result<N: DomNavigator>(result: &XPathValue<N>) -> Option<i64> {
1827        match result {
1828            XPathValue::Item(XmlItem::Atomic(v)) => v.as_integer().and_then(|i| i.to_i64()),
1829            _ => None,
1830        }
1831    }
1832
1833    fn get_decimal_result<N: DomNavigator>(result: &XPathValue<N>) -> Option<Decimal> {
1834        match result {
1835            XPathValue::Item(XmlItem::Atomic(v)) => v.as_decimal(),
1836            _ => None,
1837        }
1838    }
1839
1840    // ========================================================================
1841    // Current time function tests
1842    // ========================================================================
1843
1844    #[test]
1845    fn test_current_datetime_returns_value() {
1846        let mut ctx = make_context();
1847        let result = current_datetime(&mut ctx, vec![]).unwrap();
1848        assert!(!result.is_empty());
1849    }
1850
1851    #[test]
1852    fn test_current_date_returns_value() {
1853        let mut ctx = make_context();
1854        let result = current_date(&mut ctx, vec![]).unwrap();
1855        assert!(!result.is_empty());
1856    }
1857
1858    #[test]
1859    fn test_current_time_returns_value() {
1860        let mut ctx = make_context();
1861        let result = current_time(&mut ctx, vec![]).unwrap();
1862        assert!(!result.is_empty());
1863    }
1864
1865    #[test]
1866    fn test_implicit_timezone_returns_value() {
1867        let mut ctx = make_context();
1868        let result = implicit_timezone(&mut ctx, vec![]).unwrap();
1869        assert!(!result.is_empty());
1870    }
1871
1872    // ========================================================================
1873    // Duration component extraction tests
1874    // ========================================================================
1875
1876    #[test]
1877    fn test_years_from_duration() {
1878        let mut ctx = make_context();
1879        // P1Y2M3DT4H5M6S -> 1 year
1880        let dur = make_duration_value(false, 1, 2, 3, 4, 5, Decimal::from(6));
1881        let result = years_from_duration(&mut ctx, vec![dur]).unwrap();
1882        assert_eq!(get_integer_result(&result), Some(1));
1883    }
1884
1885    #[test]
1886    fn test_years_from_duration_negative() {
1887        let mut ctx = make_context();
1888        // -P1Y2M -> -1 year
1889        let dur = make_duration_value(true, 1, 2, 0, 0, 0, Decimal::ZERO);
1890        let result = years_from_duration(&mut ctx, vec![dur]).unwrap();
1891        assert_eq!(get_integer_result(&result), Some(-1));
1892    }
1893
1894    #[test]
1895    fn test_months_from_duration() {
1896        let mut ctx = make_context();
1897        // P1Y14M -> 2 months (14 % 12 = 2)
1898        let dur = make_duration_value(false, 1, 14, 0, 0, 0, Decimal::ZERO);
1899        let result = months_from_duration(&mut ctx, vec![dur]).unwrap();
1900        assert_eq!(get_integer_result(&result), Some(2));
1901    }
1902
1903    #[test]
1904    fn test_days_from_duration() {
1905        let mut ctx = make_context();
1906        // P5D -> 5 days
1907        let dur = make_day_time_duration(false, 5, 0, 0, Decimal::ZERO);
1908        let result = days_from_duration(&mut ctx, vec![dur]).unwrap();
1909        assert_eq!(get_integer_result(&result), Some(5));
1910    }
1911
1912    #[test]
1913    fn test_hours_from_duration() {
1914        let mut ctx = make_context();
1915        // PT10H -> 10 hours
1916        let dur = make_day_time_duration(false, 0, 10, 0, Decimal::ZERO);
1917        let result = hours_from_duration(&mut ctx, vec![dur]).unwrap();
1918        assert_eq!(get_integer_result(&result), Some(10));
1919    }
1920
1921    #[test]
1922    fn test_minutes_from_duration() {
1923        let mut ctx = make_context();
1924        // PT45M -> 45 minutes
1925        let dur = make_day_time_duration(false, 0, 0, 45, Decimal::ZERO);
1926        let result = minutes_from_duration(&mut ctx, vec![dur]).unwrap();
1927        assert_eq!(get_integer_result(&result), Some(45));
1928    }
1929
1930    #[test]
1931    fn test_seconds_from_duration() {
1932        let mut ctx = make_context();
1933        // PT30.5S -> 30.5 seconds
1934        let dur = make_day_time_duration(false, 0, 0, 0, Decimal::new(305, 1));
1935        let result = seconds_from_duration(&mut ctx, vec![dur]).unwrap();
1936        assert_eq!(get_decimal_result(&result), Some(Decimal::new(305, 1)));
1937    }
1938
1939    // ========================================================================
1940    // DateTime component extraction tests
1941    // ========================================================================
1942
1943    #[test]
1944    fn test_year_from_datetime() {
1945        let mut ctx = make_context();
1946        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
1947        let result = year_from_datetime(&mut ctx, vec![dt]).unwrap();
1948        assert_eq!(get_integer_result(&result), Some(2024));
1949    }
1950
1951    #[test]
1952    fn test_month_from_datetime() {
1953        let mut ctx = make_context();
1954        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
1955        let result = month_from_datetime(&mut ctx, vec![dt]).unwrap();
1956        assert_eq!(get_integer_result(&result), Some(3));
1957    }
1958
1959    #[test]
1960    fn test_day_from_datetime() {
1961        let mut ctx = make_context();
1962        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
1963        let result = day_from_datetime(&mut ctx, vec![dt]).unwrap();
1964        assert_eq!(get_integer_result(&result), Some(15));
1965    }
1966
1967    #[test]
1968    fn test_hours_from_datetime() {
1969        let mut ctx = make_context();
1970        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
1971        let result = hours_from_datetime(&mut ctx, vec![dt]).unwrap();
1972        assert_eq!(get_integer_result(&result), Some(10));
1973    }
1974
1975    #[test]
1976    fn test_minutes_from_datetime() {
1977        let mut ctx = make_context();
1978        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
1979        let result = minutes_from_datetime(&mut ctx, vec![dt]).unwrap();
1980        assert_eq!(get_integer_result(&result), Some(30));
1981    }
1982
1983    #[test]
1984    fn test_seconds_from_datetime() {
1985        let mut ctx = make_context();
1986        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::new(455, 1), None);
1987        let result = seconds_from_datetime(&mut ctx, vec![dt]).unwrap();
1988        assert_eq!(get_decimal_result(&result), Some(Decimal::new(455, 1)));
1989    }
1990
1991    #[test]
1992    fn test_timezone_from_datetime_with_tz() {
1993        let mut ctx = make_context();
1994        let dt = make_datetime_value(
1995            2024,
1996            3,
1997            15,
1998            10,
1999            30,
2000            Decimal::from(0),
2001            Some(TimezoneOffset(-300)),
2002        );
2003        let result = timezone_from_datetime(&mut ctx, vec![dt]).unwrap();
2004        assert!(!result.is_empty());
2005    }
2006
2007    #[test]
2008    fn test_timezone_from_datetime_without_tz() {
2009        let mut ctx = make_context();
2010        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
2011        let result = timezone_from_datetime(&mut ctx, vec![dt]).unwrap();
2012        assert!(result.is_empty());
2013    }
2014
2015    // ========================================================================
2016    // Date component extraction tests
2017    // ========================================================================
2018
2019    #[test]
2020    fn test_year_from_date() {
2021        let mut ctx = make_context();
2022        let d = make_date_value(2024, 6, 20, None);
2023        let result = year_from_date(&mut ctx, vec![d]).unwrap();
2024        assert_eq!(get_integer_result(&result), Some(2024));
2025    }
2026
2027    #[test]
2028    fn test_month_from_date() {
2029        let mut ctx = make_context();
2030        let d = make_date_value(2024, 6, 20, None);
2031        let result = month_from_date(&mut ctx, vec![d]).unwrap();
2032        assert_eq!(get_integer_result(&result), Some(6));
2033    }
2034
2035    #[test]
2036    fn test_day_from_date() {
2037        let mut ctx = make_context();
2038        let d = make_date_value(2024, 6, 20, None);
2039        let result = day_from_date(&mut ctx, vec![d]).unwrap();
2040        assert_eq!(get_integer_result(&result), Some(20));
2041    }
2042
2043    // ========================================================================
2044    // Time component extraction tests
2045    // ========================================================================
2046
2047    #[test]
2048    fn test_hours_from_time() {
2049        let mut ctx = make_context();
2050        let t = make_time_value(14, 35, Decimal::from(0), None);
2051        let result = hours_from_time(&mut ctx, vec![t]).unwrap();
2052        assert_eq!(get_integer_result(&result), Some(14));
2053    }
2054
2055    #[test]
2056    fn test_minutes_from_time() {
2057        let mut ctx = make_context();
2058        let t = make_time_value(14, 35, Decimal::from(0), None);
2059        let result = minutes_from_time(&mut ctx, vec![t]).unwrap();
2060        assert_eq!(get_integer_result(&result), Some(35));
2061    }
2062
2063    #[test]
2064    fn test_seconds_from_time() {
2065        let mut ctx = make_context();
2066        let t = make_time_value(14, 35, Decimal::new(125, 1), None);
2067        let result = seconds_from_time(&mut ctx, vec![t]).unwrap();
2068        assert_eq!(get_decimal_result(&result), Some(Decimal::new(125, 1)));
2069    }
2070
2071    // ========================================================================
2072    // dateTime constructor tests
2073    // ========================================================================
2074
2075    #[test]
2076    fn test_create_datetime_no_tz() {
2077        let mut ctx = make_context();
2078        let d = make_date_value(2024, 3, 15, None);
2079        let t = make_time_value(10, 30, Decimal::from(0), None);
2080        let result = create_datetime(&mut ctx, vec![d, t]).unwrap();
2081        assert!(!result.is_empty());
2082    }
2083
2084    #[test]
2085    fn test_create_datetime_with_matching_tz() {
2086        let mut ctx = make_context();
2087        let tz = TimezoneOffset::from_hm(5, 0);
2088        let d = make_date_value(2024, 3, 15, Some(tz));
2089        let t = make_time_value(10, 30, Decimal::from(0), Some(tz));
2090        let result = create_datetime(&mut ctx, vec![d, t]).unwrap();
2091        assert!(!result.is_empty());
2092    }
2093
2094    #[test]
2095    fn test_create_datetime_mismatched_tz() {
2096        let mut ctx = make_context();
2097        let d = make_date_value(2024, 3, 15, Some(TimezoneOffset::from_hm(5, 0)));
2098        let t = make_time_value(
2099            10,
2100            30,
2101            Decimal::from(0),
2102            Some(TimezoneOffset::from_hm(-5, 0)),
2103        );
2104        let result = create_datetime(&mut ctx, vec![d, t]);
2105        assert!(matches!(result, Err(XPathError::FORG0008)));
2106    }
2107
2108    // ========================================================================
2109    // Timezone adjustment tests
2110    // ========================================================================
2111
2112    #[test]
2113    fn test_adjust_datetime_to_timezone_no_input_tz() {
2114        let mut ctx = make_context();
2115        ctx.implicit_timezone = Some(TimezoneOffset::from_hm(-5, 0));
2116        let dt = make_datetime_value(2024, 3, 15, 10, 30, Decimal::from(0), None);
2117        let result = adjust_datetime_to_timezone(&mut ctx, vec![dt]).unwrap();
2118        // Should attach -05:00 without shifting
2119        assert!(!result.is_empty());
2120    }
2121
2122    #[test]
2123    fn test_adjust_datetime_to_timezone_strip_tz() {
2124        let mut ctx = make_context();
2125        let dt = make_datetime_value(
2126            2024,
2127            3,
2128            15,
2129            10,
2130            30,
2131            Decimal::from(0),
2132            Some(TimezoneOffset::UTC),
2133        );
2134        let result = adjust_datetime_to_timezone(&mut ctx, vec![dt, XPathValue::Empty]).unwrap();
2135        // Should have no timezone
2136        assert!(!result.is_empty());
2137    }
2138
2139    #[test]
2140    fn test_adjust_time_to_timezone() {
2141        let mut ctx = make_context();
2142        ctx.implicit_timezone = Some(TimezoneOffset::from_hm(0, 0));
2143        let t = make_time_value(10, 30, Decimal::from(0), None);
2144        let result = adjust_time_to_timezone(&mut ctx, vec![t]).unwrap();
2145        assert!(!result.is_empty());
2146    }
2147
2148    // ========================================================================
2149    // Empty sequence tests
2150    // ========================================================================
2151
2152    #[test]
2153    fn test_component_functions_with_empty() {
2154        let mut ctx = make_context();
2155
2156        let result = years_from_duration(&mut ctx, vec![XPathValue::Empty]).unwrap();
2157        assert!(result.is_empty());
2158
2159        let result = year_from_datetime(&mut ctx, vec![XPathValue::Empty]).unwrap();
2160        assert!(result.is_empty());
2161
2162        let result = year_from_date(&mut ctx, vec![XPathValue::Empty]).unwrap();
2163        assert!(result.is_empty());
2164
2165        let result = hours_from_time(&mut ctx, vec![XPathValue::Empty]).unwrap();
2166        assert!(result.is_empty());
2167    }
2168
2169    // ========================================================================
2170    // Duration normalization tests (Issue fix verification)
2171    // ========================================================================
2172
2173    #[test]
2174    fn test_duration_normalization_p14m() {
2175        // P14M should normalize to years=1, months=2
2176        let mut ctx = make_context();
2177        let dur = make_year_month_duration(false, 0, 14);
2178
2179        let result = years_from_duration(&mut ctx, vec![dur.clone()]).unwrap();
2180        assert_eq!(get_integer_result(&result), Some(1));
2181
2182        let result = months_from_duration(&mut ctx, vec![dur]).unwrap();
2183        assert_eq!(get_integer_result(&result), Some(2));
2184    }
2185
2186    #[test]
2187    fn test_duration_normalization_pt30h() {
2188        // PT30H should normalize to days=1, hours=6
2189        let mut ctx = make_context();
2190        let dur = make_day_time_duration(false, 0, 30, 0, Decimal::ZERO);
2191
2192        let result = days_from_duration(&mut ctx, vec![dur.clone()]).unwrap();
2193        assert_eq!(get_integer_result(&result), Some(1));
2194
2195        let result = hours_from_duration(&mut ctx, vec![dur]).unwrap();
2196        assert_eq!(get_integer_result(&result), Some(6));
2197    }
2198
2199    #[test]
2200    fn test_duration_normalization_pt90m() {
2201        // PT90M should normalize to hours=1, minutes=30
2202        let mut ctx = make_context();
2203        let dur = make_day_time_duration(false, 0, 0, 90, Decimal::ZERO);
2204
2205        let result = hours_from_duration(&mut ctx, vec![dur.clone()]).unwrap();
2206        assert_eq!(get_integer_result(&result), Some(1));
2207
2208        let result = minutes_from_duration(&mut ctx, vec![dur]).unwrap();
2209        assert_eq!(get_integer_result(&result), Some(30));
2210    }
2211
2212    #[test]
2213    fn test_duration_normalization_pt3665s() {
2214        // PT3665S should normalize to hours=1, minutes=1, seconds=5
2215        let mut ctx = make_context();
2216        let dur = make_day_time_duration(false, 0, 0, 0, Decimal::from(3665));
2217
2218        let result = hours_from_duration(&mut ctx, vec![dur.clone()]).unwrap();
2219        assert_eq!(get_integer_result(&result), Some(1));
2220
2221        let result = minutes_from_duration(&mut ctx, vec![dur.clone()]).unwrap();
2222        assert_eq!(get_integer_result(&result), Some(1));
2223
2224        let result = seconds_from_duration(&mut ctx, vec![dur]).unwrap();
2225        assert_eq!(get_decimal_result(&result), Some(Decimal::from(5)));
2226    }
2227
2228    // ========================================================================
2229    // Timezone offset validation tests (Issue fix verification)
2230    // ========================================================================
2231
2232    #[test]
2233    fn test_timezone_offset_with_days_rejected() {
2234        // A timezone with days component should be rejected with FODT0003
2235        let dur = DayTimeDurationValue {
2236            negative: false,
2237            days: 1,
2238            hours: 0,
2239            minutes: 0,
2240            seconds: Decimal::ZERO,
2241        };
2242        let result = day_time_duration_to_timezone(&dur);
2243        assert!(matches!(result, Err(XPathError::FODT0003 { .. })));
2244    }
2245
2246    #[test]
2247    fn test_timezone_offset_with_seconds_rejected() {
2248        // A timezone with seconds component should be rejected with FODT0003
2249        let dur = DayTimeDurationValue {
2250            negative: false,
2251            days: 0,
2252            hours: 5,
2253            minutes: 0,
2254            seconds: Decimal::from(30),
2255        };
2256        let result = day_time_duration_to_timezone(&dur);
2257        assert!(matches!(result, Err(XPathError::FODT0003 { .. })));
2258    }
2259
2260    #[test]
2261    fn test_timezone_offset_with_fractional_seconds_rejected() {
2262        // A timezone with fractional seconds should be rejected
2263        let dur = DayTimeDurationValue {
2264            negative: false,
2265            days: 0,
2266            hours: 5,
2267            minutes: 0,
2268            seconds: Decimal::new(5, 1), // 0.5 seconds
2269        };
2270        let result = day_time_duration_to_timezone(&dur);
2271        assert!(matches!(result, Err(XPathError::FODT0003 { .. })));
2272    }
2273
2274    #[test]
2275    fn test_timezone_offset_valid() {
2276        // Valid timezone: PT5H30M
2277        let dur = DayTimeDurationValue {
2278            negative: false,
2279            days: 0,
2280            hours: 5,
2281            minutes: 30,
2282            seconds: Decimal::ZERO,
2283        };
2284        let result = day_time_duration_to_timezone(&dur).unwrap();
2285        assert_eq!(result.0, 330); // 5*60 + 30 = 330 minutes
2286    }
2287
2288    #[test]
2289    fn test_timezone_offset_out_of_range_rejected() {
2290        // Timezone > 14:00 should be rejected
2291        let dur = DayTimeDurationValue {
2292            negative: false,
2293            days: 0,
2294            hours: 15,
2295            minutes: 0,
2296            seconds: Decimal::ZERO,
2297        };
2298        let result = day_time_duration_to_timezone(&dur);
2299        assert!(matches!(result, Err(XPathError::FODT0003 { .. })));
2300    }
2301}