1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{error::Error, str::FromStr};
6
7fn normalized_token(value: &str) -> String {
8 value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
9}
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum GeoCoordinateError {
13 LatitudeNotFinite,
14 LatitudeOutOfRange,
15 LongitudeNotFinite,
16 LongitudeOutOfRange,
17}
18
19impl fmt::Display for GeoCoordinateError {
20 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Self::LatitudeNotFinite => formatter.write_str("latitude must be finite"),
23 Self::LatitudeOutOfRange => {
24 formatter.write_str("latitude must be within -90.0..=90.0 degrees")
25 },
26 Self::LongitudeNotFinite => formatter.write_str("longitude must be finite"),
27 Self::LongitudeOutOfRange => {
28 formatter.write_str("longitude must be within -180.0..=180.0 degrees")
29 },
30 }
31 }
32}
33
34impl Error for GeoCoordinateError {}
35
36#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
37pub struct Latitude(f64);
38
39impl Latitude {
40 pub fn new(value: f64) -> Result<Self, GeoCoordinateError> {
47 if !value.is_finite() {
48 return Err(GeoCoordinateError::LatitudeNotFinite);
49 }
50
51 if !(-90.0..=90.0).contains(&value) {
52 return Err(GeoCoordinateError::LatitudeOutOfRange);
53 }
54
55 Ok(Self(value))
56 }
57
58 #[must_use]
59 pub const fn degrees(self) -> f64 {
60 self.0
61 }
62}
63
64impl fmt::Display for Latitude {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 write!(formatter, "{} deg", self.degrees())
67 }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
71pub struct Longitude(f64);
72
73impl Longitude {
74 pub fn new(value: f64) -> Result<Self, GeoCoordinateError> {
81 if !value.is_finite() {
82 return Err(GeoCoordinateError::LongitudeNotFinite);
83 }
84
85 if !(-180.0..=180.0).contains(&value) {
86 return Err(GeoCoordinateError::LongitudeOutOfRange);
87 }
88
89 Ok(Self(value))
90 }
91
92 #[must_use]
93 pub const fn degrees(self) -> f64 {
94 self.0
95 }
96}
97
98impl fmt::Display for Longitude {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 write!(formatter, "{} deg", self.degrees())
101 }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq)]
105pub struct GeoCoordinate {
106 latitude: Latitude,
107 longitude: Longitude,
108}
109
110impl GeoCoordinate {
111 #[must_use]
112 pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
113 Self {
114 latitude,
115 longitude,
116 }
117 }
118
119 #[must_use]
120 pub const fn latitude(self) -> Latitude {
121 self.latitude
122 }
123
124 #[must_use]
125 pub const fn longitude(self) -> Longitude {
126 self.longitude
127 }
128}
129
130impl fmt::Display for GeoCoordinate {
131 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132 write!(formatter, "{}, {}", self.latitude, self.longitude)
133 }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq)]
137pub struct CoordinatePair(Latitude, Longitude);
138
139impl CoordinatePair {
140 #[must_use]
141 pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
142 Self(latitude, longitude)
143 }
144
145 #[must_use]
146 pub const fn latitude(self) -> Latitude {
147 self.0
148 }
149
150 #[must_use]
151 pub const fn longitude(self) -> Longitude {
152 self.1
153 }
154}
155
156impl fmt::Display for CoordinatePair {
157 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158 write!(formatter, "{}, {}", self.0, self.1)
159 }
160}
161
162impl From<CoordinatePair> for GeoCoordinate {
163 fn from(pair: CoordinatePair) -> Self {
164 Self::new(pair.latitude(), pair.longitude())
165 }
166}
167
168impl From<GeoCoordinate> for CoordinatePair {
169 fn from(coordinate: GeoCoordinate) -> Self {
170 Self::new(coordinate.latitude(), coordinate.longitude())
171 }
172}
173
174#[derive(Clone, Debug, Eq, Hash, PartialEq)]
175pub enum CoordinateFormat {
176 DecimalDegrees,
177 DegreesMinutesSeconds,
178 Unknown,
179 Custom(String),
180}
181
182impl fmt::Display for CoordinateFormat {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 Self::DecimalDegrees => formatter.write_str("decimal-degrees"),
186 Self::DegreesMinutesSeconds => formatter.write_str("degrees-minutes-seconds"),
187 Self::Unknown => formatter.write_str("unknown"),
188 Self::Custom(value) => formatter.write_str(value),
189 }
190 }
191}
192
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194pub enum CoordinateFormatParseError {
195 Empty,
196}
197
198impl fmt::Display for CoordinateFormatParseError {
199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 Self::Empty => formatter.write_str("coordinate format cannot be empty"),
202 }
203 }
204}
205
206impl Error for CoordinateFormatParseError {}
207
208impl FromStr for CoordinateFormat {
209 type Err = CoordinateFormatParseError;
210
211 fn from_str(value: &str) -> Result<Self, Self::Err> {
212 let trimmed = value.trim();
213
214 if trimmed.is_empty() {
215 return Err(CoordinateFormatParseError::Empty);
216 }
217
218 Ok(match normalized_token(trimmed).as_str() {
219 "decimal-degrees" => Self::DecimalDegrees,
220 "degrees-minutes-seconds" | "dms" => Self::DegreesMinutesSeconds,
221 "unknown" => Self::Unknown,
222 _ => Self::Custom(trimmed.to_string()),
223 })
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::{
230 CoordinateFormat, CoordinateFormatParseError, CoordinatePair, GeoCoordinate,
231 GeoCoordinateError, Latitude, Longitude,
232 };
233
234 #[test]
235 fn valid_latitude() -> Result<(), GeoCoordinateError> {
236 let latitude = Latitude::new(37.7749)?;
237
238 assert!((latitude.degrees() - 37.7749).abs() < f64::EPSILON);
239 Ok(())
240 }
241
242 #[test]
243 fn invalid_latitude_rejected() {
244 assert_eq!(
245 Latitude::new(90.1),
246 Err(GeoCoordinateError::LatitudeOutOfRange)
247 );
248 }
249
250 #[test]
251 fn valid_longitude() -> Result<(), GeoCoordinateError> {
252 let longitude = Longitude::new(-122.4194)?;
253
254 assert!((longitude.degrees() - -122.4194).abs() < f64::EPSILON);
255 Ok(())
256 }
257
258 #[test]
259 fn invalid_longitude_rejected() {
260 assert_eq!(
261 Longitude::new(-180.1),
262 Err(GeoCoordinateError::LongitudeOutOfRange)
263 );
264 }
265
266 #[test]
267 fn coordinate_pair_construction() -> Result<(), GeoCoordinateError> {
268 let latitude = Latitude::new(51.5074)?;
269 let longitude = Longitude::new(-0.1278)?;
270 let pair = CoordinatePair::new(latitude, longitude);
271 let coordinate = GeoCoordinate::from(pair);
272
273 assert_eq!(pair.latitude(), latitude);
274 assert_eq!(pair.longitude(), longitude);
275 assert_eq!(coordinate.latitude(), latitude);
276 assert_eq!(coordinate.longitude(), longitude);
277 assert_eq!(CoordinatePair::from(coordinate), pair);
278 Ok(())
279 }
280
281 #[test]
282 fn coordinate_format_display_parse() -> Result<(), CoordinateFormatParseError> {
283 assert_eq!(
284 CoordinateFormat::DecimalDegrees.to_string(),
285 "decimal-degrees"
286 );
287 assert_eq!(
288 "degrees minutes seconds".parse::<CoordinateFormat>()?,
289 CoordinateFormat::DegreesMinutesSeconds
290 );
291 assert_eq!(
292 "vendor-specific".parse::<CoordinateFormat>()?,
293 CoordinateFormat::Custom(String::from("vendor-specific"))
294 );
295 Ok(())
296 }
297}