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](https://wttr.in), in one HTTPS 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 (~550 ms over HTTPS) replaces both. fastfetch uses the same service over plain
11//! HTTP (~360 ms); HTTPS was chosen deliberately, keeping the request and the approximate
12//! location it reveals encrypted.
13
14/// Temperature unit for weather display.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum WeatherUnit {
17    /// Degrees Fahrenheit (default).
18    #[default]
19    Fahrenheit,
20    /// Degrees Celsius.
21    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    /// wttr.in's unit switch: `u` for USCS (°F), `m` for metric (°C).
36    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
51/// Fetch current weather for `location` and return a formatted string like
52/// `"Santa Barbara, California: ☀️ 67°F"`.
53///
54/// If `location` is `None` or empty, wttr.in locates the caller by IP address. Otherwise
55/// it accepts a city (`London`, `Thousand Oaks, CA`), a US ZIP code, a three-letter airport
56/// code, `lat,lon` coordinates, or `~landmark`, and the location is shown as given.
57/// Returns `None` on any network failure, unknown location (wttr.in answers HTTP 500, which
58/// `curl -f` treats as failure), or unexpected response — weather is best-effort and must
59/// never block or garble the rest of the output.
60pub(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
66/// The one request: location (empty for IP-based), then `%l` location, `%c` condition
67/// emoji and `%t` temperature, `|`-separated, in the requested unit.
68fn 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
76/// Parses `location|emoji |+67°F` into `location: emoji 67°F`.
77///
78/// Strict on purpose: anything that is not exactly three fields with a temperature in the
79/// requested unit is rejected, so an error or rate-limit page served with HTTP 200 can
80/// never be printed as weather.
81fn 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
115/// wttr.in names an IP-derived location `City, Region, Country`. retch has always shown
116/// `City, Region` for the US and `City, Country` elsewhere, so keep that.
117fn 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
133/// Run `curl -sf --max-time 4 <url>` and return stdout on success, `None` on any failure.
134fn 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
145/// Percent-encodes a location for the URL path. Spaces become `+`, which wttr.in accepts.
146///
147/// Non-ASCII characters are encoded as their UTF-8 bytes. Before v0.20.0 this encoded the
148/// Unicode code point instead (`ã` as `%E3`, which is not valid UTF-8), so a name like
149/// `São Paulo` never reached any weather service intact.
150fn 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        // `ã` is U+00E3; its UTF-8 form is C3 A3. The old encoder emitted `%E3`.
181        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    // Fixtures below are verbatim wttr.in responses captured 2026-09-28.
215
216    #[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        // wttr.in echoes the query; a ZIP, airport code or coordinates stay as typed.
244        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        // Three comma-separated parts would be shortened if this were an IP-derived name;
253        // typed by the user, it must survive intact.
254        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        // wttr.in's unknown-location body (it comes with HTTP 500, but must not parse
269        // even if some proxy passed it through as a 200).
270        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}