ako 0.0.3

Ako is a Rust crate that offers a practical and human-friendly approach to creating, manipulating, formatting and converting dates, times and timestamps.
Documentation
use alloc::sync::Arc;
use core::fmt::{self, Debug, Formatter};
use core::time::Duration;

use crate::error::ErrorKind;

#[derive(Clone, Hash, Ord, PartialOrd, Eq, PartialEq)]
pub enum OffsetName {
    Fixed,
    Location(Arc<str>),
}

/// A time-zone **offset** from UTC, such as `+02:00`.
///
/// A time-zone offset is the number of seconds that a specific time-zone
/// is ahead (`+`) or behind (`-`) UTC. For example, `+02:00` means that the
/// local time is 2 hours ahead of UTC, while `-00:30` means that the local
/// time is 30 minutes behind UTC.
///
/// **A time-zone offset is not a time-zone.** A time-zone may use “-07:00” in
/// the winter and “-08:00” in the summer. A time-zone may decide to change its
/// offset at any point.
///
#[derive(Clone, Hash, Ord, PartialOrd, Eq, PartialEq)]
pub struct Offset {
    // whether daylight savings time is being observed
    // None if the information is not available (e.g., this offset is fixed and not from a time-zone)
    pub(crate) dst: Option<bool>,

    pub(crate) seconds: i32,

    pub(crate) name: OffsetName,
}

// Constants: UTC
impl Offset {
    /// The fixed time-zone offset for `UTC`.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// #
    /// assert_eq!(Offset::UTC.as_total_minutes(), 0);
    /// ```
    ///
    pub const UTC: Self = Self {
        seconds: 0,
        dst: None,
        name: OffsetName::Fixed,
    };
}

// Constants: Bounds
impl Offset {
    /// The maximum supported time-zone offset, `+18:00:00`.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// #
    /// assert_eq!(Offset::MAX.as_total_minutes(), 1080);
    /// ```
    ///
    pub const MAX: Self = Self {
        seconds: 18 * 60 * 60,
        dst: None,
        name: OffsetName::Fixed,
    };

    /// The minimum supported time-zone offset, `-18:00:00`.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// #
    /// assert_eq!(Offset::MIN.as_total_minutes(), -1080);
    /// ```
    ///
    pub const MIN: Self = Self {
        seconds: -18 * 60 * 60,
        dst: None,
        name: OffsetName::Fixed,
    };
}

// Construction
impl Offset {
    /// Obtains a UTC time-zone offset from the given `hours`, `minutes`, and `seconds`.
    ///
    /// The signs of the arguments should be consistent. The final offset will
    /// take the sign of the first non-zero argument.
    ///
    /// Use [`offset!`] when the values are statically known for compile-time
    /// validation.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert_eq!(Offset::of(-7, 0, 0)?.as_total_minutes(), -420); // -07:00
    /// assert_eq!(Offset::of(0, -30, 0)?.as_total_minutes(), -30); // -00:30
    /// # Ok(()) }
    /// ```
    ///
    pub const fn of(hours: i8, minutes: i8, seconds: i8) -> crate::Result<Self> {
        ensure_range!(-18, 18, hours);
        ensure_range!(-59, 59, minutes);
        ensure_range!(-59, 59, seconds);

        let negative = if hours != 0 {
            hours.is_negative()
        } else if minutes != 0 {
            minutes.is_negative()
        } else {
            seconds.is_negative()
        };

        let mut seconds =
            (hours.abs() as i32 * 60 * 60) + (minutes.abs() as i32 * 60) + seconds.abs() as i32;

        if negative {
            seconds = -seconds;
        }

        Self::from_seconds(seconds)
    }
}

// Conversion From
impl Offset {
    /// Obtains the UTC time-zone offset from the number of hours from UTC.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert_eq!(Offset::from_hours(4)?.as_total_minutes(), 240);
    /// # Ok(()) }
    /// ```
    ///
    pub const fn from_hours(hours: i8) -> crate::Result<Self> {
        Self::from_minutes((hours as i16) * 60)
    }

    /// Obtains the UTC time-zone offset from the number of minutes from UTC.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert_eq!(Offset::from_minutes(1080)?, Offset::MAX);
    /// # Ok(()) }
    /// ```
    ///
    pub const fn from_minutes(minutes: i16) -> crate::Result<Self> {
        Self::from_seconds((minutes as i32) * 60)
    }

    /// Obtains the UTC time-zone offset from the number of seconds from UTC.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert_eq!(Offset::from_seconds(120)?.as_total_minutes(), 2);
    /// # Ok(()) }
    /// ```
    ///
    pub const fn from_seconds(seconds: i32) -> crate::Result<Self> {
        ensure_range!(Self::MIN.seconds, Self::MAX.seconds, seconds);

        Ok(Self {
            seconds,
            dst: None,
            name: OffsetName::Fixed,
        })
    }
}

// Components
impl Offset {
    /// Gets the hours, minutes, and seconds of this UTC offset.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// #
    /// assert_eq!(Offset::MAX.components(), (18, 0, 0));
    /// ```
    ///
    #[must_use]
    pub const fn components(&self) -> (i8, i8, i8) {
        let mut seconds = self.seconds;

        let hours = seconds / 3_600;
        seconds -= hours * 3_600;

        let minutes = seconds / 60;
        seconds -= minutes * 60;

        (hours as i8, minutes as i8, seconds as i8)
    }

    /// Gets the hours of this UTC offset.
    #[must_use]
    pub const fn hours(&self) -> i8 {
        (self.seconds / 3_600) as i8
    }

    /// Gets the minutes of this UTC offset.
    #[must_use]
    pub const fn minutes(&self) -> i8 {
        ((self.seconds % 3_600) / 60) as i8
    }

    /// Gets the seconds of this UTC offset.
    #[must_use]
    pub const fn seconds(&self) -> i8 {
        ((self.seconds % 3_600) % 60) as i8
    }
}

// Queries
impl Offset {
    /// Returns `true` if this UTC time-zone offset is exactly zero.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert!(Offset::UTC.is_utc()); // UTC
    /// assert!(Offset::from_seconds(0)?.is_utc()); // UTC
    /// assert!(!Offset::of(3, 30, 0)?.is_utc()); // not UTC
    /// # Ok(()) }
    /// ```
    ///
    #[must_use]
    pub const fn is_utc(&self) -> bool {
        self.seconds == 0
    }

    /// Returns `true` if this UTC time-zone offset is positive (and not zero).
    ///
    /// A positive time-zone offset means that the local time is ahead of UTC.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert!(!Offset::UTC.is_positive()); // not positive
    /// assert!(Offset::from_hours(4)?.is_positive()); // positive
    /// assert!(!Offset::from_hours(-7)?.is_positive()); // not positive
    /// # Ok(()) }
    /// ```
    ///
    #[must_use]
    pub const fn is_positive(&self) -> bool {
        self.seconds.is_positive()
    }

    /// Returns `true` if this UTC time-zone offset is negative (and not zero).
    ///
    /// A negative time-zone offset means that the local time is behind UTC.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use ako::Offset;
    /// # fn main() -> ako::Result<()> {
    /// assert!(!Offset::UTC.is_negative()); // not negative
    /// assert!(Offset::of(0, -30, 0)?.is_negative()); // negative
    /// assert!(!Offset::from_hours(4)?.is_negative()); // not negative
    /// # Ok(()) }
    /// ```
    ///
    #[must_use]
    pub const fn is_negative(&self) -> bool {
        self.seconds.is_negative()
    }
}

// Conversion To
impl Offset {
    /// Gets the total number of whole hours represented by this UTC offset.
    #[must_use]
    pub const fn as_total_hours(&self) -> i32 {
        self.seconds / 3_600
    }

    /// Gets the total number of whole minutes represented by this UTC offset.
    #[must_use]
    pub const fn as_total_minutes(&self) -> i32 {
        self.seconds / 60
    }

    /// Gets the total number of whole seconds represented by this UTC offset.
    #[must_use]
    pub const fn as_total_seconds(&self) -> i32 {
        self.seconds
    }

    /// Gets the total number of whole nanoseconds represented by this UTC offset.
    ///
    /// Offsets only store seconds; the number of seconds is simply
    /// multiplied by 1,000,000,000 to get a number of nanoseconds
    /// for convenience.
    ///
    #[must_use]
    pub const fn as_total_nanoseconds(&self) -> i64 {
        self.seconds as i64 * 1_000_000_000
    }
}

impl Debug for Offset {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        f.pad(&self.format_rfc3339())
    }
}

// Convert std Duration to ako Offset
impl TryFrom<Duration> for Offset {
    type Error = crate::Error;

    fn try_from(value: Duration) -> crate::Result<Self> {
        let seconds: i32 = value
            .as_secs()
            .try_into()
            .map_err(|_| ErrorKind::OutOfRange)?;

        Self::from_seconds(seconds)
    }
}

// Convert ako Offset to std Duration
impl TryFrom<Offset> for Duration {
    type Error = crate::Error;

    fn try_from(value: Offset) -> crate::Result<Self> {
        let seconds = value.as_total_seconds();

        if seconds < 0 {
            Err(ErrorKind::OutOfRange.into())
        } else {
            Ok(Duration::from_secs(seconds as u64))
        }
    }
}

#[cfg(test)]
mod tests {
    use test_case::test_case;

    use crate::Offset;

    #[test_case((0, 0), "Z")]
    #[test_case((7, 0), "+07:00")]
    #[test_case((-8, 0), "-08:00")]
    #[test_case((8, 45), "+08:45")]
    fn expect_rfc3339_offset((hours, minutes): (i8, i8), text: &str) -> crate::Result<()> {
        let offset: Offset = Offset::parse_rfc3339(text)?;

        assert_eq!(offset, Offset::of(hours, minutes, 0)?);

        Ok(())
    }
}