Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_datime/src/format/rfc9557.rs

15.3 KiB, 28 runs

created by r1870400018:8456, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! RFC 9557 Internet Extended Date/Time Format (IXDTF) serialisation capabilities.
2//!
3//! This module implements timezone-preserving serialisation according to RFC 9557,
4//! which extends RFC 3339 timestamps with additional timezone information.
5//!
6//! RFC 9557 allows timestamps to include both UTC offset and IANA timezone identifier,
7//! enabling proper handling of daylight saving transitions and timezone-aware calculations.
8//!
9//! # Examples
10//!
11//! ```ignore
12//! use oxedyne_fe2o3_datime::{CalClock, CalClockZone, format::rfc9557::Rfc9557Format};
13//!
14//! let zone = res!(CalClockZone::new("America/New_York"));
15//! let calclock = res!(CalClock::new(2024, 6, 15, 14, 30, 0, 0, zone));
16//!
17//! // Standard RFC 3339 format
18//! let rfc3339 = res!(calclock.to_rfc9557_basic());
19//! // "2024-06-15T14:30:00.000000000-04:00"
20//!
21//! // Extended RFC 9557 format with timezone preservation
22//! let rfc9557 = res!(calclock.to_rfc9557_extended());
23//! // "2024-06-15T14:30:00.000000000-04:00[America/New_York]"
24//! ```
25//!
26//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
27//! Anthropic Claude
28
29use crate::{
30 time::{CalClock, CalClockZone},
31 clock::ClockTime,
32 calendar::CalendarDate,
33};
34
35use oxedyne_fe2o3_core::prelude::*;
36
37#[derive(Clone, Debug, PartialEq)]
38pub struct Rfc9557Config {
39 pub include_timezone_name: bool, // IANA name in square brackets
40 pub always_include_nanoseconds: bool, // even when zero
41 pub use_z_for_utc: bool, // Z rather than +00:00
42 pub include_precision_indicators: bool,
43 pub precision_level: PrecisionLevel,
44}
45
46#[derive(Clone, Debug, PartialEq)]
47pub enum PrecisionLevel {
48 // The indicator character each level writes.
49 Exact, // none
50 Approximate, // ~
51 Uncertain, // %
52 Around, // @
53 Between, // *
54}
55
56impl Default for Rfc9557Config {
57 fn default() -> Self {
58 Self {
59 include_timezone_name: true,
60 always_include_nanoseconds: false,
61 use_z_for_utc: true,
62 include_precision_indicators: false,
63 precision_level: PrecisionLevel::Exact,
64 }
65 }
66}
67
68impl PrecisionLevel {
69 pub fn indicator(&self) -> Option<char> {
70 match self {
71 Self::Exact => None,
72 Self::Approximate => Some('~'),
73 Self::Uncertain => Some('%'),
74 Self::Around => Some('@'),
75 Self::Between => Some('*'),
76 }
77 }
78}
79
80pub trait Rfc9557Format {
81 fn to_rfc9557(&self) -> Outcome<String>;
82
83 fn to_rfc9557_with_config(&self, config: &Rfc9557Config) -> Outcome<String>;
84
85 /// RFC 3339 only, with no timezone name.
86 fn to_rfc9557_basic(&self) -> Outcome<String>;
87
88 /// Appends the IANA timezone name in square brackets.
89 fn to_rfc9557_extended(&self) -> Outcome<String>;
90
91 fn from_rfc9557(input: &str) -> Outcome<Self>
92 where
93 Self: Sized;
94}
95
96impl Rfc9557Format for CalClock {
97 fn to_rfc9557(&self) -> Outcome<String> {
98 self.to_rfc9557_with_config(&Rfc9557Config::default())
99 }
100
101 fn to_rfc9557_with_config(&self, config: &Rfc9557Config) -> Outcome<String> {
102 // Build the basic timestamp part.
103 let date_part = format!("{:04}-{:02}-{:02}",
104 self.year(), self.month(), self.day());
105
106 // Format time with conditional nanoseconds.
107 let time_part = if config.always_include_nanoseconds || self.nanosecond() > 0 {
108 format!("{:02}:{:02}:{:02}.{:09}",
109 self.hour(), self.minute(), self.second(), self.nanosecond())
110 } else {
111 format!("{:02}:{:02}:{:02}",
112 self.hour(), self.minute(), self.second())
113 };
114
115 // Get timezone offset.
116 let offset_millis = res!(self.zone().offset_millis_at_time(res!(self.to_millis())));
117 let offset_hours = offset_millis / (60 * 60 * 1000);
118 let offset_minutes = (offset_millis.abs() % (60 * 60 * 1000)) / (60 * 1000);
119
120 // Format offset part.
121 let offset_part = if offset_millis == 0 && config.use_z_for_utc {
122 "Z".to_string()
123 } else {
124 format!("{:+03}:{:02}", offset_hours, offset_minutes)
125 };
126
127 // Add precision indicator if configured.
128 let precision_suffix = if config.include_precision_indicators {
129 config.precision_level.indicator()
130 .map(|c| c.to_string())
131 .unwrap_or_default()
132 } else {
133 String::new()
134 };
135
136 // Add timezone name if configured.
137 let timezone_suffix = if config.include_timezone_name {
138 format!("[{}]", self.zone().id())
139 } else {
140 String::new()
141 };
142
143 Ok(format!("{}T{}{}{}{}",
144 date_part, time_part, offset_part, precision_suffix, timezone_suffix))
145 }
146
147 fn to_rfc9557_basic(&self) -> Outcome<String> {
148 let config = Rfc9557Config {
149 include_timezone_name: false,
150 ..Default::default()
151 };
152 self.to_rfc9557_with_config(&config)
153 }
154
155 fn to_rfc9557_extended(&self) -> Outcome<String> {
156 let config = Rfc9557Config {
157 include_timezone_name: true,
158 ..Default::default()
159 };
160 self.to_rfc9557_with_config(&config)
161 }
162
163 fn from_rfc9557(input: &str) -> Outcome<Self> {
164 // Parse RFC 9557 format: YYYY-MM-DDTHH:MM:SS[.nnnnnnnnn][±HH:MM|Z][~|%|@|*][timezone]
165 let input = input.trim();
166
167 // Extract timezone name if present (in square brackets at the end).
168 let (timestamp_part, timezone_name) = if let Some(bracket_start) = input.rfind('[') {
169 if let Some(bracket_end) = input.rfind(']') {
170 if bracket_end > bracket_start {
171 let timezone_part = &input[bracket_start + 1..bracket_end];
172 let timestamp_part = &input[..bracket_start];
173 (timestamp_part, Some(timezone_part))
174 } else {
175 (input, None)
176 }
177 } else {
178 (input, None)
179 }
180 } else {
181 (input, None)
182 };
183
184 // Remove precision indicators if present.
185 let timestamp_part = timestamp_part.trim_end_matches(['~', '%', '@', '*']);
186
187 // Manual parsing instead of relying on parse_iso.
188 // Split into date and time parts.
189 let parts: Vec<&str> = timestamp_part.split('T').collect();
190 if parts.len() != 2 {
191 return Err(err!("Invalid timestamp format: {}", timestamp_part; Invalid, Input));
192 }
193
194 let date_part = parts[0];
195 let time_part_with_offset = parts[1];
196
197 // Parse date.
198 let date_components: Vec<&str> = date_part.split('-').collect();
199 if date_components.len() != 3 {
200 return Err(err!("Invalid date format: {}", date_part; Invalid, Input));
201 }
202
203 let year = res!(date_components[0].parse::<i32>()
204 .map_err(|_| err!("Invalid year: {}", date_components[0]; Invalid, Input)));
205 let month = res!(date_components[1].parse::<u8>()
206 .map_err(|_| err!("Invalid month: {}", date_components[1]; Invalid, Input)));
207 let day = res!(date_components[2].parse::<u8>()
208 .map_err(|_| err!("Invalid day: {}", date_components[2]; Invalid, Input)));
209
210 // Extract offset and time.
211 let (time_part, _offset_part) = if time_part_with_offset.ends_with('Z') {
212 (&time_part_with_offset[..time_part_with_offset.len() - 1], "Z")
213 } else if let Some(plus_pos) = time_part_with_offset.rfind('+') {
214 (&time_part_with_offset[..plus_pos], &time_part_with_offset[plus_pos..])
215 } else if let Some(minus_pos) = time_part_with_offset.rfind('-') {
216 (&time_part_with_offset[..minus_pos], &time_part_with_offset[minus_pos..])
217 } else {
218 (time_part_with_offset, "")
219 };
220
221 // Parse time components.
222 let time_components: Vec<&str> = time_part.split(':').collect();
223 if time_components.len() < 3 {
224 return Err(err!("Invalid time format: {}", time_part; Invalid, Input));
225 }
226
227 let hour = res!(time_components[0].parse::<u8>()
228 .map_err(|_| err!("Invalid hour: {}", time_components[0]; Invalid, Input)));
229 let minute = res!(time_components[1].parse::<u8>()
230 .map_err(|_| err!("Invalid minute: {}", time_components[1]; Invalid, Input)));
231
232 // Handle seconds with optional fractional part.
233 let (second, nanosecond) = if let Some(dot_pos) = time_components[2].find('.') {
234 let second_part = &time_components[2][..dot_pos];
235 let fraction_part = &time_components[2][dot_pos + 1..];
236
237 let second = res!(second_part.parse::<u8>()
238 .map_err(|_| err!("Invalid second: {}", second_part; Invalid, Input)));
239
240 // Parse fractional seconds, padding or truncating to 9 digits.
241 let mut fraction_str = fraction_part.to_string();
242 fraction_str.truncate(9); // Max 9 digits for nanoseconds.
243 while fraction_str.len() < 9 {
244 fraction_str.push('0'); // Pad with zeros.
245 }
246
247 let nanosecond = res!(fraction_str.parse::<u32>()
248 .map_err(|_| err!("Invalid nanosecond: {}", fraction_str; Invalid, Input)));
249
250 (second, nanosecond)
251 } else {
252 let second = res!(time_components[2].parse::<u8>()
253 .map_err(|_| err!("Invalid second: {}", time_components[2]; Invalid, Input)));
254 (second, 0)
255 };
256
257 // Determine timezone.
258 let zone = if let Some(tz_name) = timezone_name {
259 res!(CalClockZone::new(tz_name))
260 } else {
261 CalClockZone::utc()
262 };
263
264 Self::new(year, month, day, hour, minute, second, nanosecond, zone)
265 }
266}
267
268impl Rfc9557Format for ClockTime {
269 fn to_rfc9557(&self) -> Outcome<String> {
270 self.to_rfc9557_with_config(&Rfc9557Config::default())
271 }
272
273 fn to_rfc9557_with_config(&self, config: &Rfc9557Config) -> Outcome<String> {
274 // Format time component only.
275 let time_part = if config.always_include_nanoseconds || self.nanosecond().of() > 0 {
276 format!("{:02}:{:02}:{:02}.{:09}",
277 self.hour().of(), self.minute().of(), self.second().of(), self.nanosecond().of())
278 } else {
279 format!("{:02}:{:02}:{:02}",
280 self.hour().of(), self.minute().of(), self.second().of())
281 };
282
283 // Add precision indicator if configured.
284 let precision_suffix = if config.include_precision_indicators {
285 config.precision_level.indicator()
286 .map(|c| c.to_string())
287 .unwrap_or_default()
288 } else {
289 String::new()
290 };
291
292 // Add timezone name if configured.
293 let timezone_suffix = if config.include_timezone_name {
294 format!("[{}]", self.zone().id())
295 } else {
296 String::new()
297 };
298
299 Ok(format!("{}{}{}", time_part, precision_suffix, timezone_suffix))
300 }
301
302 fn to_rfc9557_basic(&self) -> Outcome<String> {
303 let config = Rfc9557Config {
304 include_timezone_name: false,
305 ..Default::default()
306 };
307 self.to_rfc9557_with_config(&config)
308 }
309
310 fn to_rfc9557_extended(&self) -> Outcome<String> {
311 let config = Rfc9557Config {
312 include_timezone_name: true,
313 ..Default::default()
314 };
315 self.to_rfc9557_with_config(&config)
316 }
317
318 fn from_rfc9557(input: &str) -> Outcome<Self> {
319 // For time-only parsing, we'll use UTC as default and then update if timezone specified.
320 let input = input.trim();
321
322 // Extract timezone name if present.
323 let (time_part, timezone_name) = if let Some(bracket_start) = input.rfind('[') {
324 if let Some(bracket_end) = input.rfind(']') {
325 if bracket_end > bracket_start {
326 let timezone_part = &input[bracket_start + 1..bracket_end];
327 let time_part = &input[..bracket_start];
328 (time_part, Some(timezone_part))
329 } else {
330 (input, None)
331 }
332 } else {
333 (input, None)
334 }
335 } else {
336 (input, None)
337 };
338
339 // Remove precision indicators.
340 let time_part = time_part.trim_end_matches(['~', '%', '@', '*']);
341
342 // Parse time components.
343 let parts: Vec<&str> = time_part.split(':').collect();
344 if parts.len() < 3 {
345 return Err(err!("Invalid time format: {}", input; Invalid, Input));
346 }
347
348 let hour = res!(parts[0].parse::<u8>()
349 .map_err(|_| err!("Invalid hour: {}", parts[0]; Invalid, Input)));
350 let minute = res!(parts[1].parse::<u8>()
351 .map_err(|_| err!("Invalid minute: {}", parts[1]; Invalid, Input)));
352
353 // Handle seconds with optional fractional part.
354 let (second, nanosecond) = if let Some(dot_pos) = parts[2].find('.') {
355 let second_part = &parts[2][..dot_pos];
356 let fraction_part = &parts[2][dot_pos + 1..];
357
358 let second = res!(second_part.parse::<u8>()
359 .map_err(|_| err!("Invalid second: {}", second_part; Invalid, Input)));
360
361 // Parse fractional seconds, padding or truncating to 9 digits.
362 let mut fraction_str = fraction_part.to_string();
363 fraction_str.truncate(9); // Max 9 digits for nanoseconds.
364 while fraction_str.len() < 9 {
365 fraction_str.push('0'); // Pad with zeros.
366 }
367
368 let nanosecond = res!(fraction_str.parse::<u32>()
369 .map_err(|_| err!("Invalid nanosecond: {}", fraction_str; Invalid, Input)));
370
371 (second, nanosecond)
372 } else {
373 let second = res!(parts[2].parse::<u8>()
374 .map_err(|_| err!("Invalid second: {}", parts[2]; Invalid, Input)));
375 (second, 0)
376 };
377
378 // Determine timezone.
379 let zone = if let Some(tz_name) = timezone_name {
380 res!(CalClockZone::new(tz_name))
381 } else {
382 CalClockZone::utc()
383 };
384
385 Self::new(hour, minute, second, nanosecond, zone)
386 }
387}
388
389impl Rfc9557Format for CalendarDate {
390 fn to_rfc9557(&self) -> Outcome<String> {
391 self.to_rfc9557_with_config(&Rfc9557Config::default())
392 }
393
394 fn to_rfc9557_with_config(&self, config: &Rfc9557Config) -> Outcome<String> {
395 // Format date component only.
396 let date_part = format!("{:04}-{:02}-{:02}",
397 self.year(), self.month(), self.day());
398
399 // Add timezone name if configured.
400 let timezone_suffix = if config.include_timezone_name {
401 format!("[{}]", self.zone().id())
402 } else {
403 String::new()
404 };
405
406 Ok(format!("{}{}", date_part, timezone_suffix))
407 }
408
409 fn to_rfc9557_basic(&self) -> Outcome<String> {
410 let config = Rfc9557Config {
411 include_timezone_name: false,
412 ..Default::default()
413 };
414 self.to_rfc9557_with_config(&config)
415 }
416
417 fn to_rfc9557_extended(&self) -> Outcome<String> {
418 let config = Rfc9557Config {
419 include_timezone_name: true,
420 ..Default::default()
421 };
422 self.to_rfc9557_with_config(&config)
423 }
424
425 fn from_rfc9557(input: &str) -> Outcome<Self> {
426 let input = input.trim();
427
428 // Extract timezone name if present.
429 let (date_part, timezone_name) = if let Some(bracket_start) = input.rfind('[') {
430 if let Some(bracket_end) = input.rfind(']') {
431 if bracket_end > bracket_start {
432 let timezone_part = &input[bracket_start + 1..bracket_end];
433 let date_part = &input[..bracket_start];
434 (date_part, Some(timezone_part))
435 } else {
436 (input, None)
437 }
438 } else {
439 (input, None)
440 }
441 } else {
442 (input, None)
443 };
444
445 // Parse date components.
446 let parts: Vec<&str> = date_part.split('-').collect();
447 if parts.len() != 3 {
448 return Err(err!("Invalid date format: {}", input; Invalid, Input));
449 }
450
451 let year = res!(parts[0].parse::<i32>()
452 .map_err(|_| err!("Invalid year: {}", parts[0]; Invalid, Input)));
453 let month = res!(parts[1].parse::<u8>()
454 .map_err(|_| err!("Invalid month: {}", parts[1]; Invalid, Input)));
455 let day = res!(parts[2].parse::<u8>()
456 .map_err(|_| err!("Invalid day: {}", parts[2]; Invalid, Input)));
457
458 // Determine timezone.
459 let zone = if let Some(tz_name) = timezone_name {
460 res!(CalClockZone::new(tz_name))
461 } else {
462 CalClockZone::utc()
463 };
464
465 Self::new(year, month, day, zone)
466 }
467}
468
469pub mod utils {
470 use super::*;
471
472 pub fn validate_rfc9557(input: &str) -> Outcome<()> {
473 // Try to parse as CalClock to validate format.
474 match CalClock::from_rfc9557(input) {
475 Ok(_) => Ok(()),
476 Err(_) => Err(err!("Invalid RFC 9557 format: {}", input; Invalid, Input)),
477 }
478 }
479
480 pub fn extract_timezone_name(input: &str) -> Option<&str> {
481 if let Some(bracket_start) = input.rfind('[') {
482 if let Some(bracket_end) = input.rfind(']') {
483 if bracket_end > bracket_start {
484 return Some(&input[bracket_start + 1..bracket_end]);
485 }
486 }
487 }
488 None
489 }
490
491 pub fn extract_precision_indicator(input: &str) -> Option<PrecisionLevel> {
492 if input.ends_with('~') {
493 Some(PrecisionLevel::Approximate)
494 } else if input.ends_with('%') {
495 Some(PrecisionLevel::Uncertain)
496 } else if input.ends_with('@') {
497 Some(PrecisionLevel::Around)
498 } else if input.ends_with('*') {
499 Some(PrecisionLevel::Between)
500 } else {
501 None
502 }
503 }
504}