nmeasis 26.4.1

A memory-safe NMEA 0183 parser with a C FFI
Documentation
use crate::number::NmeaNumber;

#[cfg_attr(feature = "c", repr(C))]
#[derive(Debug, Clone, Copy, Default)]
pub struct NmeaCoordinate {
    pub degrees: u8,
    pub minutes: NmeaNumber,
    pub negative: bool,
}

impl NmeaCoordinate {
    /// Parses an `NmeaCoordinate` from the given raw strings.
    fn parse(raw: &str, dir: &str, max_degrees: u8) -> Option<Self> {
        if raw.is_empty() {
            return None;
        }
        let dot = raw.find('.')?;

        // degrees are all digits before the last 2 digits before the dot
        let deg_end = dot - 2;
        let degrees: u8 = raw[..deg_end].parse().ok()?;
        if degrees > max_degrees {
            return None;
        }
        let minutes = NmeaNumber::parse(&raw[deg_end..])?;
        let negative = matches!(dir, "S" | "W");
        Some(Self {
            degrees,
            minutes,
            negative,
        })
    }
}

#[cfg_attr(feature = "c", repr(C))]
#[derive(Debug, Clone, Copy, Default)]
pub struct NmeaCoordinates {
    pub latitude: NmeaCoordinate,
    pub longitude: NmeaCoordinate,
}

impl NmeaCoordinates {
    #[must_use]
    pub fn parse(lat: &str, lat_dir: &str, lon: &str, lon_dir: &str) -> Option<Self> {
        if lat.is_empty() || lon.is_empty() {
            return None;
        }

        Some(Self {
            latitude: NmeaCoordinate::parse(lat, lat_dir, 90)?,
            longitude: NmeaCoordinate::parse(lon, lon_dir, 180)?,
        })
    }
}

#[cfg(feature = "float")]
impl From<NmeaCoordinate> for f32 {
    fn from(value: NmeaCoordinate) -> f32 {
        let decimal = f32::from(value.degrees) + Into::<f32>::into(value.minutes) / 60.0;
        if value.negative { -decimal } else { decimal }
    }
}

#[cfg(feature = "float")]
impl From<NmeaCoordinate> for f64 {
    fn from(value: NmeaCoordinate) -> f64 {
        let decimal = f64::from(value.degrees) + Into::<f64>::into(value.minutes) / 60.0;
        if value.negative { -decimal } else { decimal }
    }
}

#[cfg(feature = "geo")]
impl From<NmeaCoordinates> for geo_types::Point<f32> {
    fn from(value: NmeaCoordinates) -> Self {
        geo_types::Point::new(f32::from(value.longitude), f32::from(value.latitude))
    }
}

#[cfg(feature = "geo")]
impl From<NmeaCoordinates> for geo_types::Coord<f32> {
    fn from(value: NmeaCoordinates) -> Self {
        geo_types::Coord {
            x: f32::from(value.longitude),
            y: f32::from(value.latitude),
        }
    }
}

#[cfg(feature = "geo")]
impl From<NmeaCoordinates> for geo_types::Point<f64> {
    fn from(value: NmeaCoordinates) -> Self {
        geo_types::Point::new(f64::from(value.longitude), f64::from(value.latitude))
    }
}

#[cfg(feature = "geo")]
impl From<NmeaCoordinates> for geo_types::Coord<f64> {
    fn from(value: NmeaCoordinates) -> Self {
        geo_types::Coord {
            x: f64::from(value.longitude),
            y: f64::from(value.latitude),
        }
    }
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "geo")]
    mod geo_tests {
        use super::super::*;

        fn coords() -> NmeaCoordinates {
            NmeaCoordinates {
                latitude: NmeaCoordinate {
                    degrees: 55,
                    minutes: NmeaNumber {
                        value: 400,
                        scale: 1,
                    }, // 40.0 minutes
                    negative: false,
                },
                longitude: NmeaCoordinate {
                    degrees: 12,
                    minutes: NmeaNumber {
                        value: 300,
                        scale: 1,
                    }, // 30.0 minutes
                    negative: false,
                },
            }
        }

        fn coords_negative() -> NmeaCoordinates {
            NmeaCoordinates {
                latitude: NmeaCoordinate {
                    degrees: 33,
                    minutes: NmeaNumber {
                        value: 450,
                        scale: 1,
                    },
                    negative: true,
                },
                longitude: NmeaCoordinate {
                    degrees: 70,
                    minutes: NmeaNumber {
                        value: 250,
                        scale: 1,
                    },
                    negative: true,
                },
            }
        }

        #[test]
        fn point_from_coords() {
            let p = geo_types::Point::<f64>::from(coords());
            // longitude is x, latitude is y
            assert!(p.x() > 12.0 && p.x() < 13.0);
            assert!(p.y() > 55.0 && p.y() < 56.0);
        }

        #[test]
        fn coord_from_coords() {
            let c = geo_types::Coord::<f64>::from(coords());
            assert!(c.x > 12.0 && c.x < 13.0);
            assert!(c.y > 55.0 && c.y < 56.0);
        }

        #[test]
        fn point_negative_coords() {
            let p = geo_types::Point::<f64>::from(coords_negative());
            assert!(p.x() < 0.0);
            assert!(p.y() < 0.0);
        }

        #[test]
        fn coord_negative_coords() {
            let c = geo_types::Coord::<f64>::from(coords_negative());
            assert!(c.x < 0.0);
            assert!(c.y < 0.0);
        }

        #[test]
        fn point_and_coord_agree() {
            let p = geo_types::Point::<f64>::from(coords());
            let c = geo_types::Coord::<f64>::from(coords());
            assert_eq!(p.x(), c.x);
            assert_eq!(p.y(), c.y);
        }

        #[test]
        fn longitude_is_x_latitude_is_y() {
            // explicitly documents the geo_types convention
            let c = NmeaCoordinates {
                latitude: NmeaCoordinate {
                    degrees: 10,
                    minutes: NmeaNumber { value: 0, scale: 1 },
                    negative: false,
                },
                longitude: NmeaCoordinate {
                    degrees: 20,
                    minutes: NmeaNumber { value: 0, scale: 1 },
                    negative: false,
                },
            };
            let p = geo_types::Point::<f64>::from(c);
            assert_eq!(p.x(), 20.0); // longitude
            assert_eq!(p.y(), 10.0); // latitude
        }

        #[test]
        fn point_from_coords_f32() {
            let p = geo_types::Point::<f32>::from(coords());
            assert!(p.x() > 12.0 && p.x() < 13.0);
            assert!(p.y() > 55.0 && p.y() < 56.0);
        }

        #[test]
        fn coord_from_coords_f32() {
            let c = geo_types::Coord::<f32>::from(coords());
            assert!(c.x > 12.0 && c.x < 13.0);
            assert!(c.y > 55.0 && c.y < 56.0);
        }

        #[test]
        fn point_negative_coords_f32() {
            let p = geo_types::Point::<f32>::from(coords_negative());
            assert!(p.x() < 0.0);
            assert!(p.y() < 0.0);
        }

        #[test]
        fn f32_and_f64_points_agree() {
            let p32 = geo_types::Point::<f32>::from(coords());
            let p64 = geo_types::Point::<f64>::from(coords());
            // f32 has less precision so use an epsilon comparison
            assert!((p32.x() as f64 - p64.x()).abs() < 1e-5);
            assert!((p32.y() as f64 - p64.y()).abs() < 1e-5);
        }
    }
}