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
//! UTC offset: [`Offset`].
//!
//! An offset is a signed whole-second displacement from UTC, constrained to
//! the open interval `(-24h, 24h)`. It is the smallest timezone concept there
//! is: no transitions, no rules, just "how far is this clock from UTC".
use core::fmt;
use core::str::FromStr;
use crate::calendar::SECS_PER_DAY;
use crate::error::{Error, Result};
use crate::format;
use crate::write::{with_buf, FmtSink};
#[cfg(feature = "alloc")]
use alloc::string::String;
/// A UTC offset in seconds.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Offset(i32);
impl Offset {
/// The zero offset.
pub const UTC: Offset = Offset(0);
/// Build from a whole-second offset; `None` outside `(-24h, 24h)`.
pub const fn from_seconds_opt(seconds: i32) -> Option<Offset> {
if seconds <= -(SECS_PER_DAY as i32) || seconds >= SECS_PER_DAY as i32 {
return None;
}
Some(Offset(seconds))
}
/// Build from a whole-second offset; fails outside `(-24h, 24h)`.
pub const fn from_seconds(seconds: i32) -> Result<Offset> {
match Self::from_seconds_opt(seconds) {
Some(offset) => Ok(offset),
None => Err(Error::invalid_offset()),
}
}
/// Build from a whole-second *east* offset (chrono's `FixedOffset::east`,
/// but `const`). Panics outside `(-24h, 24h)`.
///
/// This exists so a fixed zone can be declared in `const` context without
/// `Option::unwrap` (which is not `const` on Rust < 1.83). Prefer
/// [`Offset::from_seconds`] when the value is untrusted.
///
/// ```
/// # use tzcraft::Offset;
/// const TOKYO: Offset = Offset::east(9 * 3600);
/// # fn main() { assert_eq!(TOKYO.as_seconds(), 9 * 3600); }
/// ```
pub const fn east(seconds: i32) -> Offset {
match Self::from_seconds_opt(seconds) {
Some(offset) => offset,
None => panic!("timezone offset must be within ±24 hours"),
}
}
/// Build from a whole-second *west* offset (chrono's `FixedOffset::west`,
/// but `const`). Panics outside `(-24h, 24h)`.
pub const fn west(seconds: i32) -> Offset {
// Negate without overflowing at `i32::MIN` (which is out of range
// anyway and lands in the panic arm).
let negated = if seconds == i32::MIN {
i32::MIN
} else {
-seconds
};
match Self::from_seconds_opt(negated) {
Some(offset) => offset,
None => panic!("timezone offset must be within ±24 hours"),
}
}
/// Build from signed hours plus minutes and seconds.
///
/// `hours` carries the sign and the whole offset shares it
/// (`from_hms(-8, 30, 15)` is `-08:30:15`); minutes and seconds are
/// magnitudes. The total must stay inside `(-24h, 24h)`.
pub fn from_hms(hours: i32, minutes: u32, seconds: u32) -> Result<Offset> {
if minutes >= 60 {
return Err(Error::out_of_range("minute"));
}
if seconds >= 60 {
return Err(Error::out_of_range("second"));
}
let sign = if hours < 0 { -1i64 } else { 1i64 };
let magnitude = (hours as i64).abs() * 3600 + minutes as i64 * 60 + seconds as i64;
let total = i32::try_from(sign * magnitude).map_err(|_| Error::invalid_offset())?;
Self::from_seconds(total)
}
/// Whole-second displacement.
pub const fn as_seconds(self) -> i32 {
self.0
}
/// `(signed_hours, minutes, seconds)`.
pub fn as_hms(self) -> (i32, u32, u32) {
let abs = self.0.unsigned_abs();
(
self.0.signum() * (abs / 3600) as i32,
(abs / 60) % 60,
abs % 60,
)
}
/// Whether this is exactly UTC.
pub const fn is_utc(self) -> bool {
self.0 == 0
}
/// The mirrored offset.
pub const fn negate(self) -> Offset {
Offset(-self.0)
}
/// ISO 8601 rendering: `Z`, or `±HH:MM` with `:SS` when seconds differ.
///
/// This method allocates. The allocator-free equivalent is
/// [`Offset::write_iso`].
#[cfg(feature = "alloc")]
pub fn to_iso(self) -> String {
format::format_offset(self)
}
/// ISO 8601 rendering into a caller-owned buffer (allocator-free).
///
/// Returns the number of bytes written; a 10-byte buffer is always large
/// enough.
pub fn write_iso(self, out: &mut [u8]) -> Result<usize> {
with_buf(out, |b| format::format_offset_into(b, self))
}
/// Parse `Z`, `±HH:MM`, `±HHMM`, `±HH` or `±HH:MM:SS`.
pub fn from_iso(s: &str) -> Result<Offset> {
format::parse_offset_iso(s)
}
}
impl fmt::Display for Offset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut sink = FmtSink(f);
format::format_offset_into(&mut sink, *self).map_err(|_| fmt::Error)
}
}
impl FromStr for Offset {
type Err = Error;
fn from_str(s: &str) -> Result<Offset> {
Offset::from_iso(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn construction_and_parts() {
let o = Offset::from_hms(-8, 30, 15).unwrap();
assert_eq!(o.as_seconds(), -(8 * 3600 + 30 * 60 + 15));
assert_eq!(o.as_hms(), (-8, 30, 15));
assert!(Offset::from_hms(24, 0, 0).is_err());
assert!(Offset::from_hms(0, 60, 0).is_err());
assert!(Offset::from_seconds(0).unwrap().is_utc());
assert_eq!(Offset::from_seconds(86_399).unwrap().as_seconds(), 86_399);
assert!(Offset::from_seconds(86_400).is_err());
assert!(Offset::from_seconds(-86_400).is_err());
}
#[cfg(feature = "alloc")]
#[test]
fn iso_round_trips() {
for s in ["Z", "+08:00", "-05:30", "-04:30:15"] {
let o = Offset::from_iso(s).unwrap_or_else(|e| panic!("{s}: {e}"));
assert_eq!(o.to_iso(), s, "{s}");
}
// "+0530" and "+05" parse but canonicalize to the colon form on output.
assert_eq!(
Offset::from_iso("+0530").unwrap().as_seconds(),
5 * 3600 + 30 * 60
);
assert_eq!(Offset::from_iso("+05").unwrap().as_seconds(), 5 * 3600);
// A zero offset parses but canonicalizes to "Z".
assert_eq!(Offset::from_iso("+00:00").unwrap(), Offset::UTC);
assert!(Offset::from_iso("+24:00").is_err());
assert!(Offset::from_iso("abc").is_err());
assert!(Offset::from_iso("+08:60").is_err());
}
}