shvproto 6.1.1

Rust implementation of the SHV protocol
Documentation
#![allow(clippy::string_slice, reason = "strings are not utf8")]

use std::cmp::Ordering;
use std::fmt;
use chrono::{FixedOffset, NaiveDateTime, Offset};

/// msec: 57, tz: 7;
/// tz is stored as signed count of quarters of hour (15 min)
/// I'm storing whole DateTime in one i64 to keep size_of RpcValue == 24
const TZ_MASK: i64 = 127;
pub enum IncludeMilliseconds {
    Never,
    Always,
    WhenNonZero,
}
pub struct ToISOStringOptions {
    pub(crate) include_millis: IncludeMilliseconds,
    pub(crate) include_timezone: bool,
}
impl Default for ToISOStringOptions {
    fn default() -> Self {
        ToISOStringOptions {
            include_millis: IncludeMilliseconds::Always,
            include_timezone: true
        }
    }
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct DateTime(i64);
impl DateTime {
    //pub fn invalid() -> DateTime {
    //    DateTime::from_epoch_msec(0)
    //}
    //pub fn is_valid(&self) -> bool { }

#[cfg(feature = "serde")]
    pub(crate) fn from_inner(inner: i64) -> Self {
        Self(inner)
    }

#[cfg(feature = "serde")]
    pub(crate) fn to_inner(self) -> i64 {
        self.0
    }

    pub fn now() -> DateTime {
        let dt = chrono::offset::Local::now();
        let msec = dt.naive_utc().and_utc().timestamp_millis();
        let offset = dt.offset().local_minus_utc() / 60 / 15;
        DateTime::from_epoch_msec_tz(msec, offset)
    }

    pub fn from_datetime<Tz: chrono::TimeZone>(dt: &chrono::DateTime<Tz>) -> DateTime {
        let msec = dt.naive_utc().and_utc().timestamp_millis();
        let offset = dt.offset().fix().local_minus_utc();
        DateTime::from_epoch_msec_tz(msec, offset)
    }
    pub fn from_naive_datetime(dt: &chrono::NaiveDateTime) -> DateTime {
        let msec = dt.and_utc().timestamp_millis();
        DateTime::from_epoch_msec(msec)
    }
    pub fn from_epoch_msec_tz(epoch_msec: i64, utc_offset_sec: i32) -> DateTime {
        let mut msec = epoch_msec;
        // offset in quarters of hour
        msec *= TZ_MASK + 1;
        let offset = i64::from(utc_offset_sec / 60 / 15);
        msec |= offset & TZ_MASK;
        DateTime(msec)
    }
    pub fn from_epoch_msec(epoch_msec: i64) -> DateTime {
        Self::from_epoch_msec_tz(epoch_msec, 0)
    }
    pub fn from_iso_str(iso_str: &str) -> Result<DateTime, String> {
            const PATTERN: &str = "2020-02-03T11:59:43";
            if iso_str.len() >= PATTERN.len() {
                let s = iso_str;
                let naive_str = &s[..PATTERN.len()];
                if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(naive_str, "%Y-%m-%dT%H:%M:%S") {
                    let mut msec = 0;
                    let mut offset = 0;
                    let mut rest = &s[PATTERN.len()..];
                    if matches!(rest.as_bytes().first(), Some(b'.')) {
                        rest = &rest[1..];
                        if rest.len() >= 3 {
                            match rest[..3].parse::<i32>() {
                                Ok(ms) => {
                                    msec = ms;
                                    rest = &rest[3..];
                                }
                                Err(err) => {
                                    return Err(format!("Parsing DateTime msec part error: {err}, in '{iso_str}"))
                                }
                            }
                        }
                    }
                    if !rest.is_empty() {
                        if rest.len() == 1 && *rest.as_bytes().first().expect("len() is 1") == b'Z' {
                        } else if rest.len() == 3 {
                            if let Ok(hrs) = rest.parse::<i32>() {
                                offset = 60 * 60 * hrs;
                            } else {
                                return Err(format!("Invalid DateTime TZ(3) part: '{rest}, date time: {iso_str}"))
                            }
                        } else if rest.len() == 5 {
                            if let Ok(hrs) = rest.parse::<i32>() {
                                offset = 60 * (60 * (hrs / 100) + (hrs % 100));
                            } else {
                                return Err(format!("Invalid DateTime TZ(5) part: '{rest}, date time: {iso_str}"))
                            }
                        } else {
                            return Err(format!("Invalid DateTime TZ part: '{rest}, date time: {iso_str}"))
                        }
                    }
                    let epoch_msec = (ndt.and_utc().timestamp() - i64::from(offset)) * 1000 + i64::from(msec);
                    let dt = DateTime::from_epoch_msec_tz(epoch_msec, offset);
                    return Ok(dt)
                }
            }
            Err(format!("Invalid DateTime: '{iso_str:?}"))
    }
    pub fn epoc_msec_utc_offset(self) -> (i64, i32) {
        let msec= self.0 / (TZ_MASK + 1);
        let mut offset = self.0 & TZ_MASK;
        if (offset & ((TZ_MASK + 1) / 2)) != 0 {
            // sign extension
            offset |= !TZ_MASK;
        }
        #[expect(clippy::cast_possible_truncation, reason = "We hope that the offset is small enough to fit")]
        let offset = (offset * 15 * 60) as i32;
        (msec, offset)
    }
    pub fn epoch_msec(self) -> i64 { self.epoc_msec_utc_offset().0 }
    pub fn utc_offset(self) -> i32 { self.epoc_msec_utc_offset().1 }

    pub fn to_chrono_naivedatetime(self) -> chrono::NaiveDateTime {
        let msec = self.epoch_msec();
        chrono::DateTime::from_timestamp_millis(msec).unwrap_or_default().naive_utc()
    }
    pub fn to_chrono_datetime(self) -> chrono::DateTime<chrono::offset::FixedOffset> {
        let offset = FixedOffset::east_opt(self.utc_offset())
            .unwrap_or_else(|| FixedOffset::east_opt(0).expect("Zero is within the range"));
        chrono::DateTime::from_naive_utc_and_offset(self.to_chrono_naivedatetime(), offset)
    }
    pub fn to_iso_string(self) -> String {
        self.to_iso_string_opt(&ToISOStringOptions::default())
    }
    pub fn to_iso_string_opt(self, opts: &ToISOStringOptions) -> String {
        let dt = self.to_chrono_datetime();
        let mut s = format!("{}", dt.format("%Y-%m-%dT%H:%M:%S"));
        let ms = self.epoch_msec() % 1000;
        match opts.include_millis {
            IncludeMilliseconds::Never => {}
            IncludeMilliseconds::Always => { s.push_str(&format!(".{ms:03}")); }
            IncludeMilliseconds::WhenNonZero => {
                if ms > 0 {
                    s.push_str(&format!(".{ms:03}"));
                }
            }
        }
        if opts.include_timezone {
            let mut offset = self.utc_offset();
            if offset == 0 {
                s.push('Z');
            }
            else {
                if offset < 0 {
                    s.push('-');
                    offset = -offset;
                } else {
                    s.push('+');
                }
                let offset_hr = offset / 60 / 60;
                let offset_min = offset / 60 % 60;
                s += &format!("{offset_hr:02}");
                if offset_min > 0 {
                    s += &format!("{offset_min:02}");
                }
            }
        }
        s
    }

    #[must_use]
    pub fn add_days(self, days: i64) -> Self {
        let (msec, offset) = self.epoc_msec_utc_offset();
        Self::from_epoch_msec_tz(msec + (days * 24 * 60 * 60 * 1000), offset)
    }
    #[must_use]
    pub fn add_hours(self, hours: i64) -> Self {
        let (msec, offset) = self.epoc_msec_utc_offset();
        Self::from_epoch_msec_tz(msec + (hours * 60 * 60 * 1000), offset)
    }
    #[must_use]
    pub fn add_minutes(self, minutes: i64) -> Self {
        let (msec, offset) = self.epoc_msec_utc_offset();
        Self::from_epoch_msec_tz(msec + (minutes * 60 * 1000), offset)
    }
    #[must_use]
    pub fn add_seconds(self, seconds: i64) -> Self {
        let (msec, offset) = self.epoc_msec_utc_offset();
        Self::from_epoch_msec_tz(msec + (seconds * 1000), offset)
    }
    #[must_use]
    pub fn add_millis(self, millis: i64) -> Self {
        let (msec, offset) = self.epoc_msec_utc_offset();
        Self::from_epoch_msec_tz(msec + millis, offset)
    }
}

impl PartialOrd for DateTime {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Eq for DateTime {}

impl Ord for DateTime {
    fn cmp(&self, other: &Self) -> Ordering {
        let e1 = self.epoch_msec();
        let e2 = other.epoch_msec();
        e1.cmp(&e2)
    }
}

impl fmt::Display for DateTime {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.to_iso_string())
    }
}

impl From<NaiveDateTime> for DateTime {
    fn from(ndt: NaiveDateTime) -> Self {
        DateTime::from_naive_datetime(&ndt)
    }
}