Skip to main content

use_geo_coordinate/
lib.rs

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    /// Creates a latitude from decimal degrees.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`GeoCoordinateError::LatitudeNotFinite`] when the value is not finite.
45    /// Returns [`GeoCoordinateError::LatitudeOutOfRange`] when the value is outside `-90.0..=90.0`.
46    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    /// Creates a longitude from decimal degrees.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`GeoCoordinateError::LongitudeNotFinite`] when the value is not finite.
79    /// Returns [`GeoCoordinateError::LongitudeOutOfRange`] when the value is outside `-180.0..=180.0`.
80    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}