use serde_json::{json, Map, Value};
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[allow(non_camel_case_types)]
pub struct _WeatherKey {
pub location_query: Option<String>,
pub weather_api_key: String,
}
#[allow(non_snake_case)]
pub fn weather_conditions_codes() -> &'static std::collections::HashMap<u16, Vec<&'static str>> {
static M: OnceLock<std::collections::HashMap<u16, Vec<&'static str>>> = OnceLock::new();
M.get_or_init(|| {
let entries: &[(u16, &str)] = &[
(200, "stormy"),
(201, "stormy"),
(202, "stormy"),
(210, "stormy"),
(211, "stormy"),
(212, "stormy"),
(221, "stormy"),
(230, "stormy"),
(231, "stormy"),
(232, "stormy"),
(300, "rainy"),
(301, "rainy"),
(302, "rainy"),
(310, "rainy"),
(311, "rainy"),
(312, "rainy"),
(313, "rainy"),
(314, "rainy"),
(321, "rainy"),
(500, "rainy"),
(501, "rainy"),
(502, "rainy"),
(503, "rainy"),
(504, "rainy"),
(511, "snowy"),
(520, "rainy"),
(521, "rainy"),
(522, "rainy"),
(531, "rainy"),
(600, "snowy"),
(601, "snowy"),
(602, "snowy"),
(611, "snowy"),
(612, "snowy"),
(613, "snowy"),
(615, "snowy"),
(616, "snowy"),
(620, "snowy"),
(621, "snowy"),
(622, "snowy"),
(701, "foggy"),
(711, "foggy"),
(721, "foggy"),
(731, "foggy"),
(741, "foggy"),
(751, "foggy"),
(761, "foggy"),
(762, "foggy"),
(771, "foggy"),
(781, "foggy"),
(800, "sunny"),
(801, "cloudy"),
(802, "cloudy"),
(803, "cloudy"),
(804, "cloudy"),
];
let mut m = std::collections::HashMap::new();
for (code, name) in entries {
m.insert(*code, vec![*name]);
}
m
})
}
#[allow(non_snake_case)]
pub fn weather_conditions_icons() -> &'static std::collections::HashMap<&'static str, &'static str>
{
static M: OnceLock<std::collections::HashMap<&'static str, &'static str>> = OnceLock::new();
M.get_or_init(|| {
let mut m = std::collections::HashMap::new();
m.insert("day", "DAY");
m.insert("blustery", "WIND");
m.insert("rainy", "RAIN");
m.insert("cloudy", "CLOUDS");
m.insert("snowy", "SNOW");
m.insert("stormy", "STORM");
m.insert("foggy", "FOG");
m.insert("sunny", "SUN");
m.insert("night", "NIGHT");
m.insert("windy", "WINDY");
m.insert("not_available", "NA");
m.insert("unknown", "UKN");
m
})
}
pub fn temp_conversions(unit: &str, temp_k: f64) -> f64 {
match unit {
"C" => temp_k - 273.15,
"F" => (temp_k * 9.0 / 5.0) - 459.67,
"K" => temp_k,
_ => temp_k,
}
}
pub fn temp_units(unit: &str) -> &'static str {
match unit {
"C" => "°C",
"F" => "°F",
"K" => "K",
_ => "",
}
}
pub const WEATHER_API_KEY: &str = "fbc9549d91a5e4b26c15be0dbdac3460";
pub const WEATHER_INTERVAL: u32 = 600;
pub fn key(location_query: Option<String>, weather_api_key: Option<String>) -> _WeatherKey {
weather_key(location_query, weather_api_key)
}
pub fn weather_key(location_query: Option<String>, weather_api_key: Option<String>) -> _WeatherKey {
let api = weather_api_key.unwrap_or_else(|| WEATHER_API_KEY.to_string());
_WeatherKey {
location_query,
weather_api_key: api,
}
}
pub fn get_request_url(weather_key: &_WeatherKey) -> String {
let mut url = String::from("https://api.openweathermap.org/data/2.5/weather?");
url.push_str(&format!("appid={}", weather_key.weather_api_key));
if let Some(q) = &weather_key.location_query {
url.push_str(&format!("&q={}", q));
}
url
}
pub fn compute_state(weather_key: &_WeatherKey) -> Option<(f64, Vec<&'static str>)> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
type CacheEntry = (Instant, _WeatherKey, Option<(f64, Vec<&'static str>)>);
static CACHE: std::sync::OnceLock<Mutex<Option<CacheEntry>>> = std::sync::OnceLock::new();
let cell = CACHE.get_or_init(|| Mutex::new(None));
let ttl = Duration::from_secs(600);
if let Ok(guard) = cell.lock() {
if let Some((t, k, v)) = guard.as_ref() {
if t.elapsed() < ttl && k == weather_key && v.is_some() {
crate::extensions::diag_log::log(&format!(
"weather cache HIT age={:.1}s",
t.elapsed().as_secs_f64()
));
return v.clone();
}
}
}
let t0 = Instant::now();
use crate::extensions::wthr_extensions::{http_get, ip_geolocate, urlencode};
let mut url = String::from("https://api.openweathermap.org/data/2.5/weather?appid=");
url.push_str(&weather_key.weather_api_key);
match &weather_key.location_query {
Some(q) => {
url.push_str("&q=");
url.push_str(&urlencode(q));
}
None => {
let (lat, lon) = ip_geolocate()?;
url.push_str(&format!("&lat={}&lon={}", lat, lon));
}
}
let raw = http_get(&url)?;
let parsed = compute_state_from_response(&raw);
crate::extensions::diag_log::log(&format!(
"weather cache MISS fetch_ms={} parsed_some={}",
t0.elapsed().as_millis(),
parsed.is_some()
));
if parsed.is_some() {
if let Ok(mut guard) = cell.lock() {
*guard = Some((Instant::now(), weather_key.clone(), parsed.clone()));
}
}
parsed
}
pub fn compute_state_from_response(raw_response: &str) -> Option<(f64, Vec<&'static str>)> {
if raw_response.is_empty() {
return None;
}
let response: Value = serde_json::from_str(raw_response).ok()?;
let weather_array = response.get("weather")?.as_array()?;
let condition = weather_array.first()?;
let condition_code = condition.get("id")?.as_u64()? as u16;
let temp = response.get("main")?.get("temp")?.as_f64()?;
let icon_names = weather_conditions_codes()
.get(&condition_code)
.cloned()
.unwrap_or_else(|| vec!["unknown"]);
Some((temp, icon_names))
}
pub fn pick_icon(icon_names: &[&str], icons: Option<&Map<String, Value>>) -> String {
if let Some(icons_map) = icons {
for name in icon_names {
if let Some(icon) = icons_map.get(*name).and_then(|v| v.as_str()) {
return icon.to_string();
}
}
}
let last = icon_names.last().copied().unwrap_or("unknown");
weather_conditions_icons()
.get(last)
.copied()
.unwrap_or("UKN")
.to_string()
}
pub fn temp_gradient_level(converted_temp: f64, temp_coldest: f64, temp_hottest: f64) -> f64 {
if converted_temp <= temp_coldest {
0.0
} else if converted_temp >= temp_hottest {
100.0
} else {
(converted_temp - temp_coldest) * 100.0 / (temp_hottest - temp_coldest)
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_one(
weather: Option<(f64, Vec<&'static str>)>,
icons: Option<&Map<String, Value>>,
unit: &str,
temp_format: Option<&str>,
temp_coldest: f64,
temp_hottest: f64,
) -> Option<Vec<Value>> {
let (temp_k, icon_names) = weather?;
let icon = pick_icon(&icon_names, icons);
let default_format = format!("{{temp:.0f}}{}", temp_units(unit));
let fmt = temp_format.unwrap_or(&default_format);
let converted_temp = temp_conversions(unit, temp_k);
let gradient_level = temp_gradient_level(converted_temp, temp_coldest, temp_hottest);
let mut groups: Vec<String> = icon_names
.iter()
.map(|n| format!("weather_condition_{}", n))
.collect();
groups.push("weather_conditions".to_string());
groups.push("weather".to_string());
let temp_str = if fmt.contains("{temp:.0f}") {
fmt.replace("{temp:.0f}", &format!("{:.0}", converted_temp))
} else {
fmt.replace("{temp}", &format!("{}", converted_temp))
};
Some(vec![
json!({
"contents": format!("{} ", icon),
"highlight_groups": groups,
"divider_highlight_group": "background:divider",
}),
json!({
"contents": temp_str,
"highlight_groups": ["weather_temp_gradient", "weather_temp", "weather"],
"divider_highlight_group": "background:divider",
"gradient_level": gradient_level,
}),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weather_api_key_matches_upstream() {
assert_eq!(WEATHER_API_KEY, "fbc9549d91a5e4b26c15be0dbdac3460");
}
#[test]
fn weather_interval_matches_upstream() {
assert_eq!(WEATHER_INTERVAL, 600);
}
#[test]
fn weather_conditions_codes_maps_thunderstorm_to_stormy() {
let table = weather_conditions_codes();
assert_eq!(table.get(&200), Some(&vec!["stormy"]));
assert_eq!(table.get(&232), Some(&vec!["stormy"]));
}
#[test]
fn weather_conditions_codes_maps_rain_to_rainy() {
let table = weather_conditions_codes();
assert_eq!(table.get(&500), Some(&vec!["rainy"]));
assert_eq!(table.get(&501), Some(&vec!["rainy"]));
}
#[test]
fn weather_conditions_codes_maps_511_to_snowy() {
let table = weather_conditions_codes();
assert_eq!(table.get(&511), Some(&vec!["snowy"]));
}
#[test]
fn weather_conditions_codes_maps_clear_sky_to_sunny() {
let table = weather_conditions_codes();
assert_eq!(table.get(&800), Some(&vec!["sunny"]));
}
#[test]
fn weather_conditions_codes_maps_clouds_to_cloudy() {
let table = weather_conditions_codes();
assert_eq!(table.get(&801), Some(&vec!["cloudy"]));
assert_eq!(table.get(&804), Some(&vec!["cloudy"]));
}
#[test]
fn weather_conditions_icons_table_matches_upstream() {
let icons = weather_conditions_icons();
assert_eq!(icons.get("sunny"), Some(&"SUN"));
assert_eq!(icons.get("rainy"), Some(&"RAIN"));
assert_eq!(icons.get("not_available"), Some(&"NA"));
assert_eq!(icons.get("unknown"), Some(&"UKN"));
}
#[test]
fn temp_conversions_kelvin_to_celsius() {
let r = temp_conversions("C", 273.15);
assert!((r - 0.0).abs() < 1e-9);
}
#[test]
fn temp_conversions_kelvin_to_fahrenheit() {
let r = temp_conversions("F", 273.15);
assert!((r - 32.0).abs() < 1e-1);
}
#[test]
fn temp_conversions_kelvin_to_kelvin_is_passthrough() {
let r = temp_conversions("K", 273.15);
assert!((r - 273.15).abs() < 1e-9);
}
#[test]
fn temp_units_matches_upstream() {
assert_eq!(temp_units("C"), "°C");
assert_eq!(temp_units("F"), "°F");
assert_eq!(temp_units("K"), "K");
}
#[test]
fn weather_key_uses_default_api_key_when_unset() {
let k = weather_key(None, None);
assert_eq!(k.weather_api_key, WEATHER_API_KEY);
assert!(k.location_query.is_none());
}
#[test]
fn weather_key_respects_custom_api_key() {
let k = weather_key(Some("oslo".to_string()), Some("custom-key".to_string()));
assert_eq!(k.weather_api_key, "custom-key");
assert_eq!(k.location_query, Some("oslo".to_string()));
}
#[test]
fn compute_state_from_response_parses_owm_payload() {
let payload = r#"{"weather":[{"id":800}],"main":{"temp":295.15}}"#;
let (temp, icons) = compute_state_from_response(payload).unwrap();
assert!((temp - 295.15).abs() < 1e-9);
assert_eq!(icons, vec!["sunny"]);
}
#[test]
fn compute_state_from_response_unknown_code_returns_unknown_icon() {
let payload = r#"{"weather":[{"id":99999}],"main":{"temp":295.15}}"#;
let (_temp, icons) = compute_state_from_response(payload).unwrap();
assert_eq!(icons, vec!["unknown"]);
}
#[test]
fn compute_state_from_response_empty_returns_none() {
assert!(compute_state_from_response("").is_none());
}
#[test]
fn compute_state_from_response_malformed_returns_none() {
assert!(compute_state_from_response("not json").is_none());
assert!(compute_state_from_response("{}").is_none());
assert!(compute_state_from_response(r#"{"weather":[]}"#).is_none());
}
#[test]
fn pick_icon_uses_override_when_present() {
let mut overrides = Map::new();
overrides.insert("sunny".to_string(), Value::String("☀".into()));
let icon = pick_icon(&["sunny"], Some(&overrides));
assert_eq!(icon, "☀");
}
#[test]
fn pick_icon_falls_back_to_default_table() {
let icon = pick_icon(&["sunny"], None);
assert_eq!(icon, "SUN");
}
#[test]
fn pick_icon_unknown_returns_ukn() {
let icon = pick_icon(&["unknown"], None);
assert_eq!(icon, "UKN");
}
#[test]
fn temp_gradient_level_below_coldest_returns_zero() {
assert_eq!(temp_gradient_level(-40.0, -30.0, 40.0), 0.0);
assert_eq!(temp_gradient_level(-30.0, -30.0, 40.0), 0.0);
}
#[test]
fn temp_gradient_level_above_hottest_returns_100() {
assert_eq!(temp_gradient_level(50.0, -30.0, 40.0), 100.0);
assert_eq!(temp_gradient_level(40.0, -30.0, 40.0), 100.0);
}
#[test]
fn temp_gradient_level_midpoint_returns_50() {
let r = temp_gradient_level(5.0, -30.0, 40.0);
assert!((r - 50.0).abs() < 1e-9);
}
#[test]
fn render_one_no_weather_returns_none() {
let r = render_one(None, None, "C", None, -30.0, 40.0);
assert!(r.is_none());
}
#[test]
fn render_one_emits_two_segments() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
assert_eq!(r.len(), 2);
}
#[test]
fn render_one_icon_segment_appends_trailing_space() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
let contents = r[0]["contents"].as_str().unwrap();
assert!(contents.ends_with(' '));
assert_eq!(contents, "SUN ");
}
#[test]
fn render_one_temp_segment_emits_gradient_level() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
let level = r[1]["gradient_level"].as_f64().unwrap();
assert!((level - 74.285_714).abs() < 1e-3);
}
#[test]
fn render_one_temp_segment_renders_default_format() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
let temp_contents = r[1]["contents"].as_str().unwrap();
assert_eq!(temp_contents, "22°C");
}
#[test]
fn render_one_custom_temp_format() {
let r = render_one(
Some((295.15, vec!["sunny"])),
None,
"C",
Some("temp: {temp:.0f}"),
-30.0,
40.0,
)
.unwrap();
assert_eq!(r[1]["contents"], "temp: 22");
}
#[test]
fn render_one_emits_weather_condition_groups() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
let groups = r[0]["highlight_groups"].as_array().unwrap();
assert_eq!(groups[0], "weather_condition_sunny");
assert_eq!(groups[1], "weather_conditions");
assert_eq!(groups[2], "weather");
}
#[test]
fn render_one_temp_segment_groups_match_upstream() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
let groups = r[1]["highlight_groups"].as_array().unwrap();
assert_eq!(groups[0], "weather_temp_gradient");
assert_eq!(groups[1], "weather_temp");
assert_eq!(groups[2], "weather");
}
#[test]
fn render_one_uses_custom_icon_override() {
let mut icons = Map::new();
icons.insert("sunny".to_string(), Value::String("☀".into()));
let r = render_one(
Some((295.15, vec!["sunny"])),
Some(&icons),
"C",
None,
-30.0,
40.0,
)
.unwrap();
assert_eq!(r[0]["contents"], "☀ ");
}
#[test]
fn render_one_emits_background_divider_on_both() {
let r = render_one(Some((295.15, vec!["sunny"])), None, "C", None, -30.0, 40.0).unwrap();
assert_eq!(r[0]["divider_highlight_group"], "background:divider");
assert_eq!(r[1]["divider_highlight_group"], "background:divider");
}
}