Skip to main content

deep_time/dt/
to_str.rs

1use crate::{AsciiStr, Dt, DtErr, GregorianTime, STRFTIME_SIZE, Scale, tzdb::offset_info_at_utc};
2
3#[cfg(feature = "alloc")]
4use crate::ATTOS_PER_SEC;
5
6#[cfg(feature = "alloc")]
7impl Dt {
8    /// Converts this `Dt` to an ISO 8601 duration string
9    /// (e.g. `"PT1H23M45.6789S"`, `"-PT0.5S"`, `"PT0.000000000000000001S"`, or `"PT0S"`).
10    ///
11    /// - This method is only available when the **`alloc`** feature is enabled.
12    /// - It returns `alloc::string::String` (no_std + alloc compatible).
13    pub fn to_iso_duration(&self) -> alloc::string::String {
14        if self.is_zero() {
15            return alloc::string::String::from("PT0S");
16        }
17
18        let total = self.to_attos();
19        let negative = total < 0;
20        let mut attos = total.unsigned_abs();
21
22        let mut s = alloc::string::String::with_capacity(48);
23        if negative {
24            s.push('-');
25        }
26        s.push_str("PT");
27
28        const A_PER_S: u128 = ATTOS_PER_SEC as u128;
29        const A_PER_M: u128 = A_PER_S * 60;
30        const A_PER_H: u128 = A_PER_M * 60;
31
32        let hours = attos / A_PER_H;
33        attos %= A_PER_H;
34        let minutes = attos / A_PER_M;
35        attos %= A_PER_M;
36        let seconds = attos / A_PER_S;
37        let frac_attos = attos % A_PER_S;
38
39        if hours > 0 {
40            s.push_str(&alloc::format!("{}", hours));
41            s.push('H');
42        }
43        if minutes > 0 {
44            s.push_str(&alloc::format!("{}", minutes));
45            s.push('M');
46        }
47
48        if seconds > 0 || frac_attos > 0 {
49            s.push_str(&alloc::format!("{}", seconds));
50
51            if frac_attos != 0 {
52                let frac_str = alloc::format!("{frac_attos:018}");
53                let trimmed = frac_str.trim_end_matches('0');
54                s.push('.');
55                s.push_str(trimmed);
56            }
57
58            s.push('S');
59        }
60
61        s
62    }
63
64    /// High-level allocating formatter (defaults to UTC / label-only).
65    ///
66    /// Equivalent to [`Self::to_str_with_offset`] with `secs = 0`.
67    ///
68    /// This is the convenient `alloc` version of [`Self::to_str_bin`].
69    #[inline]
70    pub fn to_str(&self, current: Scale, fmt: &str) -> Result<alloc::string::String, DtErr> {
71        self.to_str_with_offset(current, fmt, 0)
72    }
73
74    /// High-level allocating formatter with a fixed UTC offset.
75    ///
76    /// - The civil time is adjusted by the given offset before formatting,
77    /// and `%z`/`%:z` directives will reflect that offset.
78    /// - IANA name/abbreviation are **not** set.
79    ///
80    /// This is the convenient `alloc` version of [`Self::to_str_bin_with_offset`].
81    #[inline]
82    pub fn to_str_with_offset(
83        &self,
84        current: Scale,
85        fmt: &str,
86        secs: i32,
87    ) -> Result<alloc::string::String, DtErr> {
88        let mut buf = [0u8; STRFTIME_SIZE];
89        let n = self.to_u8_with_offset(current, fmt, &mut buf, secs)?;
90        Ok(alloc::string::String::from_utf8_lossy(&buf[0..n]).into_owned())
91    }
92
93    /// High-level allocating formatter with full IANA timezone support
94    /// (Jiff-compatible directive behavior).
95    ///
96    /// Performs a correct UTC-based IANA lookup so that the following
97    /// directives produce accurate results:
98    ///
99    /// - `%Q` / `%:Q` → full IANA timezone name (e.g. `America/New_York`)
100    /// - `%Z` → timezone abbreviation (e.g. `EDT`). These **do not** round-trip.
101    /// - `%z` / `%:z` → numeric offset
102    ///
103    /// This is the convenient `alloc` version of [`Self::to_str_bin_with_tz`].
104    #[inline]
105    pub fn to_str_with_tz(
106        &self,
107        current: Scale,
108        fmt: &str,
109        tz_name: &str,
110    ) -> Result<alloc::string::String, DtErr> {
111        let mut buf = [0u8; STRFTIME_SIZE];
112        let n = self.to_u8_with_tz(current, fmt, &mut buf, tz_name)?;
113        Ok(alloc::string::String::from_utf8_lossy(&buf[0..n]).into_owned())
114    }
115}
116
117impl Dt {
118    /// Formats this `Dt` into a fixed-size ASCII string **without** any heap allocation.
119    ///
120    /// The datetime is first converted to Gregorian civil time on the given
121    /// `current` scale, **UTC** (offset = 0, no timezone abbreviation or IANA
122    /// name). This is the simplest no-alloc formatter.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`DtErr`] if the format string contains invalid specifiers
127    /// or if the internal formatting buffer overflows (extremely unlikely
128    /// with [`STRFTIME_SIZE`]).
129    pub fn to_str_bin(&self, current: Scale, fmt: &str) -> Result<AsciiStr<STRFTIME_SIZE>, DtErr> {
130        let mut gt = self.to_gregorian_time(current);
131        gt.set_offset(Some(0)).set_tz_abbrev(None);
132        let mut buf = [0u8; STRFTIME_SIZE];
133        let mut pos = 0usize;
134        gt.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
135        Ok(AsciiStr::from_filled_buffer(buf))
136    }
137
138    /// Formats this `Dt` into a fixed-size ASCII string **without** any heap allocation,
139    /// applying a fixed UTC offset.
140    ///
141    /// - The civil time is adjusted by the given `secs` offset **before** formatting,
142    /// and the offset is stored so that `%z` / `%:z` directives will reflect it.
143    /// - No IANA timezone name or abbreviation is set.
144    pub fn to_str_bin_with_offset(
145        &self,
146        current: Scale,
147        fmt: &str,
148        secs: i32,
149    ) -> Result<AsciiStr<STRFTIME_SIZE>, DtErr> {
150        let gt = self.gregorian_time_with_offset(current, secs);
151        let mut buf = [0u8; STRFTIME_SIZE];
152        let mut pos = 0usize;
153        gt.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
154        Ok(AsciiStr::from_filled_buffer(buf))
155    }
156
157    /// Formats this `Dt` into a fixed-size ASCII string **without** any heap allocation,
158    /// adjusting to the given IANA timezone.
159    ///
160    /// This performs a correct UTC-based lookup in the IANA transition table,
161    /// so the resulting `GregorianTime` contains:
162    /// - accurate civil time
163    /// - correct numeric offset (for `%z` / `%:z`)
164    /// - timezone abbreviation (for `%Z`). These **do not** round-trip.
165    /// - full IANA timezone name (for `%Q` / `%:Q`)
166    ///
167    /// Use this method when you want full IANA-aware formatting (`%Q`, `%Z`,
168    /// `%z`, etc.).
169    pub fn to_str_bin_with_tz(
170        &self,
171        current: Scale,
172        fmt: &str,
173        tz_name: &str,
174    ) -> Result<AsciiStr<STRFTIME_SIZE>, DtErr> {
175        let gt = self.gregorian_time_with_tz(current, tz_name);
176        let mut buf = [0u8; STRFTIME_SIZE];
177        let mut pos = 0usize;
178        gt.format_to_buffer(fmt.as_bytes(), &mut buf, &mut pos)?;
179        Ok(AsciiStr::from_filled_buffer(buf))
180    }
181
182    /// Low-level no-alloc formatter that writes into a caller-provided slice,
183    /// using a fixed UTC offset.
184    ///
185    /// Same logic as [`Self::to_str_bin_with_offset`], but writes directly into
186    /// `dest` (truncated to `dest.len()`) and returns the number of bytes written.
187    pub fn to_u8_with_offset(
188        &self,
189        current: Scale,
190        fmt: &str,
191        dest: &mut [u8],
192        secs: i32,
193    ) -> Result<usize, DtErr> {
194        let gt = self.gregorian_time_with_offset(current, secs);
195        let mut internal_buf = [0u8; STRFTIME_SIZE];
196        let mut pos = 0usize;
197        gt.format_to_buffer(fmt.as_bytes(), &mut internal_buf, &mut pos)?;
198        let written = pos.min(dest.len());
199        if written > 0 {
200            dest[0..written].copy_from_slice(&internal_buf[0..written]);
201        }
202        Ok(written)
203    }
204
205    /// Low-level no-alloc formatter that writes into a caller-provided slice,
206    /// using a full IANA timezone.
207    ///
208    /// Same logic as [`Self::to_str_bin_with_tz`], but writes directly into
209    /// `dest` (truncated to `dest.len()`) and returns the number of bytes written.
210    pub fn to_u8_with_tz(
211        &self,
212        current: Scale,
213        fmt: &str,
214        dest: &mut [u8],
215        tz_name: &str,
216    ) -> Result<usize, DtErr> {
217        let gt = self.gregorian_time_with_tz(current, tz_name);
218        let mut internal_buf = [0u8; STRFTIME_SIZE];
219        let mut pos = 0usize;
220        gt.format_to_buffer(fmt.as_bytes(), &mut internal_buf, &mut pos)?;
221        let written = pos.min(dest.len());
222        if written > 0 {
223            dest[0..written].copy_from_slice(&internal_buf[0..written]);
224        }
225        Ok(written)
226    }
227
228    /// Returns `(is_negative, hours, minutes)`.
229    #[inline]
230    pub const fn sec_as_hhmm(seconds: i32) -> (bool, u8, u8) {
231        let total = seconds.saturating_abs();
232        let hours = (total / 3600) as u8;
233        let minutes = ((total % 3600) / 60) as u8;
234        (seconds < 0, hours, minutes)
235    }
236
237    /// Helper for creating an offset adjusted GregorianTime.
238    pub(crate) fn gregorian_time_with_offset(&self, current: Scale, secs: i32) -> GregorianTime {
239        let local_tp = if secs != 0 {
240            *self + Dt::new(secs as i64, 0)
241        } else {
242            *self
243        };
244        let mut gt = local_tp.to_gregorian_time(current);
245        gt.set_offset(Some(secs));
246        gt
247    }
248
249    /// Helper for creating a timezone-adjusted GregorianTime.
250    ///
251    /// Always converts to UTC first, then does a correct UTC-based lookup
252    /// in the IANA transition table. This avoids the previous bug where
253    /// a non-UTC `unix_ts` was being passed to `offset_info_at_local`.
254    pub(crate) fn gregorian_time_with_tz(&self, current: Scale, tz_name: &str) -> GregorianTime {
255        // 1. Get the true UTC Unix timestamp (this is what we search with)
256        let utc_unix = self
257            .to(current, current.to_ut())
258            .to_diff_raw(Dt::UNIX_EPOCH);
259
260        // 2. Look up offset + abbrev at that exact UTC instant
261        let (offset_secs, abbrev) = match offset_info_at_utc(tz_name, utc_unix.sec) {
262            Some(info) => (info.offset, info.abbrev),
263            None => (0, "UTC"), // fallback for unknown timezone
264        };
265
266        // 3. Build local time = UTC + offset
267        let span = Dt::new(offset_secs as i64, 0);
268        let local_tp = *self + span;
269
270        let mut gt = local_tp.to_gregorian_time(current);
271        gt.set_offset(Some(offset_secs));
272        gt.set_tz(Some(tz_name));
273        gt.set_tz_abbrev(Some(abbrev));
274        gt
275    }
276}