nmeasis 26.4.1

A memory-safe NMEA 0183 parser with a C FFI
Documentation
use crate::{
    encoder::NmeaEncode,
    macros::{write_byte, write_str},
    message::NmeaMessageError,
    parser::NmeaParse,
    time::{NmeaDate, NmeaDateTime, NmeaDateTimeOffset, NmeaTime},
};

/// ZDA - Time & Date
///
/// UTC, day, month, year and local time zone
#[derive(Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Zda<'a> {
    /// 1. UTC Time (hhmmss.ss)
    pub time: &'a str,
    /// 2. Day
    pub day: &'a str,
    /// 3. Month
    pub month: &'a str,
    /// 4. Year
    pub year: &'a str,
    /// 5. Local Zone Hours Description
    pub local_zone_hours: &'a str,
    /// 6. Local Zone Minutes Description
    pub local_zone_minutes: &'a str,
}

impl<'a> NmeaParse<'a> for Zda<'a> {
    fn parse(fields: &'a str) -> Result<Self, NmeaMessageError> {
        let mut f = fields.splitn(6, ',');
        Ok(Self {
            time: f.next().ok_or(NmeaMessageError::MissingField)?,
            day: f.next().ok_or(NmeaMessageError::MissingField)?,
            month: f.next().ok_or(NmeaMessageError::MissingField)?,
            year: f.next().ok_or(NmeaMessageError::MissingField)?,
            local_zone_hours: f.next().ok_or(NmeaMessageError::MissingField)?,
            local_zone_minutes: f.next().ok_or(NmeaMessageError::MissingField)?,
        })
    }
}

impl NmeaEncode for Zda<'_> {
    fn encoded_len(&self) -> usize {
        self.time.len()
            + self.day.len()
            + self.month.len()
            + self.year.len()
            + self.local_zone_hours.len()
            + self.local_zone_minutes.len()
            + 5
    }

    fn encode(&self, buf: &mut [u8]) -> usize {
        let mut pos = 0;
        write_str!(buf, pos, self.time);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.day);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.month);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.year);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.local_zone_hours);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.local_zone_minutes);
        pos
    }
}

impl Zda<'_> {
    #[must_use]
    pub fn time(&self) -> Option<NmeaTime> {
        NmeaTime::parse(self.time)
    }

    #[must_use]
    pub fn date(&self) -> Option<NmeaDate> {
        let year = self.year.parse().ok()?;
        let month = self.month.parse().ok()?;
        if !(1..=12).contains(&month) {
            return None;
        }
        let day = self.day.parse().ok()?;
        if !(1..=31).contains(&day) {
            return None;
        }
        Some(NmeaDate { year, month, day })
    }

    #[must_use]
    pub fn date_time(&self) -> Option<NmeaDateTime> {
        Some(NmeaDateTime {
            date: self.date()?,
            time: self.time()?,
        })
    }

    #[must_use]
    pub fn date_time_with_offset(&self) -> Option<NmeaDateTimeOffset> {
        Some(NmeaDateTimeOffset {
            datetime: self.date_time()?,
            offset_hours: self.local_zone_hours.parse().ok()?,
            offset_minutes: self.local_zone_minutes.parse().ok()?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{encoder::NmeaEncode, parser::NmeaParse};

    fn zda(fields: &str) -> Zda<'_> {
        Zda::parse(fields).expect("valid parse")
    }

    #[test]
    fn parse_typical() {
        let s = zda("123519.00,14,03,2004,00,00");
        assert_eq!(s.time, "123519.00");
        assert_eq!(s.day, "14");
        assert_eq!(s.month, "03");
        assert_eq!(s.year, "2004");
        assert_eq!(s.local_zone_hours, "00");
        assert_eq!(s.local_zone_minutes, "00");
    }

    #[test]
    fn parse_with_offset() {
        let s = zda("010203.00,01,01,2000,05,30");
        assert_eq!(s.local_zone_hours, "05");
        assert_eq!(s.local_zone_minutes, "30");
    }

    #[test]
    fn parse_empty_fields() {
        // NMEA allows empty optional fields
        let s = zda(",,,,, ");
        assert_eq!(s.time, "");
        assert_eq!(s.day, "");
        assert_eq!(s.month, "");
        assert_eq!(s.year, "");
        assert_eq!(s.local_zone_hours, "");
        // splitn(6, ',') gives the last field as the remainder (trailing space)
        assert_eq!(s.local_zone_minutes, " ");
    }

    #[test]
    fn parse_missing_fields_returns_error() {
        assert!(Zda::parse("123519.00,14,03,2004,00").is_err());
        assert!(Zda::parse("123519.00").is_err());
        assert!(Zda::parse("").is_err());
    }

    fn roundtrip(input: &str) {
        let s = zda(input);
        let len = s.encoded_len();
        let mut buf = [0u8; 256];
        let written = s.encode(&mut buf);
        assert_eq!(
            written, len,
            "encode() returned {written} but encoded_len() said {len}"
        );
        assert_eq!(&buf[..written], input.as_bytes());
    }

    #[test]
    fn encode_roundtrip_typical() {
        roundtrip("123519.00,14,03,2004,00,00");
    }

    #[test]
    fn encode_roundtrip_with_offset() {
        roundtrip("010203.00,01,01,2000,05,30");
    }

    #[test]
    fn encode_roundtrip_empty_fields() {
        roundtrip(",,,,,");
    }

    #[test]
    fn encoded_len_counts_five_commas() {
        let s = zda(",,,,,");
        // 6 empty fields + 5 separators
        assert_eq!(s.encoded_len(), 5);
    }

    #[test]
    fn time_valid() {
        let t = zda("123519.00,14,03,2004,00,00").time().unwrap();
        assert_eq!(t.hours, 12);
        assert_eq!(t.minutes, 35);
        assert_eq!(t.seconds, 19);
    }

    #[test]
    fn time_invalid_returns_none() {
        assert!(zda("BADTIME,14,03,2004,00,00").time().is_none());
        assert!(zda(",14,03,2004,00,00").time().is_none());
    }

    #[test]
    fn date_valid() {
        let d = zda("123519.00,14,03,2004,00,00").date().unwrap();
        assert_eq!(d.year, 2004);
        assert_eq!(d.month, 3);
        assert_eq!(d.day, 14);
    }

    #[test]
    fn date_month_zero_is_invalid() {
        assert!(zda("120000.00,01,00,2000,00,00").date().is_none());
    }

    #[test]
    fn date_month_thirteen_is_invalid() {
        assert!(zda("120000.00,01,13,2000,00,00").date().is_none());
    }

    #[test]
    fn date_day_zero_is_invalid() {
        assert!(zda("120000.00,00,01,2000,00,00").date().is_none());
    }

    #[test]
    fn date_day_thirty_two_is_invalid() {
        assert!(zda("120000.00,32,01,2000,00,00").date().is_none());
    }

    #[test]
    fn date_boundary_day_31_month_12() {
        let d = zda("235959.00,31,12,1999,00,00").date().unwrap();
        assert_eq!(d.day, 31);
        assert_eq!(d.month, 12);
    }

    #[test]
    fn date_non_numeric_fields_return_none() {
        assert!(zda("120000.00,XX,03,2004,00,00").date().is_none());
        assert!(zda("120000.00,14,YY,2004,00,00").date().is_none());
        assert!(zda("120000.00,14,03,ZZZZ,00,00").date().is_none());
    }

    #[test]
    fn date_empty_fields_return_none() {
        assert!(zda("120000.00,,,2004,00,00").date().is_none());
    }

    #[test]
    fn date_time_valid() {
        let dt = zda("123519.00,14,03,2004,00,00").date_time().unwrap();
        assert_eq!(dt.date.year, 2004);
        assert_eq!(dt.date.month, 3);
        assert_eq!(dt.date.day, 14);
        assert_eq!(dt.time.hours, 12);
        assert_eq!(dt.time.minutes, 35);
        assert_eq!(dt.time.seconds, 19);
    }

    #[test]
    fn date_time_bad_time_returns_none() {
        assert!(zda("BADTIME,14,03,2004,00,00").date_time().is_none());
    }

    #[test]
    fn date_time_bad_date_returns_none() {
        assert!(zda("123519.00,14,13,2004,00,00").date_time().is_none());
    }

    #[test]
    fn offset_valid_positive() {
        let dto = zda("123519.00,14,03,2004,05,30")
            .date_time_with_offset()
            .unwrap();
        assert_eq!(dto.offset_hours, 5);
        assert_eq!(dto.offset_minutes, 30);
    }

    #[test]
    fn offset_valid_negative() {
        let dto = zda("123519.00,14,03,2004,-05,00")
            .date_time_with_offset()
            .unwrap();
        assert_eq!(dto.offset_hours, -5);
        assert_eq!(dto.offset_minutes, 0);
    }

    #[test]
    fn offset_utc_zero() {
        let dto = zda("000000.00,01,01,2000,00,00")
            .date_time_with_offset()
            .unwrap();
        assert_eq!(dto.offset_hours, 0);
        assert_eq!(dto.offset_minutes, 0);
    }

    #[test]
    fn offset_non_numeric_returns_none() {
        assert!(
            zda("123519.00,14,03,2004,XX,00")
                .date_time_with_offset()
                .is_none()
        );
        assert!(
            zda("123519.00,14,03,2004,05,YY")
                .date_time_with_offset()
                .is_none()
        );
    }

    #[test]
    fn offset_bad_datetime_returns_none() {
        // Bad month means date() fails, which propagates all the way up
        assert!(
            zda("123519.00,14,13,2004,05,30")
                .date_time_with_offset()
                .is_none()
        );
    }
}