deep_time/dt/to_str.rs
1use crate::{
2 Dt, DtErr, DtErrKind, LiteStr, STRFTIME_SIZE, Scale, YmdHmsRich, an_err,
3 tzdb::offset_info_at_utc,
4};
5
6#[cfg(feature = "alloc")]
7use crate::ATTOS_PER_SEC;
8
9#[cfg(feature = "alloc")]
10impl Dt {
11 /// Converts this `Dt` to an ISO 8601 duration string
12 /// (e.g. `"PT1H23M45.6789S"`, `"-PT0.5S"`, `"PT0.000000000000000001S"`, or `"PT0S"`).
13 ///
14 /// - This method is only available when the **`alloc`** feature is enabled.
15 /// - It returns `alloc::string::String` (no_std + alloc compatible).
16 pub fn to_iso_duration(&self) -> alloc::string::String {
17 if self.is_zero() {
18 return alloc::string::String::from("PT0S");
19 }
20
21 let total = self.to_attos();
22 let negative = total < 0;
23 let mut attos = total.unsigned_abs();
24
25 let mut s = alloc::string::String::with_capacity(48);
26 if negative {
27 s.push('-');
28 }
29 s.push_str("PT");
30
31 const A_PER_S: u128 = ATTOS_PER_SEC as u128;
32 const A_PER_M: u128 = A_PER_S * 60;
33 const A_PER_H: u128 = A_PER_M * 60;
34
35 let hours = attos / A_PER_H;
36 attos %= A_PER_H;
37 let minutes = attos / A_PER_M;
38 attos %= A_PER_M;
39 let seconds = attos / A_PER_S;
40 let frac_attos = attos % A_PER_S;
41
42 if hours > 0 {
43 s.push_str(&alloc::format!("{}", hours));
44 s.push('H');
45 }
46 if minutes > 0 {
47 s.push_str(&alloc::format!("{}", minutes));
48 s.push('M');
49 }
50
51 if seconds > 0 || frac_attos > 0 {
52 s.push_str(&alloc::format!("{}", seconds));
53
54 if frac_attos != 0 {
55 let frac_str = alloc::format!("{frac_attos:018}");
56 let trimmed = frac_str.trim_end_matches('0');
57 s.push('.');
58 s.push_str(trimmed);
59 }
60
61 s.push('S');
62 }
63
64 s
65 }
66
67 /// Formats this [`Dt`] into a String. Requires the `"alloc"` feature.
68 ///
69 /// - It is first converted from the `current` [`Scale`] into
70 /// the `UTC` time scale.
71 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice`
72 /// are preserved. See [`Scale`] for more info on time scales.
73 ///
74 /// ## Example
75 ///
76 /// ```rust
77 /// use deep_time::{Dt, Scale};
78 ///
79 /// let x = Dt::from_ymd(2000, 1, 1);
80 /// let s = x.to_str(Scale::TAI, "%F").unwrap();
81 ///
82 /// println!("{}", s);
83 /// ```
84 ///
85 /// ## Errors
86 ///
87 /// Returns [`DtErr`] if the format string contains invalid specifiers
88 /// or if the internal formatting buffer overflows (extremely unlikely
89 /// with [`STRFTIME_SIZE`]).
90 ///
91 /// ## See also
92 ///
93 /// - [`Dt::to_str_with_offset`](../struct.Dt.html#method.to_str_with_offset)
94 /// - [`Dt::to_str_with_tz`](../struct.Dt.html#method.to_str_with_tz)
95 #[inline]
96 pub fn to_str(&self, current: Scale, fmt: &str) -> Result<alloc::string::String, DtErr> {
97 self.to_str_with_offset(current, fmt, 0)
98 }
99
100 /// Formats this [`Dt`] into a String, applying a fixed UTC offset. Requires the
101 /// `"alloc"` feature.
102 ///
103 /// - A copy of the [`Dt`] is adjusted by the given `secs` offset **before**
104 /// formatting, and the offset is stored so that `%z` / `%:z` format directives
105 /// will reflect it.
106 /// - Then it's converted from the `current` [`Scale`] into the
107 /// `UTC` time scale.
108 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice` are preserved.
109 /// See [`Scale`] for more info on time scales.
110 /// - No IANA timezone name or abbreviation is set.
111 ///
112 /// ## Example
113 ///
114 /// ```rust
115 /// use deep_time::{Dt, Scale};
116 ///
117 /// let x = Dt::from_ymd(2000, 1, 1);
118 ///
119 /// // offset of minus one hour
120 /// let s = x.to_str_with_offset(Scale::TAI, "%F", -3600).unwrap();
121 ///
122 /// println!("{}", s);
123 /// ```
124 ///
125 /// ## Errors
126 ///
127 /// Returns [`DtErr`] if the format string contains invalid specifiers
128 /// or if the internal formatting buffer overflows (extremely unlikely
129 /// with [`STRFTIME_SIZE`]).
130 ///
131 /// ## See also
132 ///
133 /// - [`Dt::to_str`](../struct.Dt.html#method.to_str)
134 /// - [`Dt::to_str_with_tz`](../struct.Dt.html#method.to_str_with_tz)
135 #[inline]
136 pub fn to_str_with_offset(
137 &self,
138 current: Scale,
139 fmt: &str,
140 secs: i32,
141 ) -> Result<alloc::string::String, DtErr> {
142 let mut buf = [0u8; STRFTIME_SIZE];
143 let n = self._to_u8_with_offset(current, fmt, &mut buf, secs)?;
144 Ok(alloc::string::String::from_utf8_lossy(&buf[0..n]).into_owned())
145 }
146
147 /// Formats this [`Dt`] into a string, time adjusted to the given IANA timezone. Requires
148 /// the `"alloc"` feature.
149 ///
150 /// Use this method when you want full IANA-aware formatting (`%Q`, `%Z`, `%z`, etc.).
151 ///
152 /// - A copy of the [`Dt`] is adjusted by the offset at the [`Dt`]s time for the given
153 /// IANA timezone. This is so that the formatter will have:
154 /// - Accurate wall time for the timezone.
155 /// - Correct numeric offset (for `%z` / `%:z`).
156 /// - Timezone abbreviation (for `%Z`). These **do not** round-trip.
157 /// - Full IANA timezone name (for `%Q` / `%:Q`).
158 /// - Then it's converted from the `current` [`Scale`] into the
159 /// `UTC` time scale.
160 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice` are preserved.
161 /// See [`Scale`] for more info on time scales.
162 /// - No IANA timezone name or abbreviation is set.
163 ///
164 /// ## Example
165 ///
166 /// ```
167 /// # #[cfg(all(feature = "tz", feature = "parse"))]
168 /// # {
169 /// use deep_time::{Dt, Scale};
170 ///
171 /// let x: Dt = "2000-01-01 12:00:00".parse().unwrap();
172 ///
173 /// let s = x.to_str_with_tz(Scale::TAI, "%A, %B %d, %Y %H:%M:%S %Q", "America/New_York").unwrap();
174 ///
175 /// assert_eq!(s, "Saturday, January 01, 2000 07:00:00 America/New_York");
176 /// # }
177 /// ```
178 ///
179 /// ## Errors
180 ///
181 /// Returns [`DtErr`] if the format string contains invalid specifiers
182 /// or if the internal formatting buffer overflows (extremely unlikely
183 /// with [`STRFTIME_SIZE`]).
184 ///
185 /// ## See also
186 ///
187 /// - [`Dt::to_str`](../struct.Dt.html#method.to_str)
188 /// - [`Dt::to_str_with_offset`](../struct.Dt.html#method.to_str_with_offset)
189 #[inline]
190 pub fn to_str_with_tz(
191 &self,
192 current: Scale,
193 fmt: &str,
194 tz_name: &str,
195 ) -> Result<alloc::string::String, DtErr> {
196 let mut buf = [0u8; STRFTIME_SIZE];
197 let n = self._to_u8_with_tz(current, fmt, &mut buf, tz_name)?;
198 Ok(alloc::string::String::from_utf8_lossy(&buf[0..n]).into_owned())
199 }
200
201 /// Returns this instant as an **RFC 3339** / ISO 8601 timestamp in **UTC**
202 /// with the `Z` suffix.
203 ///
204 /// - Always uses UTC (`Z` = Zulu = UTC).
205 /// - Default = 9 digits (nanoseconds) but **automatically trims trailing zeros**.
206 /// - If fractional part is zero → no decimal point at all (e.g. `...45Z`).
207 /// - Example: `"2024-03-14T15:30:45.123Z"`
208 #[inline]
209 pub fn to_str_rfc3339(&self, current: Scale) -> Result<String, DtErr> {
210 self.to_str_rfc3339_nf(current, 9)
211 }
212
213 /// Same as [`Dt::to_str_rfc3339`](../struct.Dt.html#method.to_str_rfc3339) but
214 /// with a configurable maximum number of fractional digits (0–18). Trailing zeros are
215 /// always trimmed.
216 pub fn to_str_rfc3339_nf(&self, current: Scale, max_precision: usize) -> Result<String, DtErr> {
217 let prec = max_precision.min(18);
218 // Uses the formatter with the `~` "trim trailing zeros" flag.
219 // The formatter already handles:
220 // - correct 4-digit years (with sign) for |yr| < 10000
221 // - full-width years otherwise
222 // - suppressing the decimal point entirely when the trimmed fraction is zero
223 let fmt = alloc::format!("%Y-%m-%dT%H:%M:%S%.{}~fZ", prec);
224 self.to_str_with_offset(current, &fmt, 0)
225 }
226
227 /// **ISO 8601 / RFC 3339** with **actual offset** (modern `+00:00` style).
228 ///
229 /// - Uses colon-separated offset (`%:z`) instead of forcing `Z`.
230 /// - Still trims trailing zeros in the fractional part.
231 /// - Example: `"2025-04-16T14:30:45.123+00:00"`
232 #[inline]
233 pub fn to_str_iso8601(&self, current: Scale) -> Result<String, DtErr> {
234 self.to_str_with_offset(current, "%Y-%m-%dT%H:%M:%S%.~f%:z", 0)
235 }
236
237 /// **Compact ISO 8601 basic format** (no separators).
238 ///
239 /// - Useful for filenames, URLs, database keys, etc.
240 /// - Example: `"20250416T143045.123456789Z"`
241 #[inline]
242 pub fn to_str_iso8601_basic(&self, current: Scale) -> Result<String, DtErr> {
243 self.to_str_with_offset(current, "%Y%m%dT%H%M%S%.~fZ", 0)
244 }
245
246 /// **HTTP-date** format (RFC 7231 / RFC 1123) — **always in GMT**.
247 ///
248 /// This is the format used in `Date`, `Expires`, `Last-Modified` headers.
249 /// Example: `"Wed, 16 Apr 2025 14:30:45 GMT"`
250 #[inline]
251 pub fn to_str_http(&self, current: Scale) -> Result<String, DtErr> {
252 self.to_str_with_offset(current, "%a, %d %b %Y %H:%M:%S GMT", 0)
253 }
254
255 /// **RFC 2822** date format (used in email `Date` headers).
256 ///
257 /// Example: `"Wed, 16 Apr 2025 14:30:45 +0000"`
258 #[inline]
259 pub fn to_str_rfc2822(&self, current: Scale) -> Result<String, DtErr> {
260 self.to_str_with_offset(current, "%a, %d %b %Y %H:%M:%S %z", 0)
261 }
262
263 /// **ISO 8601 week date**.
264 ///
265 /// Example: `"2025-W16-3"` (year-week-day)
266 #[inline]
267 pub fn to_str_iso_week_date(&self, current: Scale) -> Result<String, DtErr> {
268 self.to_str_with_offset(current, "%G-W%V-%u", 0)
269 }
270
271 /// Just the **ISO date** part (no time).
272 ///
273 /// Example: `"2025-04-16"`
274 #[inline]
275 pub fn to_str_iso_date(&self, current: Scale) -> Result<String, DtErr> {
276 self.to_str_with_offset(current, "%Y-%m-%d", 0)
277 }
278
279 /// Just the **time** part with fractional seconds (trimmed).
280 ///
281 /// Example: `"14:30:45.123456789"`
282 #[inline]
283 pub fn to_str_iso_time(&self, current: Scale) -> Result<String, DtErr> {
284 self.to_str_with_offset(current, "%H:%M:%S%.~f", 0)
285 }
286}
287
288impl Dt {
289 /// Formats this [`Dt`] into a fixed-size binary string.
290 ///
291 /// - It is first converted from the `current` [`Scale`] into
292 /// the `UTC` time scale.
293 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice`
294 /// are preserved. See [`Scale`] for more info on time scales.
295 ///
296 /// ## Example
297 ///
298 /// ```rust
299 /// use deep_time::{Dt, Scale};
300 ///
301 /// let x = Dt::from_ymd(2000, 1, 1);
302 /// let b = x.to_str_bin(Scale::TAI, "%F").unwrap();
303 /// let s = b.as_str().unwrap();
304 ///
305 /// println!("{}", s);
306 /// ```
307 ///
308 /// ## Errors
309 ///
310 /// Returns [`DtErr`] if the format string contains invalid specifiers
311 /// or if the internal formatting buffer overflows (extremely unlikely
312 /// with [`STRFTIME_SIZE`]).
313 ///
314 /// ## See also
315 ///
316 /// - [`Dt::to_str_bin_with_offset`](../struct.Dt.html#method.to_str_bin_with_offset)
317 /// - [`Dt::to_str_bin_with_tz`](../struct.Dt.html#method.to_str_bin_with_tz)
318 pub fn to_str_bin(&self, current: Scale, fmt: &str) -> Result<LiteStr<STRFTIME_SIZE>, DtErr> {
319 let mut ymdhms = self.to_ymdhms_rich_on(current, current.to_utc());
320 ymdhms.set_offset(Some(0)).set_tz_abbrev(None);
321 let mut buf = [0u8; STRFTIME_SIZE];
322 let mut pos = 0usize;
323 ymdhms.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
324 Ok(LiteStr::from_bytes(&buf).map_err(|_| an_err!(DtErrKind::InvalidBytes))?)
325 }
326
327 /// Formats this [`Dt`] into a fixed-size binary string, applying a fixed UTC offset.
328 ///
329 /// - A copy of the [`Dt`] is adjusted by the given `secs` offset **before**
330 /// formatting, and the offset is stored so that `%z` / `%:z` format directives
331 /// will reflect it.
332 /// - Then it's converted from the `current` [`Scale`] into the
333 /// `UTC` time scale.
334 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice` are preserved.
335 /// See [`Scale`] for more info on time scales.
336 /// - No IANA timezone name or abbreviation is set.
337 ///
338 /// ## Example
339 ///
340 /// ```rust
341 /// use deep_time::{Dt, Scale};
342 ///
343 /// let x = Dt::from_ymd(2000, 1, 1);
344 ///
345 /// // offset of minus one hour
346 /// let b = x.to_str_bin_with_offset(Scale::TAI, "%F", -3600).unwrap();
347 /// let s = b.as_str().unwrap();
348 ///
349 /// println!("{}", s);
350 /// ```
351 ///
352 /// ## Errors
353 ///
354 /// Returns [`DtErr`] if the format string contains invalid specifiers
355 /// or if the internal formatting buffer overflows (extremely unlikely
356 /// with [`STRFTIME_SIZE`]).
357 ///
358 /// ## See also
359 ///
360 /// - [`Dt::to_str_bin`](../struct.Dt.html#method.to_str_bin)
361 /// - [`Dt::to_str_bin_with_tz`](../struct.Dt.html#method.to_str_bin_with_tz)
362 pub fn to_str_bin_with_offset(
363 &self,
364 current: Scale,
365 fmt: &str,
366 secs: i32,
367 ) -> Result<LiteStr<STRFTIME_SIZE>, DtErr> {
368 let ymdhms = self.ymdhms_rich_with_offset(current, secs);
369 let mut buf = [0u8; STRFTIME_SIZE];
370 let mut pos = 0usize;
371 ymdhms.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
372 Ok(LiteStr::from_bytes(&buf).map_err(|_| an_err!(DtErrKind::InvalidBytes))?)
373 }
374
375 /// Formats this [`Dt`] into a fixed-size binary string, time adjusted to the given
376 /// IANA timezone.
377 ///
378 /// Use this method when you want full IANA-aware formatting (`%Q`, `%Z`, `%z`, etc.).
379 ///
380 /// - A copy of the [`Dt`] is adjusted by the offset at the [`Dt`]s time for the given
381 /// IANA timezone. This is so that the formatter will have:
382 /// - Accurate wall time for the timezone.
383 /// - Correct numeric offset (for `%z` / `%:z`).
384 /// - Timezone abbreviation (for `%Z`). These **do not** round-trip.
385 /// - Full IANA timezone name (for `%Q` / `%:Q`).
386 /// - Then it's converted from the `current` [`Scale`] into the
387 /// `UTC` time scale.
388 /// - Historical UTC time scales such as `UTCSofa` and `UTCSpice` are preserved.
389 /// See [`Scale`] for more info on time scales.
390 /// - No IANA timezone name or abbreviation is set.
391 ///
392 /// ## Example
393 ///
394 /// ```rust
395 /// use deep_time::{Dt, Scale};
396 ///
397 /// let x = Dt::from_ymd(2000, 1, 1);
398 ///
399 /// let b = x.to_str_bin_with_tz(Scale::TAI, "%F", "America/New_York").unwrap();
400 /// let s = b.as_str().unwrap();
401 ///
402 /// println!("{}", s);
403 /// ```
404 ///
405 /// ## Errors
406 ///
407 /// Returns [`DtErr`] if the format string contains invalid specifiers
408 /// or if the internal formatting buffer overflows (extremely unlikely
409 /// with [`STRFTIME_SIZE`]).
410 ///
411 /// ## See also
412 ///
413 /// - [`Dt::to_str_bin`](../struct.Dt.html#method.to_str_bin)
414 /// - [`Dt::to_str_bin_with_offset`](../struct.Dt.html#method.to_str_bin_with_offset)
415 pub fn to_str_bin_with_tz(
416 &self,
417 current: Scale,
418 fmt: &str,
419 tz_name: &str,
420 ) -> Result<LiteStr<STRFTIME_SIZE>, DtErr> {
421 let ymdhms = self.ymdhms_rich_with_tz(current, tz_name);
422 let mut buf = [0u8; STRFTIME_SIZE];
423 let mut pos = 0usize;
424 ymdhms.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
425 Ok(LiteStr::from_bytes(&buf).map_err(|_| an_err!(DtErrKind::InvalidBytes))?)
426 }
427
428 /// Low-level no-alloc formatter that writes into a caller-provided slice,
429 /// using a fixed UTC offset.
430 ///
431 /// Same logic as [`Self::to_str_bin_with_offset`], but writes directly into
432 /// `dest` (truncated to `dest.len()`) and returns the number of bytes written.
433 pub fn _to_u8_with_offset(
434 &self,
435 current: Scale,
436 fmt: &str,
437 dest: &mut [u8],
438 secs: i32,
439 ) -> Result<usize, DtErr> {
440 let ymdhms = self.ymdhms_rich_with_offset(current, secs);
441 let mut internal_buf = [0u8; STRFTIME_SIZE];
442 let mut pos = 0usize;
443 ymdhms.format_to_buffer(fmt.as_bytes(), &mut internal_buf, &mut pos)?;
444 let written = pos.min(dest.len());
445 if written > 0 {
446 dest[0..written].copy_from_slice(&internal_buf[0..written]);
447 }
448 Ok(written)
449 }
450
451 /// Low-level no-alloc formatter that writes into a caller-provided slice,
452 /// using a full IANA timezone.
453 ///
454 /// Same logic as [`Self::to_str_bin_with_tz`], but writes directly into
455 /// `dest` (truncated to `dest.len()`) and returns the number of bytes written.
456 pub fn _to_u8_with_tz(
457 &self,
458 current: Scale,
459 fmt: &str,
460 dest: &mut [u8],
461 tz_name: &str,
462 ) -> Result<usize, DtErr> {
463 let ymdhms = self.ymdhms_rich_with_tz(current, tz_name);
464 let mut internal_buf = [0u8; STRFTIME_SIZE];
465 let mut pos = 0usize;
466 ymdhms.format_to_buffer(fmt.as_bytes(), &mut internal_buf, &mut pos)?;
467 let written = pos.min(dest.len());
468 if written > 0 {
469 dest[0..written].copy_from_slice(&internal_buf[0..written]);
470 }
471 Ok(written)
472 }
473
474 /// Returns `(is_negative, hours, minutes)`.
475 #[inline]
476 pub(crate) const fn sec_as_hhmm(seconds: i32) -> (bool, u8, u8) {
477 let total = seconds.saturating_abs();
478 let hours = (total / 3600) as u8;
479 let minutes = ((total % 3600) / 60) as u8;
480 (seconds < 0, hours, minutes)
481 }
482
483 /// Helper for creating an offset adjusted YmdHmsRich.
484 pub(crate) fn ymdhms_rich_with_offset(&self, current: Scale, secs: i32) -> YmdHmsRich {
485 let local_tp = if secs != 0 {
486 *self + Dt::new(secs as i64, 0)
487 } else {
488 *self
489 };
490 let mut ymdhms = local_tp.to_ymdhms_rich_on(current, current.to_utc());
491 ymdhms.set_offset(Some(secs));
492 ymdhms
493 }
494
495 /// Helper for creating a timezone-adjusted YmdHmsRich.
496 pub(crate) fn ymdhms_rich_with_tz(&self, current: Scale, tz_name: &str) -> YmdHmsRich {
497 // 1. Get the true UTC Unix timestamp
498 let utc_unix = self
499 .to(current, current.to_utc())
500 .to_diff_raw(Dt::UNIX_EPOCH);
501
502 // 2. Look up offset + abbrev at that exact UTC instant
503 let (offset_secs, abbrev) = match offset_info_at_utc(tz_name, utc_unix.sec) {
504 Some(info) => (info.offset, info.abbrev),
505 None => (0, "UTC"), // fallback for unknown timezone
506 };
507
508 // 3. Build local time = UTC + offset
509 let span = Dt::new(offset_secs as i64, 0);
510 let local_tp = *self + span;
511
512 let mut ymdhms = local_tp.to_ymdhms_rich_on(current, current.to_utc());
513 ymdhms.set_offset(Some(offset_secs));
514 ymdhms.set_tz(Some(tz_name));
515 ymdhms.set_tz_abbrev(Some(abbrev));
516 ymdhms
517 }
518}