#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
fn validate_label(value: impl AsRef<str>, field: &'static str) -> Result<String, GeoValueError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(GeoValueError::Empty { field })
} else {
Ok(trimmed.to_string())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GeoValueError {
Empty { field: &'static str },
InvalidLatitude(f64),
InvalidLongitude(f64),
InvalidRadius(f64),
InvalidRegionCode,
}
impl fmt::Display for GeoValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
Self::InvalidLatitude(value) => write!(formatter, "invalid latitude {value}"),
Self::InvalidLongitude(value) => write!(formatter, "invalid longitude {value}"),
Self::InvalidRadius(value) => write!(formatter, "invalid radius {value}"),
Self::InvalidRegionCode => {
formatter.write_str("region code must be 2 to 16 ASCII letters, digits, or hyphens")
},
}
}
}
impl Error for GeoValueError {}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GeoPoint {
latitude: f64,
longitude: f64,
}
impl GeoPoint {
pub fn new(latitude: f64, longitude: f64) -> Result<Self, GeoValueError> {
if !latitude.is_finite() || !(-90.0..=90.0).contains(&latitude) {
return Err(GeoValueError::InvalidLatitude(latitude));
}
if !longitude.is_finite() || !(-180.0..=180.0).contains(&longitude) {
return Err(GeoValueError::InvalidLongitude(longitude));
}
Ok(Self {
latitude,
longitude,
})
}
#[must_use]
pub const fn latitude(self) -> f64 {
self.latitude
}
#[must_use]
pub const fn longitude(self) -> f64 {
self.longitude
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GeoRegionCode(String);
impl GeoRegionCode {
pub fn new(value: impl AsRef<str>) -> Result<Self, GeoValueError> {
let normalized = value.as_ref().trim().to_ascii_uppercase();
let valid = (2..=16).contains(&normalized.len())
&& normalized
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if valid {
Ok(Self(normalized))
} else {
Err(GeoValueError::InvalidRegionCode)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for GeoRegionCode {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GeoRegionCode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GeoRegionCode {
type Err = GeoValueError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LocationRadius {
meters: f64,
}
impl LocationRadius {
pub fn from_meters(meters: f64) -> Result<Self, GeoValueError> {
if meters.is_finite() && meters > 0.0 {
Ok(Self { meters })
} else {
Err(GeoValueError::InvalidRadius(meters))
}
}
pub fn from_kilometers(kilometers: f64) -> Result<Self, GeoValueError> {
Self::from_meters(kilometers * 1_000.0)
}
#[must_use]
pub const fn meters(self) -> f64 {
self.meters
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ServiceArea {
label: String,
regions: Vec<GeoRegionCode>,
radius: Option<LocationRadius>,
}
impl ServiceArea {
pub fn new(label: impl AsRef<str>) -> Result<Self, GeoValueError> {
Ok(Self {
label: validate_label(label, "service area label")?,
regions: Vec::new(),
radius: None,
})
}
#[must_use]
pub fn with_region(mut self, region: GeoRegionCode) -> Self {
self.regions.push(region);
self
}
#[must_use]
pub const fn with_radius(mut self, radius: LocationRadius) -> Self {
self.radius = Some(radius);
self
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn regions(&self) -> &[GeoRegionCode] {
&self.regions
}
#[must_use]
pub const fn radius(&self) -> Option<LocationRadius> {
self.radius
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum GeoTarget {
Point(GeoPoint),
Region(GeoRegionCode),
ServiceArea(ServiceArea),
Radius {
center: GeoPoint,
radius: LocationRadius,
},
}
#[cfg(test)]
mod tests {
use super::{GeoPoint, GeoRegionCode, GeoTarget, GeoValueError, LocationRadius, ServiceArea};
#[test]
fn validates_geo_points() {
let point = GeoPoint::new(45.0, -122.0).unwrap();
assert!((point.latitude() - 45.0).abs() < f64::EPSILON);
assert_eq!(
GeoPoint::new(100.0, 0.0),
Err(GeoValueError::InvalidLatitude(100.0))
);
}
#[test]
fn normalizes_region_codes() {
let region = GeoRegionCode::new("us-or").unwrap();
assert_eq!(region.as_str(), "US-OR");
assert!(GeoRegionCode::new("!").is_err());
}
#[test]
fn composes_service_area_targets() {
let area = ServiceArea::new("Portland metro")
.unwrap()
.with_region(GeoRegionCode::new("us-or").unwrap())
.with_radius(LocationRadius::from_kilometers(30.0).unwrap());
let target = GeoTarget::ServiceArea(area.clone());
assert_eq!(area.regions().len(), 1);
assert!(matches!(target, GeoTarget::ServiceArea(_)));
}
}