Skip to main content

use_geo/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn validate_label(value: impl AsRef<str>, field: &'static str) -> Result<String, GeoValueError> {
8    let trimmed = value.as_ref().trim();
9    if trimmed.is_empty() {
10        Err(GeoValueError::Empty { field })
11    } else {
12        Ok(trimmed.to_string())
13    }
14}
15
16/// Error returned by geographic presence constructors.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum GeoValueError {
19    /// A text field was empty after trimming whitespace.
20    Empty { field: &'static str },
21    /// Latitude was outside the inclusive `-90..=90` range or not finite.
22    InvalidLatitude(f64),
23    /// Longitude was outside the inclusive `-180..=180` range or not finite.
24    InvalidLongitude(f64),
25    /// Radius was not positive and finite.
26    InvalidRadius(f64),
27    /// Region code shape was unsupported.
28    InvalidRegionCode,
29}
30
31impl fmt::Display for GeoValueError {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
35            Self::InvalidLatitude(value) => write!(formatter, "invalid latitude {value}"),
36            Self::InvalidLongitude(value) => write!(formatter, "invalid longitude {value}"),
37            Self::InvalidRadius(value) => write!(formatter, "invalid radius {value}"),
38            Self::InvalidRegionCode => {
39                formatter.write_str("region code must be 2 to 16 ASCII letters, digits, or hyphens")
40            },
41        }
42    }
43}
44
45impl Error for GeoValueError {}
46
47/// A latitude/longitude point used as a presence targeting label.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct GeoPoint {
50    latitude: f64,
51    longitude: f64,
52}
53
54impl GeoPoint {
55    /// Creates a geographic point from latitude and longitude values.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`GeoValueError`] when either coordinate is outside the supported range.
60    pub fn new(latitude: f64, longitude: f64) -> Result<Self, GeoValueError> {
61        if !latitude.is_finite() || !(-90.0..=90.0).contains(&latitude) {
62            return Err(GeoValueError::InvalidLatitude(latitude));
63        }
64        if !longitude.is_finite() || !(-180.0..=180.0).contains(&longitude) {
65            return Err(GeoValueError::InvalidLongitude(longitude));
66        }
67
68        Ok(Self {
69            latitude,
70            longitude,
71        })
72    }
73
74    /// Returns latitude in decimal degrees.
75    #[must_use]
76    pub const fn latitude(self) -> f64 {
77        self.latitude
78    }
79
80    /// Returns longitude in decimal degrees.
81    #[must_use]
82    pub const fn longitude(self) -> f64 {
83        self.longitude
84    }
85}
86
87/// A compact region code used for discovery or targeting labels.
88#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
89pub struct GeoRegionCode(String);
90
91impl GeoRegionCode {
92    /// Creates a normalized region code.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`GeoValueError::InvalidRegionCode`] when the shape is unsupported.
97    pub fn new(value: impl AsRef<str>) -> Result<Self, GeoValueError> {
98        let normalized = value.as_ref().trim().to_ascii_uppercase();
99        let valid = (2..=16).contains(&normalized.len())
100            && normalized
101                .bytes()
102                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
103
104        if valid {
105            Ok(Self(normalized))
106        } else {
107            Err(GeoValueError::InvalidRegionCode)
108        }
109    }
110
111    /// Returns the normalized region code.
112    #[must_use]
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116}
117
118impl AsRef<str> for GeoRegionCode {
119    fn as_ref(&self) -> &str {
120        self.as_str()
121    }
122}
123
124impl fmt::Display for GeoRegionCode {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(self.as_str())
127    }
128}
129
130impl FromStr for GeoRegionCode {
131    type Err = GeoValueError;
132
133    fn from_str(value: &str) -> Result<Self, Self::Err> {
134        Self::new(value)
135    }
136}
137
138/// A positive radius around a location, stored in meters.
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct LocationRadius {
141    meters: f64,
142}
143
144impl LocationRadius {
145    /// Creates a radius from meters.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`GeoValueError::InvalidRadius`] when the value is not positive and finite.
150    pub fn from_meters(meters: f64) -> Result<Self, GeoValueError> {
151        if meters.is_finite() && meters > 0.0 {
152            Ok(Self { meters })
153        } else {
154            Err(GeoValueError::InvalidRadius(meters))
155        }
156    }
157
158    /// Creates a radius from kilometers.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`GeoValueError::InvalidRadius`] when the value is not positive and finite.
163    pub fn from_kilometers(kilometers: f64) -> Result<Self, GeoValueError> {
164        Self::from_meters(kilometers * 1_000.0)
165    }
166
167    /// Returns the radius in meters.
168    #[must_use]
169    pub const fn meters(self) -> f64 {
170        self.meters
171    }
172}
173
174/// A named service area used for local discovery and targeting.
175#[derive(Clone, Debug, PartialEq)]
176pub struct ServiceArea {
177    label: String,
178    regions: Vec<GeoRegionCode>,
179    radius: Option<LocationRadius>,
180}
181
182impl ServiceArea {
183    /// Creates a service-area label.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`GeoValueError::Empty`] when the label is empty.
188    pub fn new(label: impl AsRef<str>) -> Result<Self, GeoValueError> {
189        Ok(Self {
190            label: validate_label(label, "service area label")?,
191            regions: Vec::new(),
192            radius: None,
193        })
194    }
195
196    /// Adds a region code to the area.
197    #[must_use]
198    pub fn with_region(mut self, region: GeoRegionCode) -> Self {
199        self.regions.push(region);
200        self
201    }
202
203    /// Sets the area radius.
204    #[must_use]
205    pub const fn with_radius(mut self, radius: LocationRadius) -> Self {
206        self.radius = Some(radius);
207        self
208    }
209
210    /// Returns the area label.
211    #[must_use]
212    pub fn label(&self) -> &str {
213        &self.label
214    }
215
216    /// Returns region codes assigned to the area.
217    #[must_use]
218    pub fn regions(&self) -> &[GeoRegionCode] {
219        &self.regions
220    }
221
222    /// Returns the optional radius.
223    #[must_use]
224    pub const fn radius(&self) -> Option<LocationRadius> {
225        self.radius
226    }
227}
228
229/// Geographic targeting shape for presence and discovery surfaces.
230#[derive(Clone, Debug, PartialEq)]
231pub enum GeoTarget {
232    /// A point target.
233    Point(GeoPoint),
234    /// A region-code target.
235    Region(GeoRegionCode),
236    /// A named service area target.
237    ServiceArea(ServiceArea),
238    /// A radius around a point.
239    Radius {
240        center: GeoPoint,
241        radius: LocationRadius,
242    },
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{GeoPoint, GeoRegionCode, GeoTarget, GeoValueError, LocationRadius, ServiceArea};
248
249    #[test]
250    fn validates_geo_points() {
251        let point = GeoPoint::new(45.0, -122.0).unwrap();
252
253        assert!((point.latitude() - 45.0).abs() < f64::EPSILON);
254        assert_eq!(
255            GeoPoint::new(100.0, 0.0),
256            Err(GeoValueError::InvalidLatitude(100.0))
257        );
258    }
259
260    #[test]
261    fn normalizes_region_codes() {
262        let region = GeoRegionCode::new("us-or").unwrap();
263
264        assert_eq!(region.as_str(), "US-OR");
265        assert!(GeoRegionCode::new("!").is_err());
266    }
267
268    #[test]
269    fn composes_service_area_targets() {
270        let area = ServiceArea::new("Portland metro")
271            .unwrap()
272            .with_region(GeoRegionCode::new("us-or").unwrap())
273            .with_radius(LocationRadius::from_kilometers(30.0).unwrap());
274        let target = GeoTarget::ServiceArea(area.clone());
275
276        assert_eq!(area.regions().len(), 1);
277        assert!(matches!(target, GeoTarget::ServiceArea(_)));
278    }
279}