utc_offset/
lib.rs

1#![allow(clippy::doc_markdown, clippy::missing_errors_doc)]
2
3#[cfg(test)]
4mod test;
5
6use std::{process::Command, str};
7
8use once_cell::sync::OnceCell;
9use parking_lot::RwLock;
10use thiserror::Error;
11use time::{
12    error::{ComponentRange, Format},
13    format_description::FormatItem,
14    macros::format_description,
15    Duration, OffsetDateTime, UtcOffset,
16};
17
18/// Wrapper with error defaulted to our [enum@Error].
19pub type Result<T, E = Error> = std::result::Result<T, E>;
20
21/// Soft errors that may occur in the process of initializing the global utc offset.
22#[derive(Debug)]
23pub struct Errors(Vec<Error>);
24impl Errors {
25    fn new() -> Self {
26        Self(vec![])
27    }
28    fn push(&mut self, e: Error) {
29        self.0.push(e)
30    }
31}
32
33/// An enumeration of all possible error that may occur here.
34#[derive(Error, Debug)]
35pub enum Error {
36    /// Failure acquiring the write lock as it was likely poisoned.
37    #[error("Unable to acquire a write lock")]
38    WriteLock,
39
40    /// Failure acquiring the read lock as it was likely poisoned.
41    #[error("Unable to acquire a read lock")]
42    ReadLock,
43
44    /// An error occurred Parsing a time string.
45    #[error("Unable to parse time: {0}")]
46    Parse(#[from] time::error::Parse),
47
48    /// The values used to create a UTC Offset were invalid.
49    #[error("Unable to construct offset from offset hours/minutes: {0}")]
50    Time(#[from] ComponentRange),
51
52    /// The library was failed to create a timestamp string from a date/time
53    /// struct
54    #[error("Unable to format timestamp: {0}")]
55    TimeFormat(#[from] Format),
56
57    /// An invalid value for the offset hours was passed in.
58    #[error("Invalid offset hours: {0}")]
59    InvalidOffsetHours(i8),
60
61    /// An invalid value for the offset minutes was passed in.
62    #[error("Invalid offset minutes: {0}")]
63    InvalidOffsetMinutes(i8),
64
65    /// An invalid value for the offset minutes was passed in.
66    #[error("Unable to parse offset string")]
67    InvalidOffsetString,
68
69    /// An error occurred executing the system-specific command to get the current time.
70    #[error("Error executing command to get system time: {0}")]
71    TimeCommand(std::io::Error),
72
73    /// There was an overflow computing the Datetime
74    #[error("Datetime overflow")]
75    DatetimeOverflow,
76
77    /// The global offset is not initialized.
78    #[error("The global offset is not initialized.")]
79    Uninitialized,
80}
81
82static OFFSET: OnceCell<RwLock<UtcOffset>> = OnceCell::new();
83const TIME_FORMAT: &[FormatItem<'static>] = format_description!(
84    "[year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_second]"
85);
86const PARSE_FORMAT: &[FormatItem<'static>] =
87    format_description!("[offset_hour][optional [:]][offset_minute]");
88
89/// Returns the global offset value if it is initialized, otherwise it
90/// returns an error. Unlike the `try_set_` functions, this waits for a read lock.
91#[inline]
92pub fn get_global_offset() -> Result<UtcOffset> {
93    if let Some(o) = OFFSET.get() {
94        Ok(*o.read())
95    } else {
96        Err(Error::Uninitialized)
97    }
98}
99/// Attempts to set the global offset, returning an error if the
100/// write lock cannot be obtained.
101#[inline]
102pub fn try_set_global_offset(o: UtcOffset) -> Result<()> {
103    let o_ref = OFFSET.get_or_init(|| RwLock::new(o));
104    if let Some(mut o_lock) = o_ref.try_write() {
105        *o_lock = o;
106        Ok(())
107    } else {
108        Err(Error::WriteLock)
109    }
110}
111
112/// Sets a static UTC offset, from an input string, to use with future calls to
113/// `get_local_timestamp_rfc3339`. The format should be [+/-]HHMM.
114///
115/// # Arguments
116/// * input - The UTC offset as a string. Example values are: +0900, -0930,
117///   1000, +09:00, -09:30, 10:00
118///
119/// # Error
120/// If we fail to parse the input offset string we'll return an `Error::InvalidOffsetString`.
121#[inline]
122pub fn try_set_global_offset_from_str(input: &str) -> Result<()> {
123    let trimmed = trim_new_lines(input);
124    let o = UtcOffset::parse(trimmed, &PARSE_FORMAT).map_err(|_| Error::InvalidOffsetString)?;
125    try_set_global_offset(o)
126}
127
128/// Sets a static UTC offset to use with future calls to
129/// `get_local_timestamp_rfc3339`
130///
131/// # Arguments
132/// * offset_hours - the hour value of the UTC offset, cannot be less than -12
133///   or greater than 14
134/// * offset_minutes - the minute value of the UTC offset, cannot be less than 0
135///   or greater than 59
136///
137/// # Errors
138/// If the offsets are out of range or there is an issue setting the offset an error will be returned.
139#[allow(clippy::manual_range_contains)]
140#[inline]
141pub fn try_set_global_offset_from_pair(offset_hours: i8, offset_minutes: i8) -> Result<()> {
142    let o = from_offset_pair(offset_hours, offset_minutes)?;
143    try_set_global_offset(o)
144}
145
146/// Gets a timestamp string using in either the local offset or +00:00
147///
148/// # Returns
149/// Returns a `Result` of either the timestamp in the following format or the error encountered during its construction.
150/// ```text
151/// [year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_second]
152/// ```
153/// , or an error if the method fails.
154/// The timezone will be in the local offset IF any of the following succeed:
155///     1.) set_global_offset is called.
156///     2.) `time::UtcOffset::current_local_offset()` works
157///     3.) The library is able to query the timezone using system commands.
158/// If none succeed, we default to UTC.
159#[inline]
160pub fn get_local_timestamp_rfc3339() -> Result<(String, Errors)> {
161    let (offset, errs) = get_utc_offset();
162    let res = get_local_timestamp_from_offset_rfc3339(offset)?;
163    Ok((res, errs))
164}
165
166/// Gets a timestamp string using the specified offset
167///
168/// # Arguments
169/// * utc_offset - A caller specified offset
170///
171/// # Returns
172/// Returns a `Result` timestamp in the following format or the error encountered during its construction.
173/// ```text
174/// [year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_second]
175/// ```
176#[allow(clippy::cast_lossless)]
177#[inline]
178pub fn get_local_timestamp_from_offset_rfc3339(utc_offset: UtcOffset) -> Result<String> {
179    let dt_now = OffsetDateTime::now_utc();
180    let offset_dt_now = if utc_offset == UtcOffset::UTC {
181        dt_now
182    } else if let Some(t) = dt_now.checked_add(Duration::minutes(utc_offset.whole_minutes() as i64))
183    {
184        t.replace_offset(utc_offset)
185    } else {
186        // datetime overflow (not just hours, total representable time)
187        return Err(Error::DatetimeOverflow);
188    };
189
190    let formatted = offset_dt_now.format(&TIME_FORMAT)?;
191    Ok(formatted)
192}
193
194/// Do whatever it takes to get a utc offset and cache it.
195/// Worst case scenario we just assume UTC time.
196#[inline]
197pub fn get_utc_offset() -> (UtcOffset, Errors) {
198    let mut errs = Errors::new();
199    if let Ok(o) = get_global_offset() {
200        return (o, errs);
201    }
202
203    let o = match construct_offset() {
204        Ok(o) => o,
205        Err(e) => {
206            errs.push(e);
207            UtcOffset::UTC
208        }
209    };
210
211    if let Err(e) = try_set_global_offset(o) {
212        errs.push(e)
213    }
214    (o, errs)
215}
216
217fn parse_cmd_output(stdout: &[u8], formatter: &[FormatItem<'static>]) -> Result<UtcOffset> {
218    let output = String::from_utf8_lossy(stdout);
219    let trimmed = trim_new_lines(&output);
220    let offset = UtcOffset::parse(trimmed, &formatter)?;
221    Ok(offset)
222}
223
224fn offset_from_process() -> Result<UtcOffset> {
225    let cmd = if cfg!(target_os = "windows") {
226        || {
227            Command::new("powershell")
228                .arg("Get-Date")
229                .arg("-Format")
230                .arg("\"K \"")
231                .output()
232        }
233    } else {
234        || Command::new("date").arg("+%z").output()
235    };
236
237    match cmd() {
238        Ok(output) => parse_cmd_output(&output.stdout, PARSE_FORMAT),
239        Err(e) => Err(Error::TimeCommand(e)),
240    }
241}
242
243fn trim_new_lines(s: &str) -> &str {
244    s.trim().trim_end_matches("\r\n").trim_matches('\n')
245}
246
247fn from_offset_pair(offset_hours: i8, offset_minutes: i8) -> Result<UtcOffset> {
248    if !(-12..=14).contains(&offset_hours) {
249        return Err(Error::InvalidOffsetHours(offset_hours));
250    } else if !(0..=59).contains(&offset_minutes) {
251        return Err(Error::InvalidOffsetMinutes(offset_minutes));
252    }
253
254    Ok(UtcOffset::from_hms(offset_hours, offset_minutes, 0)?)
255}
256
257/// Construct an offset.
258fn construct_offset() -> Result<UtcOffset> {
259    UtcOffset::current_local_offset().or_else(|_| offset_from_process())
260}