1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum WeatherUnit {
17 #[default]
19 Fahrenheit,
20 Celsius,
22}
23
24impl std::str::FromStr for WeatherUnit {
25 type Err = std::convert::Infallible;
26 fn from_str(s: &str) -> Result<Self, Self::Err> {
27 Ok(match s.to_ascii_lowercase().as_str() {
28 "celsius" | "c" => Self::Celsius,
29 _ => Self::Fahrenheit,
30 })
31 }
32}
33
34impl WeatherUnit {
35 fn wttr_flag(self) -> &'static str {
37 match self {
38 Self::Fahrenheit => "u",
39 Self::Celsius => "m",
40 }
41 }
42
43 fn symbol(self) -> &'static str {
44 match self {
45 Self::Fahrenheit => "°F",
46 Self::Celsius => "°C",
47 }
48 }
49}
50
51pub(crate) fn detect_weather(location: Option<&str>, unit: WeatherUnit) -> Option<String> {
61 let location = location.map(str::trim).filter(|l| !l.is_empty());
62 let body = curl_get(&wttr_url(location, unit))?;
63 parse_wttr(&body, unit, location.is_some())
64}
65
66fn wttr_url(location: Option<&str>, unit: WeatherUnit) -> String {
69 format!(
70 "https://wttr.in/{}?format=%l|%c|%t&{}",
71 location.map(url_encode).unwrap_or_default(),
72 unit.wttr_flag()
73 )
74}
75
76fn parse_wttr(body: &str, unit: WeatherUnit, overridden: bool) -> Option<String> {
82 let mut fields = body.trim().split('|');
83 let (loc, emoji, temp) = (fields.next()?, fields.next()?, fields.next()?);
84 if fields.next().is_some() {
85 return None;
86 }
87 let loc = loc.trim();
88 if loc.is_empty() {
89 return None;
90 }
91 let degrees: f64 = temp
92 .trim()
93 .strip_suffix(unit.symbol())?
94 .trim_start_matches('+')
95 .parse()
96 .ok()?;
97 let emoji = match emoji.trim() {
98 "" => "🌡️",
99 e => e,
100 };
101 let display = if overridden {
102 loc.to_string()
103 } else {
104 shorten_location(loc)
105 };
106 Some(format!(
107 "{}: {} {:.0}{}",
108 display,
109 emoji,
110 degrees,
111 unit.symbol()
112 ))
113}
114
115fn shorten_location(loc: &str) -> String {
118 let parts: Vec<&str> = loc.split(", ").collect();
119 match parts.as_slice() {
120 [city, region, .., country] if is_us(country) => format!("{city}, {region}"),
121 [city, .., country] if parts.len() >= 2 => format!("{city}, {country}"),
122 _ => loc.to_string(),
123 }
124}
125
126fn is_us(country: &str) -> bool {
127 matches!(
128 country,
129 "US" | "USA" | "United States" | "United States of America"
130 )
131}
132
133fn curl_get(url: &str) -> Option<String> {
135 let out = std::process::Command::new("curl")
136 .args(["-sf", "--max-time", "4", url])
137 .output()
138 .ok()?;
139 if !out.status.success() {
140 return None;
141 }
142 String::from_utf8(out.stdout).ok()
143}
144
145fn url_encode(s: &str) -> String {
151 let mut out = String::with_capacity(s.len());
152 for b in s.bytes() {
153 match b {
154 b' ' => out.push('+'),
155 b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
156 out.push(b as char)
157 }
158 b => out.push_str(&format!("%{b:02X}")),
159 }
160 }
161 out
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_url_encode() {
170 assert_eq!(url_encode("London"), "London");
171 assert_eq!(url_encode("Thousand Oaks, CA"), "Thousand+Oaks%2C+CA");
172 assert_eq!(url_encode("New York"), "New+York");
173 assert_eq!(url_encode("93426"), "93426");
174 assert_eq!(url_encode("34.42,-119.70"), "34.42%2C-119.70");
175 assert_eq!(url_encode("~Eiffel Tower"), "~Eiffel+Tower");
176 }
177
178 #[test]
179 fn url_encode_uses_utf8_bytes_not_code_points() {
180 assert_eq!(url_encode("São Paulo"), "S%C3%A3o+Paulo");
182 assert_eq!(url_encode("Zürich"), "Z%C3%BCrich");
183 }
184
185 #[test]
186 fn wttr_url_carries_location_and_unit() {
187 assert_eq!(
188 wttr_url(None, WeatherUnit::Fahrenheit),
189 "https://wttr.in/?format=%l|%c|%t&u"
190 );
191 assert_eq!(
192 wttr_url(Some("Thousand Oaks, CA"), WeatherUnit::Celsius),
193 "https://wttr.in/Thousand+Oaks%2C+CA?format=%l|%c|%t&m"
194 );
195 }
196
197 #[test]
198 fn test_weather_unit_from_str() {
199 assert_eq!(
200 "celsius".parse::<WeatherUnit>().unwrap(),
201 WeatherUnit::Celsius
202 );
203 assert_eq!("C".parse::<WeatherUnit>().unwrap(), WeatherUnit::Celsius);
204 assert_eq!(
205 "fahrenheit".parse::<WeatherUnit>().unwrap(),
206 WeatherUnit::Fahrenheit
207 );
208 assert_eq!(
209 "nonsense".parse::<WeatherUnit>().unwrap(),
210 WeatherUnit::Fahrenheit
211 );
212 }
213
214 #[test]
217 fn parses_an_ip_located_us_response() {
218 assert_eq!(
219 parse_wttr(
220 "Newbury Park, California, US|☁️ |+69°F",
221 WeatherUnit::Fahrenheit,
222 false
223 )
224 .as_deref(),
225 Some("Newbury Park, California: ☁️ 69°F")
226 );
227 }
228
229 #[test]
230 fn parses_celsius_and_negative_temperatures() {
231 assert_eq!(
232 parse_wttr("London|☁️ |+15°C", WeatherUnit::Celsius, true).as_deref(),
233 Some("London: ☁️ 15°C")
234 );
235 assert_eq!(
236 parse_wttr("Nuuk|🌨️ |-7°C\n", WeatherUnit::Celsius, true).as_deref(),
237 Some("Nuuk: 🌨️ -7°C")
238 );
239 }
240
241 #[test]
242 fn an_override_is_shown_as_given() {
243 assert_eq!(
245 parse_wttr("93426|☀️ |+58°F", WeatherUnit::Fahrenheit, true).as_deref(),
246 Some("93426: ☀️ 58°F")
247 );
248 assert_eq!(
249 parse_wttr("34.42,-119.70|☀️ |+63°F", WeatherUnit::Fahrenheit, true).as_deref(),
250 Some("34.42,-119.70: ☀️ 63°F")
251 );
252 assert_eq!(
255 parse_wttr(
256 "Paris, Ile-de-France, France|☀️ |+17°C",
257 WeatherUnit::Celsius,
258 true
259 )
260 .as_deref(),
261 Some("Paris, Ile-de-France, France: ☀️ 17°C")
262 );
263 }
264
265 #[test]
266 fn rejects_anything_that_is_not_a_weather_line() {
267 let f = WeatherUnit::Fahrenheit;
268 assert_eq!(
271 parse_wttr(
272 "location not found: upstream error: opencage: invalid response",
273 f,
274 true
275 ),
276 None
277 );
278 assert_eq!(parse_wttr("", f, false), None);
279 assert_eq!(parse_wttr("a|b", f, false), None, "too few fields");
280 assert_eq!(parse_wttr("a|b|+5°F|x", f, false), None, "too many fields");
281 assert_eq!(parse_wttr("|☀️ |+5°F", f, false), None, "no location");
282 assert_eq!(parse_wttr("X|☀️ |warm", f, false), None, "no number");
283 assert_eq!(
284 parse_wttr("X|☀️ |+5°C", f, false),
285 None,
286 "wrong unit for the request"
287 );
288 }
289
290 #[test]
291 fn a_missing_emoji_falls_back_to_a_thermometer() {
292 assert_eq!(
293 parse_wttr("X| |+5°F", WeatherUnit::Fahrenheit, true).as_deref(),
294 Some("X: 🌡️ 5°F")
295 );
296 }
297
298 #[test]
299 fn shorten_location_keeps_retchs_long_standing_display() {
300 assert_eq!(
301 shorten_location("Santa Barbara, California, US"),
302 "Santa Barbara, California"
303 );
304 assert_eq!(
305 shorten_location("London, City of London, United Kingdom"),
306 "London, United Kingdom"
307 );
308 assert_eq!(shorten_location("Paris, France"), "Paris, France");
309 assert_eq!(shorten_location("Somewhere"), "Somewhere");
310 }
311}