Skip to main content

cadmus_core/
geolocation.rs

1use anyhow::Error;
2use chrono_tz::Tz;
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::time::Duration;
6
7use crate::http::Client as HttpClient;
8
9/// Geographic coordinates expressed as latitude and longitude in degrees.
10///
11/// Latitude must be in the inclusive range `-90.0..=90.0` and longitude
12/// must be in the inclusive range `-180.0..=180.0`.
13#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Coordinates(f64, f64);
15
16impl Coordinates {
17    /// Creates validated geographic coordinates.
18    ///
19    /// Returns an error if either component is `NaN` or falls outside the
20    /// allowed latitude/longitude bounds.
21    pub fn new(lat: f64, lon: f64) -> Result<Self, anyhow::Error> {
22        anyhow::ensure!(
23            !lat.is_nan()
24                && !lon.is_nan()
25                && (-90.0..=90.0).contains(&lat)
26                && (-180.0..=180.0).contains(&lon),
27            "The given coordinates are invalid"
28        );
29
30        Ok(Self(lat, lon))
31    }
32
33    /// Returns the latitude in degrees.
34    pub fn latitude(self) -> f64 {
35        self.0
36    }
37
38    /// Returns the longitude in degrees.
39    pub fn longitude(self) -> f64 {
40        self.1
41    }
42}
43
44impl From<Coordinates> for sunrise::Coordinates {
45    fn from(value: Coordinates) -> Self {
46        Self::new(value.0, value.1).unwrap_or_else(|| {
47           panic!(
48               "Given coordinates are invalid, the Coordinates struct should not be holding invalid coordinates: {value}"
49           )
50       })
51    }
52}
53
54impl fmt::Display for Coordinates {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "({},{})", self.latitude(), self.longitude())
57    }
58}
59
60/// A geographic location paired with its local time zone.
61#[derive(Copy, Clone)]
62pub struct GeoLocation {
63    /// The location's latitude and longitude.
64    pub coordinates: Coordinates,
65    /// The IANA time zone associated with the coordinates.
66    pub timezone: Tz,
67}
68
69impl fmt::Display for GeoLocation {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{} {}", self.coordinates, self.timezone)
72    }
73}
74
75impl From<GeoLocation> for sunrise::Coordinates {
76    fn from(value: GeoLocation) -> Self {
77        value.coordinates.into()
78    }
79}
80
81#[derive(Debug, Clone, Deserialize)]
82struct IpApiResponse {
83    latitude: f64,
84    longitude: f64,
85    timezone: String,
86}
87
88/// Fetches an approximate geolocation for the current network connection.
89///
90/// This uses `https://ipapi.co/json/` to resolve the device's public IP to a
91/// latitude/longitude pair and an IANA time zone. The request times out after
92/// 10 seconds.
93pub fn fetch_geolocation(client: &HttpClient) -> Result<GeoLocation, Error> {
94    let resp: IpApiResponse = client
95        .get("https://ipapi.co/json/")
96        .timeout(Duration::from_secs(10))
97        .send()?
98        .json()?;
99
100    let timezone = resp
101        .timezone
102        .parse::<Tz>()
103        .map_err(|e| anyhow::anyhow!("invalid timezone from ipapi: {e}"))?;
104
105    let coordinates = Coordinates::new(resp.latitude, resp.longitude)?;
106
107    Ok(GeoLocation {
108        coordinates,
109        timezone,
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn parse_ipapi_response() {
119        let json = serde_json::json!({
120            "latitude": 51.5074,
121            "longitude": -0.1278,
122            "timezone": "Europe/London"
123        });
124        let resp: IpApiResponse = serde_json::from_value(json).unwrap();
125        assert!((resp.latitude - 51.5074).abs() < 0.001);
126        assert!((resp.longitude - (-0.1278)).abs() < 0.001);
127        assert_eq!(resp.timezone, "Europe/London");
128    }
129
130    #[test]
131    fn coordinates_validate_bounds() {
132        assert!(Coordinates::new(51.5074, -0.1278).is_ok());
133        assert!(Coordinates::new(-90.0, -180.0).is_ok());
134        assert!(Coordinates::new(90.0, 180.0).is_ok());
135        assert!(Coordinates::new(f64::NAN, 0.0).is_err());
136        assert!(Coordinates::new(0.0, f64::NAN).is_err());
137        assert!(Coordinates::new(90.1, 0.0).is_err());
138        assert!(Coordinates::new(0.0, 180.1).is_err());
139    }
140}