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#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum GeoValueError {
19 Empty { field: &'static str },
21 InvalidLatitude(f64),
23 InvalidLongitude(f64),
25 InvalidRadius(f64),
27 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#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct GeoPoint {
50 latitude: f64,
51 longitude: f64,
52}
53
54impl GeoPoint {
55 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 #[must_use]
76 pub const fn latitude(self) -> f64 {
77 self.latitude
78 }
79
80 #[must_use]
82 pub const fn longitude(self) -> f64 {
83 self.longitude
84 }
85}
86
87#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
89pub struct GeoRegionCode(String);
90
91impl GeoRegionCode {
92 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 #[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#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct LocationRadius {
141 meters: f64,
142}
143
144impl LocationRadius {
145 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 pub fn from_kilometers(kilometers: f64) -> Result<Self, GeoValueError> {
164 Self::from_meters(kilometers * 1_000.0)
165 }
166
167 #[must_use]
169 pub const fn meters(self) -> f64 {
170 self.meters
171 }
172}
173
174#[derive(Clone, Debug, PartialEq)]
176pub struct ServiceArea {
177 label: String,
178 regions: Vec<GeoRegionCode>,
179 radius: Option<LocationRadius>,
180}
181
182impl ServiceArea {
183 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 #[must_use]
198 pub fn with_region(mut self, region: GeoRegionCode) -> Self {
199 self.regions.push(region);
200 self
201 }
202
203 #[must_use]
205 pub const fn with_radius(mut self, radius: LocationRadius) -> Self {
206 self.radius = Some(radius);
207 self
208 }
209
210 #[must_use]
212 pub fn label(&self) -> &str {
213 &self.label
214 }
215
216 #[must_use]
218 pub fn regions(&self) -> &[GeoRegionCode] {
219 &self.regions
220 }
221
222 #[must_use]
224 pub const fn radius(&self) -> Option<LocationRadius> {
225 self.radius
226 }
227}
228
229#[derive(Clone, Debug, PartialEq)]
231pub enum GeoTarget {
232 Point(GeoPoint),
234 Region(GeoRegionCode),
236 ServiceArea(ServiceArea),
238 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}