#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WeatherUnit {
#[default]
Fahrenheit,
Celsius,
}
impl std::str::FromStr for WeatherUnit {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_ascii_lowercase().as_str() {
"celsius" | "c" => Self::Celsius,
_ => Self::Fahrenheit,
})
}
}
impl WeatherUnit {
fn wttr_flag(self) -> &'static str {
match self {
Self::Fahrenheit => "u",
Self::Celsius => "m",
}
}
fn symbol(self) -> &'static str {
match self {
Self::Fahrenheit => "°F",
Self::Celsius => "°C",
}
}
}
pub(crate) fn detect_weather(location: Option<&str>, unit: WeatherUnit) -> Option<String> {
let location = location.map(str::trim).filter(|l| !l.is_empty());
let body = curl_get(&wttr_url(location, unit))?;
parse_wttr(&body, unit, location.is_some())
}
fn wttr_url(location: Option<&str>, unit: WeatherUnit) -> String {
format!(
"http://wttr.in/{}?format=%l|%c|%t&{}",
location.map(url_encode).unwrap_or_default(),
unit.wttr_flag()
)
}
fn parse_wttr(body: &str, unit: WeatherUnit, overridden: bool) -> Option<String> {
let body = body.trim();
if body.chars().any(char::is_control) {
return None;
}
let mut fields = body.split('|');
let (loc, emoji, temp) = (fields.next()?, fields.next()?, fields.next()?);
if fields.next().is_some() {
return None;
}
let loc = loc.trim();
if loc.is_empty() {
return None;
}
let degrees: f64 = temp
.trim()
.strip_suffix(unit.symbol())?
.trim_start_matches('+')
.parse()
.ok()?;
let emoji = match emoji.trim() {
"" => "🌡️",
e => e,
};
let display = if overridden {
loc.to_string()
} else {
shorten_location(loc)
};
Some(format!(
"{}: {} {:.0}{}",
display,
emoji,
degrees,
unit.symbol()
))
}
fn shorten_location(loc: &str) -> String {
let parts: Vec<&str> = loc.split(", ").collect();
match parts.as_slice() {
[city, region, .., country] if is_us(country) => format!("{city}, {region}"),
[city, .., country] if parts.len() >= 2 => format!("{city}, {country}"),
_ => loc.to_string(),
}
}
fn is_us(country: &str) -> bool {
matches!(
country,
"US" | "USA" | "United States" | "United States of America"
)
}
fn curl_get(url: &str) -> Option<String> {
let out = std::process::Command::new("curl")
.args(["-sf", "--max-time", "4", url])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b' ' => out.push('+'),
b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
out.push(b as char)
}
b => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_encode() {
assert_eq!(url_encode("London"), "London");
assert_eq!(url_encode("Thousand Oaks, CA"), "Thousand+Oaks%2C+CA");
assert_eq!(url_encode("New York"), "New+York");
assert_eq!(url_encode("93426"), "93426");
assert_eq!(url_encode("34.42,-119.70"), "34.42%2C-119.70");
assert_eq!(url_encode("~Eiffel Tower"), "~Eiffel+Tower");
}
#[test]
fn url_encode_uses_utf8_bytes_not_code_points() {
assert_eq!(url_encode("São Paulo"), "S%C3%A3o+Paulo");
assert_eq!(url_encode("Zürich"), "Z%C3%BCrich");
}
#[test]
fn wttr_url_carries_location_and_unit() {
assert_eq!(
wttr_url(None, WeatherUnit::Fahrenheit),
"http://wttr.in/?format=%l|%c|%t&u"
);
assert_eq!(
wttr_url(Some("Thousand Oaks, CA"), WeatherUnit::Celsius),
"http://wttr.in/Thousand+Oaks%2C+CA?format=%l|%c|%t&m"
);
}
#[test]
fn rejects_a_reply_carrying_control_characters() {
let f = WeatherUnit::Fahrenheit;
assert_eq!(
parse_wttr("\u{1b}]0;pwned\u{7}X|☀️ |+5°F", f, true),
None,
"OSC title escape in the location"
);
assert_eq!(
parse_wttr("X\u{1b}[2J|☀️ |+5°F", f, true),
None,
"CSI clear-screen in the location"
);
assert_eq!(parse_wttr("X|☀️\u{8}|+5°F", f, true), None, "backspace");
assert_eq!(
parse_wttr("Los Angeles, California,\rUS|☀️ |+82°F", f, false),
None,
"carriage return mid-line"
);
}
#[test]
fn real_weather_emoji_are_not_control_characters() {
for emoji in ["☀️", "☁️", "⛅️", "🌦️", "🌧️", "⛈️", "🌨️", "❄️", "🌫️", "🌩️"]
{
let reply = format!("Los Angeles, California, US|{emoji} |+82°F\n");
assert_eq!(
parse_wttr(&reply, WeatherUnit::Fahrenheit, false),
Some(format!("Los Angeles, California: {emoji} 82°F")),
"{emoji}"
);
}
}
#[test]
fn test_weather_unit_from_str() {
assert_eq!(
"celsius".parse::<WeatherUnit>().unwrap(),
WeatherUnit::Celsius
);
assert_eq!("C".parse::<WeatherUnit>().unwrap(), WeatherUnit::Celsius);
assert_eq!(
"fahrenheit".parse::<WeatherUnit>().unwrap(),
WeatherUnit::Fahrenheit
);
assert_eq!(
"nonsense".parse::<WeatherUnit>().unwrap(),
WeatherUnit::Fahrenheit
);
}
#[test]
fn parses_an_ip_located_us_response() {
assert_eq!(
parse_wttr(
"Newbury Park, California, US|☁️ |+69°F",
WeatherUnit::Fahrenheit,
false
)
.as_deref(),
Some("Newbury Park, California: ☁️ 69°F")
);
}
#[test]
fn parses_celsius_and_negative_temperatures() {
assert_eq!(
parse_wttr("London|☁️ |+15°C", WeatherUnit::Celsius, true).as_deref(),
Some("London: ☁️ 15°C")
);
assert_eq!(
parse_wttr("Nuuk|🌨️ |-7°C\n", WeatherUnit::Celsius, true).as_deref(),
Some("Nuuk: 🌨️ -7°C")
);
}
#[test]
fn an_override_is_shown_as_given() {
assert_eq!(
parse_wttr("93426|☀️ |+58°F", WeatherUnit::Fahrenheit, true).as_deref(),
Some("93426: ☀️ 58°F")
);
assert_eq!(
parse_wttr("34.42,-119.70|☀️ |+63°F", WeatherUnit::Fahrenheit, true).as_deref(),
Some("34.42,-119.70: ☀️ 63°F")
);
assert_eq!(
parse_wttr(
"Paris, Ile-de-France, France|☀️ |+17°C",
WeatherUnit::Celsius,
true
)
.as_deref(),
Some("Paris, Ile-de-France, France: ☀️ 17°C")
);
}
#[test]
fn rejects_anything_that_is_not_a_weather_line() {
let f = WeatherUnit::Fahrenheit;
assert_eq!(
parse_wttr(
"location not found: upstream error: opencage: invalid response",
f,
true
),
None
);
assert_eq!(parse_wttr("", f, false), None);
assert_eq!(parse_wttr("a|b", f, false), None, "too few fields");
assert_eq!(parse_wttr("a|b|+5°F|x", f, false), None, "too many fields");
assert_eq!(parse_wttr("|☀️ |+5°F", f, false), None, "no location");
assert_eq!(parse_wttr("X|☀️ |warm", f, false), None, "no number");
assert_eq!(
parse_wttr("X|☀️ |+5°C", f, false),
None,
"wrong unit for the request"
);
}
#[test]
fn a_missing_emoji_falls_back_to_a_thermometer() {
assert_eq!(
parse_wttr("X| |+5°F", WeatherUnit::Fahrenheit, true).as_deref(),
Some("X: 🌡️ 5°F")
);
}
#[test]
fn shorten_location_keeps_retchs_long_standing_display() {
assert_eq!(
shorten_location("Santa Barbara, California, US"),
"Santa Barbara, California"
);
assert_eq!(
shorten_location("London, City of London, United Kingdom"),
"London, United Kingdom"
);
assert_eq!(shorten_location("Paris, France"), "Paris, France");
assert_eq!(shorten_location("Somewhere"), "Somewhere");
}
}