1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum WeatherUnit {
22 #[default]
24 Fahrenheit,
25 Celsius,
27}
28
29impl std::str::FromStr for WeatherUnit {
30 type Err = std::convert::Infallible;
31 fn from_str(s: &str) -> Result<Self, Self::Err> {
32 Ok(match s.to_ascii_lowercase().as_str() {
33 "celsius" | "c" => Self::Celsius,
34 _ => Self::Fahrenheit,
35 })
36 }
37}
38
39impl WeatherUnit {
40 fn wttr_flag(self) -> &'static str {
42 match self {
43 Self::Fahrenheit => "u",
44 Self::Celsius => "m",
45 }
46 }
47
48 fn symbol(self) -> &'static str {
49 match self {
50 Self::Fahrenheit => "°F",
51 Self::Celsius => "°C",
52 }
53 }
54}
55
56pub(crate) fn detect_weather(location: Option<&str>, unit: WeatherUnit) -> Option<String> {
66 let location = location.map(str::trim).filter(|l| !l.is_empty());
67 let body = curl_get(&wttr_url(location, unit))?;
68 parse_wttr(&body, unit, location.is_some())
69}
70
71fn wttr_url(location: Option<&str>, unit: WeatherUnit) -> String {
74 format!(
75 "http://wttr.in/{}?format=%l|%c|%t&{}",
76 location.map(url_encode).unwrap_or_default(),
77 unit.wttr_flag()
78 )
79}
80
81fn parse_wttr(body: &str, unit: WeatherUnit, overridden: bool) -> Option<String> {
93 let body = body.trim();
94 if body.chars().any(char::is_control) {
95 return None;
96 }
97 let mut fields = body.split('|');
98 let (loc, emoji, temp) = (fields.next()?, fields.next()?, fields.next()?);
99 if fields.next().is_some() {
100 return None;
101 }
102 let loc = loc.trim();
103 if loc.is_empty() {
104 return None;
105 }
106 let degrees: f64 = temp
107 .trim()
108 .strip_suffix(unit.symbol())?
109 .trim_start_matches('+')
110 .parse()
111 .ok()?;
112 let emoji = match emoji.trim() {
113 "" => "🌡️",
114 e => e,
115 };
116 let display = if overridden {
117 loc.to_string()
118 } else {
119 shorten_location(loc)
120 };
121 Some(format!(
122 "{}: {} {:.0}{}",
123 display,
124 emoji,
125 degrees,
126 unit.symbol()
127 ))
128}
129
130fn shorten_location(loc: &str) -> String {
133 let parts: Vec<&str> = loc.split(", ").collect();
134 match parts.as_slice() {
135 [city, region, .., country] if is_us(country) => format!("{city}, {region}"),
136 [city, .., country] if parts.len() >= 2 => format!("{city}, {country}"),
137 _ => loc.to_string(),
138 }
139}
140
141fn is_us(country: &str) -> bool {
142 matches!(
143 country,
144 "US" | "USA" | "United States" | "United States of America"
145 )
146}
147
148fn curl_get(url: &str) -> Option<String> {
150 let out = std::process::Command::new("curl")
151 .args(["-sf", "--max-time", "4", url])
152 .output()
153 .ok()?;
154 if !out.status.success() {
155 return None;
156 }
157 String::from_utf8(out.stdout).ok()
158}
159
160fn url_encode(s: &str) -> String {
166 let mut out = String::with_capacity(s.len());
167 for b in s.bytes() {
168 match b {
169 b' ' => out.push('+'),
170 b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
171 out.push(b as char)
172 }
173 b => out.push_str(&format!("%{b:02X}")),
174 }
175 }
176 out
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_url_encode() {
185 assert_eq!(url_encode("London"), "London");
186 assert_eq!(url_encode("Thousand Oaks, CA"), "Thousand+Oaks%2C+CA");
187 assert_eq!(url_encode("New York"), "New+York");
188 assert_eq!(url_encode("93426"), "93426");
189 assert_eq!(url_encode("34.42,-119.70"), "34.42%2C-119.70");
190 assert_eq!(url_encode("~Eiffel Tower"), "~Eiffel+Tower");
191 }
192
193 #[test]
194 fn url_encode_uses_utf8_bytes_not_code_points() {
195 assert_eq!(url_encode("São Paulo"), "S%C3%A3o+Paulo");
197 assert_eq!(url_encode("Zürich"), "Z%C3%BCrich");
198 }
199
200 #[test]
201 fn wttr_url_carries_location_and_unit() {
202 assert_eq!(
203 wttr_url(None, WeatherUnit::Fahrenheit),
204 "http://wttr.in/?format=%l|%c|%t&u"
205 );
206 assert_eq!(
207 wttr_url(Some("Thousand Oaks, CA"), WeatherUnit::Celsius),
208 "http://wttr.in/Thousand+Oaks%2C+CA?format=%l|%c|%t&m"
209 );
210 }
211
212 #[test]
213 fn rejects_a_reply_carrying_control_characters() {
214 let f = WeatherUnit::Fahrenheit;
216 assert_eq!(
217 parse_wttr("\u{1b}]0;pwned\u{7}X|☀️ |+5°F", f, true),
218 None,
219 "OSC title escape in the location"
220 );
221 assert_eq!(
222 parse_wttr("X\u{1b}[2J|☀️ |+5°F", f, true),
223 None,
224 "CSI clear-screen in the location"
225 );
226 assert_eq!(parse_wttr("X|☀️\u{8}|+5°F", f, true), None, "backspace");
227 assert_eq!(
228 parse_wttr("Los Angeles, California,\rUS|☀️ |+82°F", f, false),
229 None,
230 "carriage return mid-line"
231 );
232 }
233
234 #[test]
235 fn real_weather_emoji_are_not_control_characters() {
236 for emoji in ["☀️", "☁️", "⛅️", "🌦️", "🌧️", "⛈️", "🌨️", "❄️", "🌫️", "🌩️"]
239 {
240 let reply = format!("Los Angeles, California, US|{emoji} |+82°F\n");
241 assert_eq!(
242 parse_wttr(&reply, WeatherUnit::Fahrenheit, false),
243 Some(format!("Los Angeles, California: {emoji} 82°F")),
244 "{emoji}"
245 );
246 }
247 }
248
249 #[test]
250 fn test_weather_unit_from_str() {
251 assert_eq!(
252 "celsius".parse::<WeatherUnit>().unwrap(),
253 WeatherUnit::Celsius
254 );
255 assert_eq!("C".parse::<WeatherUnit>().unwrap(), WeatherUnit::Celsius);
256 assert_eq!(
257 "fahrenheit".parse::<WeatherUnit>().unwrap(),
258 WeatherUnit::Fahrenheit
259 );
260 assert_eq!(
261 "nonsense".parse::<WeatherUnit>().unwrap(),
262 WeatherUnit::Fahrenheit
263 );
264 }
265
266 #[test]
269 fn parses_an_ip_located_us_response() {
270 assert_eq!(
271 parse_wttr(
272 "Newbury Park, California, US|☁️ |+69°F",
273 WeatherUnit::Fahrenheit,
274 false
275 )
276 .as_deref(),
277 Some("Newbury Park, California: ☁️ 69°F")
278 );
279 }
280
281 #[test]
282 fn parses_celsius_and_negative_temperatures() {
283 assert_eq!(
284 parse_wttr("London|☁️ |+15°C", WeatherUnit::Celsius, true).as_deref(),
285 Some("London: ☁️ 15°C")
286 );
287 assert_eq!(
288 parse_wttr("Nuuk|🌨️ |-7°C\n", WeatherUnit::Celsius, true).as_deref(),
289 Some("Nuuk: 🌨️ -7°C")
290 );
291 }
292
293 #[test]
294 fn an_override_is_shown_as_given() {
295 assert_eq!(
297 parse_wttr("93426|☀️ |+58°F", WeatherUnit::Fahrenheit, true).as_deref(),
298 Some("93426: ☀️ 58°F")
299 );
300 assert_eq!(
301 parse_wttr("34.42,-119.70|☀️ |+63°F", WeatherUnit::Fahrenheit, true).as_deref(),
302 Some("34.42,-119.70: ☀️ 63°F")
303 );
304 assert_eq!(
307 parse_wttr(
308 "Paris, Ile-de-France, France|☀️ |+17°C",
309 WeatherUnit::Celsius,
310 true
311 )
312 .as_deref(),
313 Some("Paris, Ile-de-France, France: ☀️ 17°C")
314 );
315 }
316
317 #[test]
318 fn rejects_anything_that_is_not_a_weather_line() {
319 let f = WeatherUnit::Fahrenheit;
320 assert_eq!(
323 parse_wttr(
324 "location not found: upstream error: opencage: invalid response",
325 f,
326 true
327 ),
328 None
329 );
330 assert_eq!(parse_wttr("", f, false), None);
331 assert_eq!(parse_wttr("a|b", f, false), None, "too few fields");
332 assert_eq!(parse_wttr("a|b|+5°F|x", f, false), None, "too many fields");
333 assert_eq!(parse_wttr("|☀️ |+5°F", f, false), None, "no location");
334 assert_eq!(parse_wttr("X|☀️ |warm", f, false), None, "no number");
335 assert_eq!(
336 parse_wttr("X|☀️ |+5°C", f, false),
337 None,
338 "wrong unit for the request"
339 );
340 }
341
342 #[test]
343 fn a_missing_emoji_falls_back_to_a_thermometer() {
344 assert_eq!(
345 parse_wttr("X| |+5°F", WeatherUnit::Fahrenheit, true).as_deref(),
346 Some("X: 🌡️ 5°F")
347 );
348 }
349
350 #[test]
351 fn shorten_location_keeps_retchs_long_standing_display() {
352 assert_eq!(
353 shorten_location("Santa Barbara, California, US"),
354 "Santa Barbara, California"
355 );
356 assert_eq!(
357 shorten_location("London, City of London, United Kingdom"),
358 "London, United Kingdom"
359 );
360 assert_eq!(shorten_location("Paris, France"), "Paris, France");
361 assert_eq!(shorten_location("Somewhere"), "Somewhere");
362 }
363}