Skip to main content

geo_kit/
coords.rs

1//! Geographic coordinates newtype.
2
3extern crate alloc;
4
5use alloc::string::ToString;
6use core::fmt;
7
8use crate::error::GeoError;
9
10/// Geographic coordinates in decimal degrees.
11///
12/// Validation:
13/// - `lat` must be finite and in `[-90.0, 90.0]`
14/// - `lon` must be finite and in `[-180.0, 180.0]`
15#[derive(Debug, Clone, Copy, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct Coords {
18    /// Latitude in decimal degrees, `-90..=90`.
19    pub lat: f64,
20    /// Longitude in decimal degrees, `-180..=180`.
21    pub lon: f64,
22}
23
24impl Coords {
25    /// Create validated coordinates.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`GeoError::InvalidCoords`] if validation fails.
30    pub fn new(lat: f64, lon: f64) -> Result<Self, GeoError> {
31        validate_coords(lat, lon)
32    }
33
34    /// Parse from a string slice in the form `"lat,lon"` or `"lat lon"`.
35    ///
36    /// Whitespace around numbers and separator is allowed.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`GeoError::InvalidCoords`] if parsing or validation fails.
41    pub fn parse(s: &str) -> Result<Self, GeoError> {
42        let trimmed = s.trim();
43        if trimmed.is_empty() {
44            return Err(GeoError::InvalidCoords("coords string is empty".to_string()));
45        }
46        // Support ',' or whitespace separated, but prefer comma.
47        let (lat_str, lon_str) = if let Some(idx) = trimmed.find(',') {
48            let (a, b) = trimmed.split_at(idx);
49            (a.trim(), b[1..].trim())
50        } else {
51            // split on whitespace
52            let mut parts = trimmed.split_whitespace();
53            let a = parts.next();
54            let b = parts.next();
55            let extra = parts.next();
56            match (a, b, extra) {
57                (Some(a), Some(b), None) => (a, b),
58                _ => {
59                    return Err(GeoError::InvalidCoords(alloc::format!(
60                        "coords '{}' must be 'lat,lon' or 'lat lon'",
61                        s
62                    )))
63                }
64            }
65        };
66        let lat: f64 = lat_str.parse().map_err(|_| {
67            GeoError::InvalidCoords(alloc::format!("invalid latitude '{}'", lat_str))
68        })?;
69        let lon: f64 = lon_str.parse().map_err(|_| {
70            GeoError::InvalidCoords(alloc::format!("invalid longitude '{}'", lon_str))
71        })?;
72        validate_coords(lat, lon)
73    }
74
75    /// Return latitude.
76    #[must_use]
77    pub fn lat(&self) -> f64 {
78        self.lat
79    }
80
81    /// Return longitude.
82    #[must_use]
83    pub fn lon(&self) -> f64 {
84        self.lon
85    }
86}
87
88fn validate_coords(lat: f64, lon: f64) -> Result<Coords, GeoError> {
89    if !lat.is_finite() {
90        return Err(GeoError::InvalidCoords(alloc::format!(
91            "latitude {} is not finite",
92            lat
93        )));
94    }
95    if !lon.is_finite() {
96        return Err(GeoError::InvalidCoords(alloc::format!(
97            "longitude {} is not finite",
98            lon
99        )));
100    }
101    if !(-90.0..=90.0).contains(&lat) {
102        return Err(GeoError::InvalidCoords(alloc::format!(
103            "latitude {} out of range -90..90",
104            lat
105        )));
106    }
107    if !(-180.0..=180.0).contains(&lon) {
108        return Err(GeoError::InvalidCoords(alloc::format!(
109            "longitude {} out of range -180..180",
110            lon
111        )));
112    }
113    Ok(Coords { lat, lon })
114}
115
116/// Returns `true` if `lat` and `lon` are valid coordinates.
117#[must_use]
118pub fn is_valid_coords(lat: f64, lon: f64) -> bool {
119    validate_coords(lat, lon).is_ok()
120}
121
122impl fmt::Display for Coords {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{},{}", self.lat, self.lon)
125    }
126}
127
128impl core::str::FromStr for Coords {
129    type Err = GeoError;
130    fn from_str(s: &str) -> Result<Self, Self::Err> {
131        Coords::parse(s)
132    }
133}
134
135impl TryFrom<(f64, f64)> for Coords {
136    type Error = GeoError;
137    fn try_from(value: (f64, f64)) -> Result<Self, Self::Error> {
138        Coords::new(value.0, value.1)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn valid_coords() {
148        assert!(Coords::new(51.5074, -0.1278).is_ok());
149        assert!(Coords::new(90.0, 180.0).is_ok());
150        assert!(Coords::new(-90.0, -180.0).is_ok());
151        assert!(Coords::new(0.0, 0.0).is_ok());
152    }
153
154    #[test]
155    fn invalid_lat() {
156        assert!(Coords::new(91.0, 0.0).is_err());
157        assert!(Coords::new(-91.0, 0.0).is_err());
158        assert!(Coords::new(f64::NAN, 0.0).is_err());
159        assert!(Coords::new(f64::INFINITY, 0.0).is_err());
160    }
161
162    #[test]
163    fn invalid_lon() {
164        assert!(Coords::new(0.0, 181.0).is_err());
165        assert!(Coords::new(0.0, -181.0).is_err());
166        assert!(Coords::new(0.0, f64::NAN).is_err());
167    }
168
169    #[test]
170    fn parse_comma() {
171        let c = Coords::parse("51.5074,-0.1278").expect("valid");
172        assert!((c.lat - 51.5074).abs() < 1e-9);
173        assert!((c.lon - -0.1278).abs() < 1e-9);
174    }
175
176    #[test]
177    fn parse_space() {
178        let c = Coords::parse("51.5074 -0.1278").expect("valid");
179        assert!((c.lat - 51.5074).abs() < 1e-9);
180    }
181
182    #[test]
183    fn parse_invalid() {
184        assert!(Coords::parse("").is_err());
185        assert!(Coords::parse("91,0").is_err());
186        assert!(Coords::parse("0,181").is_err());
187        assert!(Coords::parse("not a coord").is_err());
188        assert!(Coords::parse("51.5").is_err());
189    }
190
191    #[test]
192    fn is_valid_helper() {
193        assert!(is_valid_coords(0.0, 0.0));
194        assert!(!is_valid_coords(91.0, 0.0));
195    }
196
197    #[test]
198    #[cfg(feature = "serde")]
199    fn serde_roundtrip() {
200        let coords = Coords::new(51.5, -0.12).expect("valid");
201        let json = serde_json::to_string(&coords).expect("serialize");
202        let de: Coords = serde_json::from_str(&json).expect("deserialize");
203        assert_eq!(coords, de);
204    }
205}