Skip to main content

retch_sysinfo/
weather.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Weather information via Open-Meteo (forecast) and Open-Meteo geocoding / ipinfo.io.
5
6/// Temperature unit for weather display.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum WeatherUnit {
9    /// Degrees Fahrenheit (default).
10    #[default]
11    Fahrenheit,
12    /// Degrees Celsius.
13    Celsius,
14}
15
16impl std::str::FromStr for WeatherUnit {
17    type Err = std::convert::Infallible;
18    fn from_str(s: &str) -> Result<Self, Self::Err> {
19        Ok(match s.to_ascii_lowercase().as_str() {
20            "celsius" | "c" => Self::Celsius,
21            _ => Self::Fahrenheit,
22        })
23    }
24}
25
26impl WeatherUnit {
27    fn api_param(self) -> &'static str {
28        match self {
29            Self::Fahrenheit => "fahrenheit",
30            Self::Celsius => "celsius",
31        }
32    }
33
34    fn symbol(self) -> &'static str {
35        match self {
36            Self::Fahrenheit => "°F",
37            Self::Celsius => "°C",
38        }
39    }
40}
41
42struct GeoPoint {
43    lat: f64,
44    lon: f64,
45    display: String,
46}
47
48/// Fetch current weather for `location` and return a formatted string like
49/// `"Santa Barbara, California: ☀️ 67°F"`.
50///
51/// If `location` is `None` or empty the caller's IP address is used to
52/// auto-detect a location via ipinfo.io.  Returns `None` on any network
53/// failure, unrecognised location, or JSON parse error — weather is
54/// best-effort and should never block the rest of the output.
55pub(crate) fn detect_weather(location: Option<&str>, unit: WeatherUnit) -> Option<String> {
56    let geo = match location {
57        Some(loc) if !loc.is_empty() => resolve_location(loc)?,
58        _ => geolocate_ip()?,
59    };
60    let (temp, wmo_code) = fetch_open_meteo(geo.lat, geo.lon, unit)?;
61    let emoji = wmo_to_emoji(wmo_code);
62    Some(format!(
63        "{}: {} {:.0}{}",
64        geo.display,
65        emoji,
66        temp,
67        unit.symbol()
68    ))
69}
70
71/// Parse a raw "lat,lon" coordinate string, returning `None` if not in that form.
72fn parse_coords(s: &str) -> Option<GeoPoint> {
73    let s = s.trim();
74    let comma = s.find(',')?;
75    let lat: f64 = s[..comma].trim().parse().ok()?;
76    let lon: f64 = s[comma + 1..].trim().parse().ok()?;
77    if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
78        return None;
79    }
80    Some(GeoPoint {
81        lat,
82        lon,
83        display: s.to_string(),
84    })
85}
86
87fn resolve_location(loc: &str) -> Option<GeoPoint> {
88    if let Some(coords) = parse_coords(loc) {
89        return Some(coords);
90    }
91    geocode_open_meteo(loc)
92}
93
94/// Geocode a city/place name using the Open-Meteo geocoding API.
95///
96/// The API's `name` parameter only matches city names, so "City, State" inputs are split
97/// on the first comma and only the city part is sent.
98fn geocode_open_meteo(loc: &str) -> Option<GeoPoint> {
99    let city_part = loc.split(',').next().unwrap_or(loc).trim();
100    let encoded = url_encode(city_part);
101    let url = format!(
102        "https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
103        encoded
104    );
105    let body = curl_get(&url)?;
106    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
107    let result = v.get("results")?.get(0)?;
108
109    let lat = result.get("latitude")?.as_f64()?;
110    let lon = result.get("longitude")?.as_f64()?;
111    let name = result.get("name")?.as_str().unwrap_or(loc);
112    let country_code = result
113        .get("country_code")
114        .and_then(|v| v.as_str())
115        .unwrap_or("");
116    let admin1 = result.get("admin1").and_then(|v| v.as_str()).unwrap_or("");
117    let country = result.get("country").and_then(|v| v.as_str()).unwrap_or("");
118
119    let display = if !admin1.is_empty() && country_code == "US" {
120        format!("{}, {}", name, admin1)
121    } else if !country.is_empty() {
122        format!("{}, {}", name, country)
123    } else {
124        name.to_string()
125    };
126
127    Some(GeoPoint { lat, lon, display })
128}
129
130/// Determine location from the outbound IP address via ipinfo.io.
131fn geolocate_ip() -> Option<GeoPoint> {
132    let body = curl_get("https://ipinfo.io/json")?;
133    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
134
135    let loc_str = v.get("loc")?.as_str()?;
136    let comma = loc_str.find(',')?;
137    let lat: f64 = loc_str[..comma].parse().ok()?;
138    let lon: f64 = loc_str[comma + 1..].parse().ok()?;
139
140    let city = v.get("city").and_then(|v| v.as_str()).unwrap_or("");
141    let region = v.get("region").and_then(|v| v.as_str()).unwrap_or("");
142    let country = v.get("country").and_then(|v| v.as_str()).unwrap_or("");
143
144    let display = match (city.is_empty(), region.is_empty(), country == "US") {
145        (false, false, true) => format!("{}, {}", city, region),
146        (false, _, false) if !country.is_empty() => format!("{}, {}", city, country),
147        (false, _, _) => city.to_string(),
148        _ => loc_str.to_string(),
149    };
150
151    Some(GeoPoint { lat, lon, display })
152}
153
154/// Fetch current temperature and WMO weather code from Open-Meteo.
155fn fetch_open_meteo(lat: f64, lon: f64, unit: WeatherUnit) -> Option<(f64, u16)> {
156    let url = format!(
157        "https://api.open-meteo.com/v1/forecast?latitude={:.5}&longitude={:.5}&current=temperature_2m,weather_code&temperature_unit={}",
158        lat, lon, unit.api_param()
159    );
160    let body = curl_get(&url)?;
161    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
162    let current = v.get("current")?;
163    let temp = current.get("temperature_2m")?.as_f64()?;
164    let wmo = current.get("weather_code")?.as_u64()? as u16;
165    Some((temp, wmo))
166}
167
168/// Run `curl -sf --max-time 4 <url>` and return stdout on success, `None` on any failure.
169fn curl_get(url: &str) -> Option<String> {
170    let out = std::process::Command::new("curl")
171        .args(["-sf", "--max-time", "4", url])
172        .output()
173        .ok()?;
174    if !out.status.success() {
175        return None;
176    }
177    String::from_utf8(out.stdout).ok()
178}
179
180/// Map a WMO weather interpretation code to a representative emoji.
181fn wmo_to_emoji(code: u16) -> &'static str {
182    match code {
183        0 => "☀️",
184        1 => "🌤️",
185        2 => "⛅",
186        3 => "☁️",
187        45 | 48 => "🌫️",
188        51 | 53 | 55 => "🌦️",
189        56 | 57 | 61..=67 | 80 | 81 => "🌧️",
190        82 | 95..=99 => "⛈️",
191        71..=77 | 85 | 86 => "🌨️",
192        _ => "🌡️",
193    }
194}
195
196fn url_encode(s: &str) -> String {
197    s.chars()
198        .map(|c| match c {
199            ' ' => "+".to_string(),
200            ',' => "%2C".to_string(),
201            c if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~') => c.to_string(),
202            c => format!("%{:02X}", c as u32),
203        })
204        .collect()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_url_encode() {
213        assert_eq!(url_encode("London"), "London");
214        assert_eq!(url_encode("Thousand Oaks, CA"), "Thousand+Oaks%2C+CA");
215        assert_eq!(url_encode("New York"), "New+York");
216        assert_eq!(url_encode("93426"), "93426");
217    }
218
219    #[test]
220    fn test_parse_coords() {
221        let g = parse_coords("34.42,-119.70").unwrap();
222        assert!((g.lat - 34.42).abs() < 1e-6);
223        assert!((g.lon - -119.70).abs() < 1e-6);
224
225        assert!(parse_coords("Santa Barbara").is_none());
226        assert!(parse_coords("93101").is_none());
227    }
228
229    #[test]
230    fn test_parse_coords_edge_cases() {
231        // Spaces around the comma are allowed.
232        let g = parse_coords("34.42, -119.70").unwrap();
233        assert!((g.lat - 34.42).abs() < 1e-6);
234        assert!((g.lon - -119.70).abs() < 1e-6);
235
236        // Out-of-range latitude must be rejected.
237        assert!(parse_coords("91.0,0.0").is_none());
238        // Out-of-range longitude must be rejected.
239        assert!(parse_coords("0.0,181.0").is_none());
240        // Both negative (southern hemisphere, western hemisphere) is valid.
241        let g = parse_coords("-33.87,151.21").unwrap();
242        assert!((g.lat - -33.87).abs() < 1e-6);
243    }
244
245    #[test]
246    fn test_weather_unit_from_str() {
247        assert_eq!(
248            "celsius".parse::<WeatherUnit>().unwrap(),
249            WeatherUnit::Celsius
250        );
251        assert_eq!("C".parse::<WeatherUnit>().unwrap(), WeatherUnit::Celsius);
252        assert_eq!(
253            "fahrenheit".parse::<WeatherUnit>().unwrap(),
254            WeatherUnit::Fahrenheit
255        );
256        assert_eq!(
257            "nonsense".parse::<WeatherUnit>().unwrap(),
258            WeatherUnit::Fahrenheit
259        );
260    }
261
262    #[test]
263    fn test_wmo_to_emoji() {
264        assert_eq!(wmo_to_emoji(0), "☀️"); // clear
265        assert_eq!(wmo_to_emoji(1), "🌤️"); // mainly clear
266        assert_eq!(wmo_to_emoji(2), "⛅"); // partly cloudy
267        assert_eq!(wmo_to_emoji(3), "☁️"); // overcast
268        assert_eq!(wmo_to_emoji(45), "🌫️"); // fog
269        assert_eq!(wmo_to_emoji(48), "🌫️"); // rime fog
270        assert_eq!(wmo_to_emoji(51), "🌦️"); // light drizzle
271        assert_eq!(wmo_to_emoji(63), "🌧️"); // moderate rain
272        assert_eq!(wmo_to_emoji(73), "🌨️"); // moderate snow
273        assert_eq!(wmo_to_emoji(82), "⛈️"); // violent rain showers
274        assert_eq!(wmo_to_emoji(95), "⛈️"); // thunderstorm
275        assert_eq!(wmo_to_emoji(200), "🌡️"); // unknown code → fallback
276    }
277
278    /// Tests the display-name logic in `geolocate_ip` using hand-constructed JSON —
279    /// no network required.
280    #[test]
281    fn test_geolocate_ip_display_name() {
282        // US city: should produce "City, Region"
283        let us_json = r#"{"city":"Santa Barbara","region":"California","country":"US","loc":"34.42,-119.70"}"#;
284        let v: serde_json::Value = serde_json::from_str(us_json).unwrap();
285        let city = v.get("city").and_then(|v| v.as_str()).unwrap_or("");
286        let region = v.get("region").and_then(|v| v.as_str()).unwrap_or("");
287        let country = v.get("country").and_then(|v| v.as_str()).unwrap_or("");
288        let display = match (city.is_empty(), region.is_empty(), country == "US") {
289            (false, false, true) => format!("{}, {}", city, region),
290            (false, _, false) if !country.is_empty() => format!("{}, {}", city, country),
291            (false, _, _) => city.to_string(),
292            _ => "0,0".to_string(),
293        };
294        assert_eq!(display, "Santa Barbara, California");
295
296        // Non-US city: should produce "City, Country"
297        let non_us_json =
298            r#"{"city":"London","region":"England","country":"GB","loc":"51.51,-0.13"}"#;
299        let v: serde_json::Value = serde_json::from_str(non_us_json).unwrap();
300        let city = v.get("city").and_then(|v| v.as_str()).unwrap_or("");
301        let region = v.get("region").and_then(|v| v.as_str()).unwrap_or("");
302        let country = v.get("country").and_then(|v| v.as_str()).unwrap_or("");
303        let display = match (city.is_empty(), region.is_empty(), country == "US") {
304            (false, false, true) => format!("{}, {}", city, region),
305            (false, _, false) if !country.is_empty() => format!("{}, {}", city, country),
306            (false, _, _) => city.to_string(),
307            _ => "0,0".to_string(),
308        };
309        assert_eq!(display, "London, GB");
310
311        // No city: should fall back to raw "lat,lon"
312        let no_city_json = r#"{"city":"","region":"","country":"US","loc":"34.42,-119.70"}"#;
313        let v: serde_json::Value = serde_json::from_str(no_city_json).unwrap();
314        let city = v.get("city").and_then(|v| v.as_str()).unwrap_or("");
315        let region = v.get("region").and_then(|v| v.as_str()).unwrap_or("");
316        let country = v.get("country").and_then(|v| v.as_str()).unwrap_or("");
317        let loc_str = "34.42,-119.70";
318        let display = match (city.is_empty(), region.is_empty(), country == "US") {
319            (false, false, true) => format!("{}, {}", city, region),
320            (false, _, false) if !country.is_empty() => format!("{}, {}", city, country),
321            (false, _, _) => city.to_string(),
322            _ => loc_str.to_string(),
323        };
324        assert_eq!(display, "34.42,-119.70");
325    }
326}