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 [wttr.in](http://wttr.in), in one plain-HTTP request.
5//!
6//! Until v0.20.0 this took two requests in sequence: ipinfo.io to turn the caller's IP into
7//! coordinates, then Open-Meteo for the forecast. Both weather hosts sit in Germany, so from
8//! the US each round trip was ~180 ms and the pair cost ~870 ms — most of `--full`'s runtime.
9//! wttr.in geolocates the caller itself and answers a compact custom format, so a single
10//! request replaces both.
11//!
12//! **Plain HTTP since v0.20.10**, as fastfetch does. v0.20.0–v0.20.9 used HTTPS, and the TLS
13//! 1.3 handshake costs one extra round trip to a server ~190 ms away: 570 vs 373 ms median
14//! on arrakis, which alone kept `--full` behind fastfetch. The trade-off is that the request,
15//! and the approximate location in the reply, travel unencrypted. Because anyone on the path
16//! can also rewrite the reply, `parse_wttr` rejects any response containing a control
17//! character, so a tampered reply cannot put terminal escape sequences on the screen.
18
19/// Temperature unit for weather display.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum WeatherUnit {
22    /// Degrees Fahrenheit (default).
23    #[default]
24    Fahrenheit,
25    /// Degrees Celsius.
26    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    /// wttr.in's unit switch: `u` for USCS (°F), `m` for metric (°C).
41    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
56/// Fetch current weather for `location` and return a formatted string like
57/// `"Santa Barbara, California: ☀️ 67°F"`.
58///
59/// If `location` is `None` or empty, wttr.in locates the caller by IP address. Otherwise
60/// it accepts a city (`London`, `Thousand Oaks, CA`), a US ZIP code, a three-letter airport
61/// code, `lat,lon` coordinates, or `~landmark`, and the location is shown as given.
62/// Returns `None` on any network failure, unknown location (wttr.in answers HTTP 500, which
63/// `curl -f` treats as failure), or unexpected response — weather is best-effort and must
64/// never block or garble the rest of the output.
65pub(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
71/// The one request: location (empty for IP-based), then `%l` location, `%c` condition
72/// emoji and `%t` temperature, `|`-separated, in the requested unit.
73fn 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
81/// Parses `location|emoji |+67°F` into `location: emoji 67°F`.
82///
83/// Strict on purpose: anything that is not exactly three fields with a temperature in the
84/// requested unit is rejected, so an error or rate-limit page served with HTTP 200 can
85/// never be printed as weather.
86///
87/// A body containing any control character is rejected whole. The request is plain HTTP, so
88/// the reply can be rewritten in transit, and the location field is printed verbatim: an
89/// `ESC` there would reach the terminal. Real replies never contain one (the emoji are a
90/// symbol plus U+FE0F, a combining mark), and a reply that does has been tampered with, so
91/// none of it is trusted rather than stripping the escapes and showing the rest.
92fn 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
130/// wttr.in names an IP-derived location `City, Region, Country`. retch has always shown
131/// `City, Region` for the US and `City, Country` elsewhere, so keep that.
132fn 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
148/// Run `curl -sf --max-time 4 <url>` and return stdout on success, `None` on any failure.
149fn 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
160/// Percent-encodes a location for the URL path. Spaces become `+`, which wttr.in accepts.
161///
162/// Non-ASCII characters are encoded as their UTF-8 bytes. Before v0.20.0 this encoded the
163/// Unicode code point instead (`ã` as `%E3`, which is not valid UTF-8), so a name like
164/// `São Paulo` never reached any weather service intact.
165fn 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        // `ã` is U+00E3; its UTF-8 form is C3 A3. The old encoder emitted `%E3`.
196        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        // Plain HTTP can be rewritten in transit, and the location is printed verbatim.
215        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        // Each is a symbol plus U+FE0F (a combining mark); none may trip the control check.
237        // Verbatim from wttr.in replies: the trailing newline is trimmed before the check.
238        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    // Fixtures below are verbatim wttr.in responses captured 2026-09-28.
267
268    #[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        // wttr.in echoes the query; a ZIP, airport code or coordinates stay as typed.
296        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        // Three comma-separated parts would be shortened if this were an IP-derived name;
305        // typed by the user, it must survive intact.
306        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        // wttr.in's unknown-location body (it comes with HTTP 500, but must not parse
321        // even if some proxy passed it through as a 200).
322        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}