1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// This is free and unencumbered software released into the public domain.
#[cfg(feature = "jiff")]
pub use self::value::DateTime;
#[cfg(feature = "jiff")]
mod value {
use crate::{
TimezoneOffset,
primitive::{Date, Time},
};
use core::fmt;
/// An XSD 1.1 date-time with an optional validated timezone offset.
///
/// Requires `jiff` (enabled by `datetime`). Supports proleptic Gregorian years
/// `-9999..=9999`, including zero, and nanosecond precision. Equality, ordering,
/// and hashing are structural, not instant comparisons: absent timezones differ
/// from UTC. Formatting uses XSD year spelling and preserves the offset,
/// normalizing explicit zero offsets to `Z` and omitting fractional trailing zeros.
/// Use [`crate::parse_datetime`] for XSD lexical validation.
///
/// This replaces the Jiff alias. Conversion to `jiff::civil::DateTime` through
/// `TryFrom` rejects any present timezone, including UTC, with
/// [`crate::TimezoneLossError`]. [`Self::civil`] explicitly discards the offset.
/// With `serde`, the encoding is a struct with `civil` (Jiff's canonical
/// date-time string) and `timezone` (optional signed minutes), replacing the
/// former bare string. Decoding validates offset bounds and requires exactly
/// the canonical civil spelling, rejecting embedded offsets and annotations.
/// Explicit JSON/BSON conversion on [`crate::Value`] instead emits an XSD
/// lexical string retaining the calendar, nanoseconds, and optional offset,
/// without a datatype tag. Parse that string with [`crate::DATE_TIME`] to
/// recover the represented value. Neither encoding recovers original spelling
/// such as hour 24, fractional trailing zeros, or signed zero offsets.
///
/// With `borsh`, the new type-local version-1 encoding is: version byte `1`,
/// little-endian `i16` year, `i8` month, day, hour, minute, and second,
/// little-endian `i32` nanoseconds, then Borsh `Option<TimezoneOffset>` (tag
/// `0` for absent, or `1` and little-endian `i16` minutes). This is 13 or 15
/// bytes, with one offset and no datatype tag or nested component versions.
/// There was no previous Borsh encoding for this type. Decoding rejects unknown
/// versions, invalid calendar/clock fields, invalid option tags, and offsets.
///
/// ```
/// use xsd::{primitive::DateTime, TimezoneOffset};
/// let value = DateTime::new(-1, 2, 28, 12, 34, 56, 1).unwrap()
/// .with_timezone(Some(TimezoneOffset::UTC));
/// assert_eq!(value.to_string(), "-0001-02-28T12:34:56.000000001Z");
/// ```
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DateTime {
#[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_civil"))]
civil: jiff::civil::DateTime,
timezone: Option<TimezoneOffset>,
}
#[cfg(feature = "serde")]
fn deserialize_civil<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<jiff::civil::DateTime, D::Error> {
use alloc::string::{String, ToString};
let input = <String as serde::Deserialize>::deserialize(deserializer)?;
let civil = input
.parse::<jiff::civil::DateTime>()
.map_err(serde::de::Error::custom)?;
if civil.to_string() != input {
return Err(serde::de::Error::custom(
"expected a canonical Jiff civil date-time without timezone or annotations",
));
}
Ok(civil)
}
impl DateTime {
/// Constructs a timezone-free date-time. Returns a Jiff error for invalid
/// calendar or clock fields, years outside `-9999..=9999`, or nanoseconds
/// outside `0..=999_999_999`. Hour 24 and leap seconds are not stored.
pub fn new(
year: i16,
month: i8,
day: i8,
hour: i8,
minute: i8,
second: i8,
nanosecond: i32,
) -> Result<Self, jiff::Error> {
jiff::civil::DateTime::new(year, month, day, hour, minute, second, nanosecond)
.map(Self::from)
}
/// Sets or removes the timezone without changing the civil fields.
pub const fn with_timezone(mut self, timezone: Option<TimezoneOffset>) -> Self {
self.timezone = timezone;
self
}
/// Returns the timezone, distinguishing absence from explicit UTC.
pub const fn timezone(self) -> Option<TimezoneOffset> {
self.timezone
}
/// Returns the civil fields, explicitly discarding any timezone.
pub const fn civil(self) -> jiff::civil::DateTime {
self.civil
}
/// Converts an explicitly timezoned value to a Jiff instant.
///
/// Applies the stored offset exactly, preserving nanoseconds. The result
/// carries neither the original civil fields nor their offset; retain the
/// offset separately if those must be recovered. Requires only `jiff`,
/// without allocation, a timezone database, or a system timezone.
///
/// # Errors
///
/// Returns a Jiff error when the timezone is absent (never assumes UTC),
/// or when the resulting instant exceeds Jiff's timestamp range, which
/// is narrower than its civil date-time range near the extreme years.
///
/// ```
/// use xsd::{primitive::DateTime, TimezoneOffset};
/// let local = DateTime::new(2026, 1, 2, 1, 0, 0, 1).unwrap();
/// assert!(local.to_timestamp().is_err());
/// let local = local.with_timezone(TimezoneOffset::from_minutes(120));
/// assert_eq!(local.to_timestamp().unwrap().to_string(), "2026-01-01T23:00:00.000000001Z");
/// ```
pub fn to_timestamp(self) -> Result<jiff::Timestamp, jiff::Error> {
let offset = self.timezone.ok_or_else(|| {
jiff::Error::from_args(format_args!(
"converting an XSD date-time to an instant requires an explicit timezone"
))
})?;
jiff::tz::Offset::from(offset).to_timestamp(self.civil)
}
/// Returns the date component, retaining the optional timezone.
pub fn date(self) -> Date {
Date::from(self.civil.date()).with_timezone(self.timezone)
}
/// Returns the time component, retaining the optional timezone.
pub fn time(self) -> Time {
Time::from(self.civil.time()).with_timezone(self.timezone)
}
/// Returns the signed year, including zero.
pub fn year(self) -> i16 {
self.civil.year()
}
/// Returns the month in `1..=12`.
pub fn month(self) -> i8 {
self.civil.month()
}
/// Returns the day of the month in `1..=31`.
pub fn day(self) -> i8 {
self.civil.day()
}
/// Returns the hour in `0..=23`.
pub fn hour(self) -> i8 {
self.civil.hour()
}
/// Returns the minute in `0..=59`.
pub fn minute(self) -> i8 {
self.civil.minute()
}
/// Returns the second in `0..=59`.
pub fn second(self) -> i8 {
self.civil.second()
}
/// Returns the fractional second in nanoseconds (`0..=999_999_999`).
pub fn subsec_nanosecond(self) -> i32 {
self.civil.subsec_nanosecond()
}
}
impl From<jiff::civil::DateTime> for DateTime {
/// Wraps civil fields with no timezone.
fn from(civil: jiff::civil::DateTime) -> Self {
Self {
civil,
timezone: None,
}
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}T{}", Date::from(self.civil.date()), self.civil.time())?;
if let Some(offset) = self.timezone {
offset.fmt(f)?;
}
Ok(())
}
}
impl TryFrom<DateTime> for jiff::civil::DateTime {
type Error = crate::TimezoneLossError;
/// Converts only timezone-free values; even explicit UTC would be lost.
/// Calendar fields and nanoseconds are returned without timezone adjustment.
fn try_from(value: DateTime) -> Result<Self, Self::Error> {
if value.timezone.is_some() {
Err(crate::TimezoneLossError)
} else {
Ok(value.civil)
}
}
}
impl TryFrom<DateTime> for jiff::Timestamp {
type Error = jiff::Error;
/// Applies the explicit offset using [`DateTime::to_timestamp`], rejecting
/// absent timezones and instants outside Jiff's supported range.
fn try_from(value: DateTime) -> Result<Self, Self::Error> {
value.to_timestamp()
}
}
#[cfg(feature = "borsh")]
impl borsh::BorshSerialize for DateTime {
fn serialize<W: borsh::io::Write>(&self, writer: &mut W) -> Result<(), borsh::io::Error> {
borsh::BorshSerialize::serialize(
&(
1u8,
self.year(),
self.month(),
self.day(),
self.hour(),
self.minute(),
self.second(),
self.subsec_nanosecond(),
self.timezone,
),
writer,
)
}
}
#[cfg(feature = "borsh")]
impl borsh::BorshDeserialize for DateTime {
fn deserialize_reader<R: borsh::io::Read>(
reader: &mut R,
) -> Result<Self, borsh::io::Error> {
use borsh::io::{Error, ErrorKind};
if u8::deserialize_reader(reader)? != 1 {
return Err(Error::new(
ErrorKind::InvalidData,
"unsupported XSD date-time encoding version",
));
}
let (year, month, day, hour, minute, second, nanosecond, timezone) =
<(i16, i8, i8, i8, i8, i8, i32, Option<TimezoneOffset>)>::deserialize_reader(
reader,
)?;
Self::new(year, month, day, hour, minute, second, nanosecond)
.map(|value| value.with_timezone(timezone))
.map_err(|_| {
Error::new(
ErrorKind::InvalidData,
"invalid XSD date-time calendar or clock fields",
)
})
}
}
}