commit 2029eadb3c567fc21749adca1a8d2e164f909aa4 Author: Paul van Tilburg Date: Tue Sep 27 17:10:45 2022 +0200 Initial import into Git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eeda2c3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "geo-uri" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +derive_builder = "0.11.2" +thiserror = "1.0.35" + +[dev-dependencies] +float_eq = "1.0.0" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..aa08940 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,313 @@ +//! TODO: Write and include `README.md`! + +use std::fmt; +use std::num::{ParseFloatError, ParseIntError}; +use std::str::FromStr; + +use derive_builder::Builder; +use thiserror::Error; + +/// The scheme name of a geo URI. +const URI_SCHEME_NAME: &str = "geo"; + +/// The reference system of the provided coordinates. +/// +/// Currently only the `WGS-84` coordinate reference system is supported. +/// It defines the latitude and longitude of the [`GeoUri`] to be in decimal degrees and the +/// altitude in meters. +/// +/// For more details see the +/// [component description](ttps://www.rfc-editor.org/rfc/rfc5870#section-3.4.2) in +/// [RFC 5870](https://www.rfc-editor.org/rfc/rfc5870). +#[non_exhaustive] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CrsId { + /// The WGS-84 coordinate reference system. + Wgs84, +} + +impl Default for CrsId { + fn default() -> Self { + Self::Wgs84 + } +} + +/// Possible geo URI parse errors. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ParseError { + /// The geo URI is missing a proper scheme, i.e. the prefix `geo:`. + #[error("Missing geo URI scheme")] + MissingScheme, + /// The geo URI contains no coordinates. + #[error("Missing coordinates in geo URI")] + MissingCoords, + /// The geo URI lacks the latitude coordinate. + #[error("Missing latitude coordinate in geo URI")] + MissingLatitudeCoord, + /// The geo URI lacks the longitude coordinate. + #[error("Missing longitude coordinate in geo URI")] + MissingLongitudeCoord, + /// The geo URI contains an unparsable/invalid coordinate. + #[error("Invalid coordinate in geo URI: {0}")] + InvalidCoord(ParseFloatError), + /// The geo URI contains an unsupported/invalid coordinate reference system. + #[error("Invalid coordinate reference system")] + InvalidCoordRefSystem, + /// The geo URI contains an unparsable/invalid (uncertainty) distance. + #[error("Invalid distance in geo URI: {0}")] + InvalidDistance(ParseIntError), +} + +/// A uniform resource identifier for geographic locations (geo URI). +/// +/// TODO: Add examples! +/// +/// # See also +/// +/// For the proposed IEEE standard, see [RFC 5870](https://www.rfc-editor.org/rfc/rfc5870). +#[derive(Builder, Copy, Clone, Debug, Default)] +pub struct GeoUri { + /// The coordinate reference system used by the coordinates of this URI. + pub crs_id: CrsId, + /// The latitude coordinate of a location. + pub latitude: f64, + /// The longitude coordinate of a location. + pub longitude: f64, + /// The altitude coordinate of a location, if provided. + #[builder(setter(strip_option))] + pub altitude: Option, + #[builder(setter(strip_option))] + /// The uncertainty around the location as a radius (distance) in meters. + pub uncertainty: Option, +} + +impl GeoUri { + /// Return a builder to build a `GeoUri`. + pub fn builder() -> GeoUriBuilder { + GeoUriBuilder::default() + } + + /// Try parsing a string into a `GeoUri`. + /// + /// For the geo URI scheme syntax, see the propsed IEEE standard + /// [RFC 5870](https://www.rfc-editor.org/rfc/rfc5870#section-3.3). + pub fn parse(uri: &str) -> Result { + let uri_path = uri.strip_prefix("geo:").ok_or(ParseError::MissingScheme)?; + let mut parts = uri_path.split(';'); + + // Parse the coordinate part. + let coords_part = parts.next().expect("Split always yields at least one part"); + // Don't iterate over anything if the coordinate part is empty! + let mut coords = if coords_part.is_empty() { + return Err(ParseError::MissingCoords); + } else { + coords_part.splitn(3, ',') + }; + let latitude = coords + .next() + .ok_or(ParseError::MissingLatitudeCoord) // This cannot really happen + .and_then(|lat_s| lat_s.parse().map_err(ParseError::InvalidCoord))?; + + let longitude = coords + .next() + .ok_or(ParseError::MissingLongitudeCoord) + .and_then(|lon_s| lon_s.parse().map_err(ParseError::InvalidCoord))?; + + let altitude = coords + .next() + .map(|alt_s| alt_s.parse().map_err(ParseError::InvalidCoord)) + .transpose()?; + + // Parse the remaining (parameters) parts. + // + // TODO: Handle possible casing issues in the pnames. + // TODO: Handle percent encoding of the parameters. + // + // If the "crs" parameter is passed, its value must be "wgs84" or it is unsupported. + // It can be followed by a "u" parameter or that can be the first one. + // All other parameters are ignored. + let mut param_parts = parts.flat_map(|part| part.split_once('=')); + let (crs_id, uncertainty) = match param_parts.next() { + Some(("crs", value)) => { + if value.to_ascii_lowercase() != "wgs84" { + return Err(ParseError::InvalidCoordRefSystem); + } + + match param_parts.next() { + Some(("u", value)) => ( + CrsId::Wgs84, + Some(value.parse().map_err(ParseError::InvalidDistance)?), + ), + Some(_) | None => (CrsId::Wgs84, None), + } + } + Some(("u", value)) => ( + CrsId::default(), + Some(value.parse().map_err(ParseError::InvalidDistance)?), + ), + Some(_) | None => (CrsId::default(), None), + }; + + Ok(GeoUri { + crs_id, + latitude, + longitude, + altitude, + uncertainty, + }) + } +} + +impl fmt::Display for GeoUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + latitude, + longitude, + .. + } = self; + write!(f, "{URI_SCHEME_NAME}:{latitude},{longitude}")?; + + if let Some(altitude) = self.altitude { + write!(f, "{altitude}")?; + } + + // Don't write the CRS since there is only one supported at the moment. + if let Some(uncertainty) = self.uncertainty { + write!(f, ";u={uncertainty}")?; + } + + Ok(()) + } +} + +impl FromStr for GeoUri { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom<&str> for GeoUri { + type Error = ParseError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl PartialEq for GeoUri { + fn eq(&self, other: &Self) -> bool { + // In the WGS-84 CRS the the longitude is ignored for the poles. + let ignore_longitude = self.crs_id == CrsId::Wgs84 && self.latitude.abs() == 90.0; + + self.crs_id == other.crs_id + && self.latitude == other.latitude + && (ignore_longitude || self.longitude == other.longitude) + && self.altitude == other.altitude + && self.uncertainty == other.uncertainty + } +} + +#[cfg(test)] +mod tests { + use float_eq::assert_float_eq; + + use super::*; + + #[test] + fn parse() -> Result<(), ParseError> { + let geo_uri = GeoUri::parse("geo:52.107,5.134")?; + assert_float_eq!(geo_uri.latitude, 52.107, abs <= 0.001); + assert_float_eq!(geo_uri.longitude, 5.134, abs <= 0.001); + assert_eq!(geo_uri.uncertainty, None); + + let geo_uri = GeoUri::parse("52.107,5.134"); + assert!(matches!(geo_uri, Err(ParseError::MissingScheme))); + + let geo_uri = GeoUri::parse("geo:geo:52.107,5.134"); + assert!(matches!(geo_uri, Err(ParseError::InvalidCoord(_)))); + + let geo_uri = GeoUri::parse("geo:"); + assert!(matches!(geo_uri, Err(ParseError::MissingCoords))); + + let geo_uri = GeoUri::parse("geo:;u=5000"); + assert!(matches!(geo_uri, Err(ParseError::MissingCoords))); + + let geo_uri = GeoUri::parse("geo:52.107;u=1000"); + assert!(matches!(geo_uri, Err(ParseError::MissingLongitudeCoord))); + + let geo_uri = GeoUri::parse("geo:52.107,;u=1000"); + assert!(matches!(geo_uri, Err(ParseError::InvalidCoord(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,,6.00;u=1000"); + assert!(matches!(geo_uri, Err(ParseError::InvalidCoord(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,5.134,;u=1000"); + assert!(matches!(geo_uri, Err(ParseError::InvalidCoord(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,5.134,3.6")?; + assert_float_eq!(geo_uri.latitude, 52.107, abs <= 0.001); + assert_float_eq!(geo_uri.longitude, 5.134, abs <= 0.001); + assert_float_eq!(geo_uri.altitude.unwrap(), 3.6, abs <= 0.001); + assert_eq!(geo_uri.uncertainty, None); + + let geo_uri = GeoUri::parse("geo:52.107,5.34,6.00;u="); + assert!(matches!(geo_uri, Err(ParseError::InvalidDistance(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,5.34,6.00;u=foo"); + assert!(matches!(geo_uri, Err(ParseError::InvalidDistance(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,5.34,6.00;crs=wgs84;u=foo"); + assert!(matches!(geo_uri, Err(ParseError::InvalidDistance(_)))); + + let geo_uri = GeoUri::parse("geo:52.107,5.134,0.5;u=25000")?; + assert_float_eq!(geo_uri.latitude, 52.107, abs <= 0.001); + assert_float_eq!(geo_uri.longitude, 5.134, abs <= 0.001); + assert_eq!(geo_uri.uncertainty, Some(25_000)); + + let geo_uri = GeoUri::parse("geo:52.107,5.134,0.5;crs=wgs84;u=25000")?; + assert_float_eq!(geo_uri.latitude, 52.107, abs <= 0.001); + assert_float_eq!(geo_uri.longitude, 5.134, abs <= 0.001); + assert_eq!(geo_uri.uncertainty, Some(25_000)); + + let geo_uri = GeoUri::parse("geo:52.107,5.134,0.5;crs=wgs84;u=25000;foo=bar")?; + assert_float_eq!(geo_uri.latitude, 52.107, abs <= 0.001); + assert_float_eq!(geo_uri.longitude, 5.134, abs <= 0.001); + assert_eq!(geo_uri.uncertainty, Some(25_000)); + + let geo_uri = GeoUri::parse("geo:52.107,5.34,6.00;crs=foo"); + assert!(matches!(geo_uri, Err(ParseError::InvalidCoordRefSystem))); + + let geo_uri = GeoUri::parse("geo:52.107,5.34,6.00;crs=wgs84")?; + assert!(matches!(geo_uri.crs_id, CrsId::Wgs84)); + + // TODO: Add exmaples from RFC 5870! + + Ok(()) + } + + #[ignore] + #[test] + fn display() { + todo!("Implement test"); + } + + #[ignore] + #[test] + fn from_str() { + todo!("Implement test"); + } + + #[ignore] + #[test] + fn try_from() { + todo!("Implement test"); + } + + #[ignore] + #[test] + fn partial_eq() { + todo!("Implement test"); + } +}