1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum WeatherUnit {
9 #[default]
11 Fahrenheit,
12 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
48pub(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
71fn 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
94fn 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
130fn 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
154fn 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}¤t=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
168fn 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
180fn 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 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 assert!(parse_coords("91.0,0.0").is_none());
238 assert!(parse_coords("0.0,181.0").is_none());
240 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), "☀️"); assert_eq!(wmo_to_emoji(1), "🌤️"); assert_eq!(wmo_to_emoji(2), "⛅"); assert_eq!(wmo_to_emoji(3), "☁️"); assert_eq!(wmo_to_emoji(45), "🌫️"); assert_eq!(wmo_to_emoji(48), "🌫️"); assert_eq!(wmo_to_emoji(51), "🌦️"); assert_eq!(wmo_to_emoji(63), "🌧️"); assert_eq!(wmo_to_emoji(73), "🌨️"); assert_eq!(wmo_to_emoji(82), "⛈️"); assert_eq!(wmo_to_emoji(95), "⛈️"); assert_eq!(wmo_to_emoji(200), "🌡️"); }
277
278 #[test]
281 fn test_geolocate_ip_display_name() {
282 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 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 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}