From caa730f490a3ecdc7ec60e3e3f5e0aba39ee5d33 Mon Sep 17 00:00:00 2001 From: Ilia Agafonov Date: Wed, 6 May 2026 03:34:17 +0700 Subject: [PATCH 1/3] :sparkles: Add day/night weather icons - Add `is_day` to Weather and bundle location_name/lat/lon into Location - Add night-variant ASCII art and emoji for Clear and PartlyCloudy - Populate `is_day` natively from open_meteo, weather_api, yr, open_weather_map, and weather_bit - Add NOAA solar-altitude fallback (`weather/sun.rs`) for tomorrow_io and world_weather_online - Introduce `weather/enrich.rs` post-provider wrapper that fills missing `is_day` and `uv_index`; lifts OpenUV calls out of individual providers - Fix world_weather_online: add `includelocation=yes`, parse `nearest_area` for coordinates, share name shortener with tomorrow_io, trim trailing space in description --- src/app.rs | 7 +- src/config/file.rs | 5 + src/display/formatter.rs | 54 +++++-- src/display/icons.rs | 124 ++++++++++++++-- src/models.rs | 13 +- src/tests.rs | 5 +- src/weather/enrich.rs | 111 ++++++++++++++ src/weather/mod.rs | 4 + src/weather/open_meteo.rs | 32 +++-- src/weather/open_weather_map.rs | 55 +++++-- src/weather/sun.rs | 152 ++++++++++++++++++++ src/weather/tomorrow_io.rs | 39 +++-- src/weather/tools.rs | 60 ++++++++ src/weather/weather_api.rs | 28 +++- src/weather/weather_bit.rs | 56 +++++++- src/weather/world_weather_online.rs | 75 ++++++++-- src/weather/yr.rs | 85 +++++++---- tests/data/open_meteo_weather_response.json | 3 +- tests/data/wwo_response.json | 64 +++++++++ 19 files changed, 847 insertions(+), 125 deletions(-) create mode 100644 src/weather/enrich.rs create mode 100644 src/weather/sun.rs create mode 100644 tests/data/wwo_response.json diff --git a/src/app.rs b/src/app.rs index a26664c..7abc3de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,7 @@ use crate::display::formatter::WeatherFormatter; use crate::errors::RustormyError; use crate::live::run as run_live; use crate::models::{Provider, Weather}; -use crate::weather::{GetWeather, GetWeatherProvider}; +use crate::weather::{GetWeather, GetWeatherProvider, enrich}; use reqwest::blocking::Client; use std::time::Duration; @@ -42,7 +42,10 @@ impl App { pub fn fetch_with_fallback(&mut self) -> Result { loop { match self.provider.get_weather(&self.client, &self.config) { - Ok(weather) => return Ok(weather), + Ok(mut weather) => { + enrich(&mut weather, &self.client, &self.config)?; + return Ok(weather); + } Err(error) => match error { RustormyError::ApiReturnedError(_) | RustormyError::HttpRequestFailed(_) => { let p: Provider = (&self.provider).into(); diff --git a/src/config/file.rs b/src/config/file.rs index 7d21dac..2aa4e16 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -310,6 +310,11 @@ impl Config { &self.api_keys } + #[cfg(test)] + pub fn api_keys_mut(&mut self) -> &mut ApiKeys { + &mut self.api_keys + } + pub fn city(&self) -> Option<&str> { self.city.as_deref() } diff --git a/src/display/formatter.rs b/src/display/formatter.rs index 496302f..7eba755 100644 --- a/src/display/formatter.rs +++ b/src/display/formatter.rs @@ -110,7 +110,7 @@ impl WeatherFormatter { fn format_one_line(&self, weather: &Weather) -> String { let color_theme = &self.config.color_theme; let (temp_unit, wind_unit, _) = unit_strings(self.config.units, self.config.language); - let emoji = weather.icon.emoji(); + let emoji = weather.icon.emoji(weather.is_day.unwrap_or(true)); let mut temperature = format!("{:.1}{}", weather.temperature, temp_unit); if self.config.use_colors { temperature = colored_text(temperature, color_theme.temperature); @@ -130,9 +130,9 @@ impl WeatherFormatter { if self.config.show_city_name { let location = if self.config.use_colors { - colored_text(&weather.location_name, color_theme.location) + colored_text(&weather.location.name, color_theme.location) } else { - weather.location_name.clone() + weather.location.name.clone() }; format!("{location}: {value}") } else { @@ -148,10 +148,11 @@ impl WeatherFormatter { self.config.language, ); let (temp_unit, wind_unit, precip_unit) = unit_strings(self.config.units, lang); + let is_day = weather.is_day.unwrap_or(true); let icon = if colors { - weather.icon.colored_icon() + weather.icon.colored_icon(is_day) } else { - weather.icon.icon() + weather.icon.icon(is_day) }; let color_theme = &self.config.color_theme; @@ -161,7 +162,7 @@ impl WeatherFormatter { output.push(make_line( icon[0], "Location", - &weather.location_name, + &weather.location.name, color_theme.location, &self.config, )); @@ -251,7 +252,7 @@ impl WeatherFormatter { mod tests { use super::*; use crate::config::Config; - use crate::models::{Language, TextMode, Units, WeatherConditionIcon}; + use crate::models::{Language, Location, TextMode, Units, WeatherConditionIcon}; fn sample_weather() -> Weather { Weather { @@ -264,9 +265,10 @@ mod tests { wind_speed: 5.0, wind_direction: 90, uv_index: None, + is_day: Some(true), description: "Partly cloudy".to_string(), icon: WeatherConditionIcon::PartlyCloudy, - location_name: "Test City".to_string(), + location: Location::new("Test City".to_string(), 0.0, 0.0), } } @@ -762,4 +764,40 @@ mod tests { lines[1] ); } + + #[test] + fn test_night_clear_uses_moon_emoji() { + let mut weather = sample_weather(); + weather.icon = WeatherConditionIcon::Clear; + weather.is_day = Some(false); + let mut config = Config::default(); + config.set_format(FormatterConfig { + text_mode: TextMode::OneLine, + ..Default::default() + }); + let formatter = WeatherFormatter::new(&config); + let line = formatter.format_one_line(&weather); + assert!( + line.contains("πŸŒ™"), + "expected moon emoji in night clear output, got '{line}'" + ); + } + + #[test] + fn test_day_clear_uses_sun_emoji() { + let mut weather = sample_weather(); + weather.icon = WeatherConditionIcon::Clear; + weather.is_day = Some(true); + let mut config = Config::default(); + config.set_format(FormatterConfig { + text_mode: TextMode::OneLine, + ..Default::default() + }); + let formatter = WeatherFormatter::new(&config); + let line = formatter.format_one_line(&weather); + assert!( + line.contains("β˜€"), + "expected sun emoji in day clear output, got '{line}'" + ); + } } diff --git a/src/display/icons.rs b/src/display/icons.rs index bb0742c..89dabb5 100644 --- a/src/display/icons.rs +++ b/src/display/icons.rs @@ -3,7 +3,8 @@ use crate::models::WeatherConditionIcon; pub type Icon = [&'static str; 7]; impl WeatherConditionIcon { - pub fn icon(self) -> Icon { + #[allow(clippy::too_many_lines)] + pub fn icon(self, is_day: bool) -> Icon { match self { Self::Unknown => [ " ", @@ -14,7 +15,7 @@ impl WeatherConditionIcon { " β€’ ", " ", ], - Self::Clear => [ + Self::Clear if is_day => [ " ", " \\ / ", " .-. ", @@ -23,7 +24,16 @@ impl WeatherConditionIcon { " / \\ ", " ", ], - Self::PartlyCloudy => [ + Self::Clear => [ + " . * ", + " .-. ", + " . ( ) ", + " `-’ ", + " * . ", + " ", + " ", + ], + Self::PartlyCloudy if is_day => [ " ", " \\ / ", " _ /\"\".-. ", @@ -32,6 +42,15 @@ impl WeatherConditionIcon { " ", " ", ], + Self::PartlyCloudy => [ + " ", + " . * ", + " _ .-. ", + " ( ). ", + " *(___(__) ", + " ", + " ", + ], Self::Cloudy => [ " ", " ", @@ -98,7 +117,8 @@ impl WeatherConditionIcon { } } - pub fn colored_icon(self) -> Icon { + #[allow(clippy::too_many_lines)] + pub fn colored_icon(self, is_day: bool) -> Icon { match self { Self::Unknown => [ " ", @@ -109,7 +129,7 @@ impl WeatherConditionIcon { " β€’ ", " ", ], - Self::Clear => [ + Self::Clear if is_day => [ " ", "\x1b[38;5;226m \\ / \x1b[0m", "\x1b[38;5;226m .-. \x1b[0m", @@ -118,7 +138,16 @@ impl WeatherConditionIcon { "\x1b[38;5;226m / \\ \x1b[0m", " ", ], - Self::PartlyCloudy => [ + Self::Clear => [ + "\x1b[38;5;111;1m . * \x1b[0m", + "\x1b[38;5;230;1m .-. \x1b[0m", + "\x1b[38;5;111;1m . \x1b[38;5;230;1m( ) \x1b[0m", + "\x1b[38;5;230;1m `-’ \x1b[0m", + "\x1b[38;5;111;1m * . \x1b[0m", + " ", + " ", + ], + Self::PartlyCloudy if is_day => [ " ", "\x1b[38;5;226m \\ /\x1b[0m ", "\x1b[38;5;226m _ /\"\"\x1b[38;5;250m.-. \x1b[0m", @@ -127,6 +156,15 @@ impl WeatherConditionIcon { " ", " ", ], + Self::PartlyCloudy => [ + " ", + "\x1b[38;5;111;1m . * \x1b[0m", + "\x1b[38;5;230;1m \x1b[38;5;250m.-. \x1b[0m", + "\x1b[38;5;250m ( ). \x1b[0m", + "\x1b[38;5;111;1m *\x1b[38;5;250m(___(__) \x1b[0m", + " ", + " ", + ], Self::Cloudy => [ " ", " ", @@ -193,12 +231,13 @@ impl WeatherConditionIcon { } } - pub fn emoji(self) -> &'static str { + pub fn emoji(self, is_day: bool) -> &'static str { match self { WeatherConditionIcon::Unknown => "❓", - WeatherConditionIcon::Clear => "β˜€οΈ ", - WeatherConditionIcon::PartlyCloudy => "⛅️", - WeatherConditionIcon::Cloudy => "☁️ ", + WeatherConditionIcon::Clear if is_day => "β˜€οΈ ", + WeatherConditionIcon::Clear => "πŸŒ™", + WeatherConditionIcon::PartlyCloudy if is_day => "⛅️", + WeatherConditionIcon::PartlyCloudy | WeatherConditionIcon::Cloudy => "☁️ ", WeatherConditionIcon::LightShowers => "🌦️ ", WeatherConditionIcon::HeavyShowers => "🌧️ ", WeatherConditionIcon::LightSnow => "🌨️ ", @@ -208,3 +247,68 @@ impl WeatherConditionIcon { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_day_and_night_differ() { + assert_ne!( + WeatherConditionIcon::Clear.icon(true), + WeatherConditionIcon::Clear.icon(false) + ); + assert_ne!( + WeatherConditionIcon::Clear.colored_icon(true), + WeatherConditionIcon::Clear.colored_icon(false) + ); + assert_ne!( + WeatherConditionIcon::Clear.emoji(true), + WeatherConditionIcon::Clear.emoji(false) + ); + } + + #[test] + fn partly_cloudy_day_and_night_differ() { + assert_ne!( + WeatherConditionIcon::PartlyCloudy.icon(true), + WeatherConditionIcon::PartlyCloudy.icon(false) + ); + assert_ne!( + WeatherConditionIcon::PartlyCloudy.emoji(true), + WeatherConditionIcon::PartlyCloudy.emoji(false) + ); + } + + #[test] + fn cloudy_ignores_is_day() { + assert_eq!( + WeatherConditionIcon::Cloudy.icon(true), + WeatherConditionIcon::Cloudy.icon(false) + ); + assert_eq!( + WeatherConditionIcon::Cloudy.colored_icon(true), + WeatherConditionIcon::Cloudy.colored_icon(false) + ); + assert_eq!( + WeatherConditionIcon::Cloudy.emoji(true), + WeatherConditionIcon::Cloudy.emoji(false) + ); + } + + #[test] + fn other_conditions_ignore_is_day() { + for variant in [ + WeatherConditionIcon::Unknown, + WeatherConditionIcon::LightShowers, + WeatherConditionIcon::HeavyShowers, + WeatherConditionIcon::LightSnow, + WeatherConditionIcon::HeavySnow, + WeatherConditionIcon::Thunderstorm, + WeatherConditionIcon::Fog, + ] { + assert_eq!(variant.icon(true), variant.icon(false), "{variant:?}"); + assert_eq!(variant.emoji(true), variant.emoji(false), "{variant:?}"); + } + } +} diff --git a/src/models.rs b/src/models.rs index b90fd28..5ad1fc7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -84,9 +84,10 @@ pub struct Weather { pub wind_speed: f64, pub wind_direction: u16, pub uv_index: Option, + pub is_day: Option, pub description: String, pub icon: WeatherConditionIcon, - pub location_name: String, + pub location: Location, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -96,6 +97,16 @@ pub struct Location { pub longitude: f64, } +impl Location { + pub fn new(name: String, latitude: f64, longitude: f64) -> Self { + Self { + name, + latitude, + longitude, + } + } +} + #[derive(Debug, Default, Copy, Clone, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] #[non_exhaustive] pub enum Language { diff --git a/src/tests.rs b/src/tests.rs index 0acd658..fb7dd46 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -33,9 +33,10 @@ impl TestProvider { wind_speed: 5.0, wind_direction: 180, uv_index: None, + is_day: Some(true), description: "Clear sky".to_string(), icon: WeatherConditionIcon::Clear, - location_name: location.name, + location, } } } @@ -78,7 +79,7 @@ fn test_valid_city_lookup() { let weather = result.unwrap(); assert_eq!(weather.temperature, 20.0); - assert_eq!(weather.location_name, "Test City".to_string()); + assert_eq!(weather.location.name, "Test City".to_string()); } #[test] diff --git a/src/weather/enrich.rs b/src/weather/enrich.rs new file mode 100644 index 0000000..9b6c464 --- /dev/null +++ b/src/weather/enrich.rs @@ -0,0 +1,111 @@ +use crate::config::Config; +use crate::errors::RustormyError; +use crate::models::Weather; +use crate::weather::openuv::get_uv_index; +use crate::weather::sun; +use chrono::Utc; +use reqwest::blocking::Client; + +pub fn enrich( + weather: &mut Weather, + client: &Client, + config: &Config, +) -> Result<(), RustormyError> { + if weather.is_day.is_none() { + weather.is_day = Some(sun::is_daytime(&weather.location, Utc::now())); + } + if weather.uv_index.is_none() && !config.api_keys().open_uv.is_empty() { + weather.uv_index = get_uv_index(client, config, &weather.location)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Location, WeatherConditionIcon}; + + fn make_weather() -> Weather { + Weather { + temperature: 0.0, + feels_like: 0.0, + humidity: 0, + dew_point: 0.0, + precipitation: 0.0, + pressure: 0, + wind_speed: 0.0, + wind_direction: 0, + uv_index: None, + is_day: None, + description: String::new(), + icon: WeatherConditionIcon::Clear, + location: Location { + name: String::new(), + latitude: 0.0, + longitude: 0.0, + }, + } + } + + #[test] + fn fills_is_day_when_none() { + let mut weather = make_weather(); + assert!(weather.is_day.is_none()); + let client = Client::new(); + let config = Config::default(); + enrich(&mut weather, &client, &config).expect("enrich should succeed"); + assert!(weather.is_day.is_some(), "is_day should be populated"); + } + + #[test] + fn does_not_overwrite_false_is_day_when_set() { + let mut weather = make_weather(); + weather.is_day = Some(false); + let client = Client::new(); + let config = Config::default(); + enrich(&mut weather, &client, &config).expect("enrich should succeed"); + assert_eq!( + weather.is_day, + Some(false), + "is_day should not be overwritten" + ); + } + + #[test] + fn does_not_overwrite_true_is_day_when_set() { + let mut weather = make_weather(); + weather.is_day = Some(true); + let client = Client::new(); + let config = Config::default(); + enrich(&mut weather, &client, &config).expect("enrich should succeed"); + assert_eq!( + weather.is_day, + Some(true), + "is_day should not be overwritten" + ); + } + + #[test] + fn skips_uv_when_openuv_key_empty() { + let mut weather = make_weather(); + let client = Client::new(); + let config = Config::default(); + enrich(&mut weather, &client, &config).expect("enrich should succeed"); + assert_eq!(weather.uv_index, None, "no UV when key is empty"); + } + + #[test] + fn does_not_overwrite_uv_when_set() { + let mut weather = make_weather(); + weather.uv_index = Some(4.2); + let client = Client::new(); + let mut config = Config::default(); + // Set a non-empty OpenUV key so the empty-key short-circuit does + // not fire. The is_none() guard on uv_index should prevent the + // OpenUV HTTP call from happening β€” if it did fire, the fake key + // would produce an error and this test would fail. + config.api_keys_mut().open_uv = "fake-key".to_string(); + enrich(&mut weather, &client, &config).expect("enrich should succeed"); + assert_eq!(weather.uv_index, Some(4.2)); + } +} diff --git a/src/weather/mod.rs b/src/weather/mod.rs index f17ba34..4158b8d 100644 --- a/src/weather/mod.rs +++ b/src/weather/mod.rs @@ -45,13 +45,17 @@ pub trait LookUpCity { } } +mod enrich; mod open_meteo; mod open_weather_map; mod openuv; mod provider; +mod sun; mod tomorrow_io; pub mod tools; mod weather_api; mod weather_bit; mod world_weather_online; mod yr; + +pub use enrich::enrich; diff --git a/src/weather/open_meteo.rs b/src/weather/open_meteo.rs index b9e1073..d069674 100644 --- a/src/weather/open_meteo.rs +++ b/src/weather/open_meteo.rs @@ -2,7 +2,6 @@ use crate::config::Config; use crate::display::translations::ll; use crate::errors::RustormyError; use crate::models::{Language, Location, Units, Weather, WeatherConditionIcon}; -use crate::weather::openuv::get_uv_index; use crate::weather::{GetWeather, LookUpCity, tools}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; @@ -40,13 +39,8 @@ struct OpenMeteoResponse { } impl OpenMeteoResponse { - pub fn into_weather( - self, - client: &Client, - config: &Config, - location: &Location, - ) -> Result { - Ok(Weather { + pub fn into_weather(self, config: &Config, location: &Location) -> Weather { + Weather { temperature: self.current.temperature, feels_like: self.current.apparent_temperature, humidity: self.current.humidity, @@ -55,11 +49,12 @@ impl OpenMeteoResponse { pressure: self.current.pressure as u32, wind_speed: self.current.wind_speed, wind_direction: self.current.wind_direction, - uv_index: get_uv_index(client, config, location)?, + uv_index: None, + is_day: Some(self.current.is_day == 1), description: self.description(config.language()).to_string(), icon: self.icon(), - location_name: location.name.clone(), - }) + location: location.clone(), + } } fn description(&self, lang: Language) -> &'static str { @@ -137,6 +132,7 @@ struct CurrentWeather { #[serde(rename = "wind_direction_10m")] wind_direction: u16, weather_code: u8, + is_day: u8, } #[derive(Debug, Serialize)] @@ -200,7 +196,7 @@ struct WeatherAPIRequest<'a> { impl<'a> WeatherAPIRequest<'a> { pub fn new(location: &Location, config: &'a Config) -> Self { - const CURRENT: &str = "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,surface_pressure,wind_speed_10m,wind_direction_10m,weather_code"; + const CURRENT: &str = "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,surface_pressure,wind_speed_10m,wind_direction_10m,weather_code,is_day"; let (temperature_unit, wind_speed_unit, precipitation_unit) = match config.units() { Units::Metric => ("celsius", "ms", "mm"), Units::Imperial => ("fahrenheit", "mph", "inch"), @@ -246,7 +242,7 @@ impl GetWeather for OpenMeteo { .json::>()? .into_result()?; - data.into_weather(client, config, &location) + Ok(data.into_weather(config, &location)) } } @@ -264,7 +260,7 @@ mod tests { fn make_weather_response(weather_code: u8) -> OpenMeteoResponse { let json = format!( - r#"{{"current":{{"temperature_2m":20.0,"apparent_temperature":18.0,"relative_humidity_2m":50,"precipitation":0.0,"surface_pressure":1013.0,"wind_speed_10m":5.0,"wind_direction_10m":180,"weather_code":{weather_code}}}}}"# + r#"{{"current":{{"temperature_2m":20.0,"apparent_temperature":18.0,"relative_humidity_2m":50,"precipitation":0.0,"surface_pressure":1013.0,"wind_speed_10m":5.0,"wind_direction_10m":180,"weather_code":{weather_code},"is_day":1}}}}"# ); serde_json::from_str(&json).unwrap() } @@ -294,6 +290,14 @@ mod tests { assert_eq!(response.current.humidity, 67); assert_eq!(response.current.wind_direction, 258); assert_eq!(response.current.weather_code, 1); + assert_eq!(response.current.is_day, 1); + } + + #[test] + fn test_is_day_zero_means_night() { + let json = r#"{"current":{"temperature_2m":20.0,"apparent_temperature":18.0,"relative_humidity_2m":50,"precipitation":0.0,"surface_pressure":1013.0,"wind_speed_10m":5.0,"wind_direction_10m":180,"weather_code":0,"is_day":0}}"#; + let response: OpenMeteoResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.current.is_day, 0); } #[test_case(0, "Clear")] diff --git a/src/weather/open_weather_map.rs b/src/weather/open_weather_map.rs index e544702..a073429 100644 --- a/src/weather/open_weather_map.rs +++ b/src/weather/open_weather_map.rs @@ -2,7 +2,6 @@ use crate::config::Config; use crate::display::translations::ll; use crate::errors::RustormyError; use crate::models::{Language, Location, Units, Weather, WeatherConditionIcon}; -use crate::weather::openuv::get_uv_index; use crate::weather::{GetWeather, LookUpCity, tools}; use capitalize::Capitalize; use reqwest::blocking::Client; @@ -102,6 +101,17 @@ impl WeatherResponseData { }) } + pub fn is_day(&self) -> Option { + self.weather + .first() + .and_then(|w| w.icon.chars().last()) + .and_then(|c| match c { + 'd' => Some(true), + 'n' => Some(false), + _ => None, + }) + } + fn dew_point(&self, units: Units) -> f64 { let t = self.main.temp; let h = self.main.humidity.into(); @@ -109,13 +119,13 @@ impl WeatherResponseData { tools::dew_point(t, h, units) } - pub fn into_weather( - self, - client: &Client, - config: &Config, - location: Location, - ) -> Result { - Ok(Weather { + pub fn into_weather(mut self, config: &Config, location: &Location) -> Weather { + let location = Location::new( + self.name.take().unwrap_or_else(|| location.name.clone()), + location.latitude, + location.longitude, + ); + Weather { temperature: self.main.temp, feels_like: self.main.feels_like, humidity: self.main.humidity, @@ -124,13 +134,14 @@ impl WeatherResponseData { pressure: self.main.pressure, wind_speed: self.wind.speed, wind_direction: self.wind.deg, - uv_index: get_uv_index(client, config, &location)?, + uv_index: None, + is_day: self.is_day(), description: self .description() .unwrap_or_else(|| ll(config.language(), "Unknown").to_string()), icon: self.icon(), - location_name: self.name.unwrap_or(location.name), - }) + location, + } } } @@ -138,6 +149,7 @@ impl WeatherResponseData { struct WeatherInfo { id: u32, description: String, + icon: String, } #[derive(Debug, serde::Deserialize)] @@ -210,7 +222,26 @@ impl GetWeather for OpenWeatherMap { let response: WeatherApiResponse = response.json()?; match response { WeatherApiResponse::Err { message } => Err(RustormyError::ApiReturnedError(message)), - WeatherApiResponse::Ok(data) => Ok(data.into_weather(client, config, location)?), + WeatherApiResponse::Ok(data) => Ok(data.into_weather(config, &location)), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_owm_is_day_from_icon_d_suffix() { + let json = r#"{"weather":[{"id":800,"description":"clear","icon":"01d"}],"main":{"temp":20.0,"feels_like":19.0,"humidity":50,"pressure":1013},"wind":{"speed":3.0,"deg":90}}"#; + let data: WeatherResponseData = serde_json::from_str(json).unwrap(); + assert_eq!(data.is_day(), Some(true)); + } + + #[test] + fn test_owm_is_day_from_icon_n_suffix() { + let json = r#"{"weather":[{"id":800,"description":"clear","icon":"01n"}],"main":{"temp":20.0,"feels_like":19.0,"humidity":50,"pressure":1013},"wind":{"speed":3.0,"deg":90}}"#; + let data: WeatherResponseData = serde_json::from_str(json).unwrap(); + assert_eq!(data.is_day(), Some(false)); + } +} diff --git a/src/weather/sun.rs b/src/weather/sun.rs new file mode 100644 index 0000000..9f2ad5c --- /dev/null +++ b/src/weather/sun.rs @@ -0,0 +1,152 @@ +use crate::models::Location; +use chrono::{DateTime, Datelike, Timelike, Utc}; + +pub fn is_daytime(location: &Location, now: DateTime) -> bool { + solar_altitude_deg(location.latitude, location.longitude, now) > 0.0 +} + +/// Calculates the solar altitude (the angle of the sun above the horizon) in degrees for a given location and time. +fn solar_altitude_deg(lat_deg: f64, lon_deg: f64, now: DateTime) -> f64 { + let lat = lat_deg.to_radians(); + + let day_of_year = f64::from(now.ordinal()); + let hours = + f64::from(now.hour()) + f64::from(now.minute()) / 60.0 + f64::from(now.second()) / 3600.0; + let year = now.year(); + let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { + 366.0 + } else { + 365.0 + }; + + let gamma = std::f64::consts::TAU / days_in_year * (day_of_year - 1.0 + (hours - 12.0) / 24.0); + + let eqtime = 229.18 + * (0.000_075 + 0.001_868 * gamma.cos() + - 0.032_077 * gamma.sin() + - 0.014_615 * (2.0 * gamma).cos() + - 0.040_849 * (2.0 * gamma).sin()); + + let decl = 0.006_918 - 0.399_912 * gamma.cos() + 0.070_257 * gamma.sin() + - 0.006_758 * (2.0 * gamma).cos() + + 0.000_907 * (2.0 * gamma).sin() + - 0.002_697 * (3.0 * gamma).cos() + + 0.001_48 * (3.0 * gamma).sin(); + + let time_offset = eqtime + 4.0 * lon_deg; + let tst_minutes = hours * 60.0 + time_offset; + + let hour_angle_deg = tst_minutes / 4.0 - 180.0; + let h = hour_angle_deg.to_radians(); + + let sin_alt = lat.sin() * decl.sin() + lat.cos() * decl.cos() * h.cos(); + sin_alt.asin().to_degrees() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn dt(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap() + } + + fn loc(latitude: f64, longitude: f64) -> Location { + Location { + name: String::new(), + latitude, + longitude, + } + } + + #[test] + fn tromso_polar_day_is_day_at_all_hours() { + let lat = 69.65; + let lon = 18.96; + for hour in [0, 3, 6, 9, 12, 15, 18, 21] { + assert!( + is_daytime(&loc(lat, lon), dt(2024, 6, 15, hour, 0)), + "TromsΓΈ should be in polar day on 2024-06-15 at {hour}:00 UTC" + ); + } + } + + #[test] + fn longyearbyen_polar_day() { + assert!(is_daytime(&loc(78.22, 15.65), dt(2024, 7, 1, 0, 0))); + } + + #[test] + fn tromso_polar_night_is_night_at_all_hours() { + let lat = 69.65; + let lon = 18.96; + for hour in [0, 3, 6, 9, 12, 15, 18, 21] { + assert!( + !is_daytime(&loc(lat, lon), dt(2024, 12, 15, hour, 0)), + "TromsΓΈ should be in polar night on 2024-12-15 at {hour}:00 UTC" + ); + } + } + + #[test] + fn longyearbyen_polar_night() { + assert!(!is_daytime(&loc(78.22, 15.65), dt(2024, 12, 15, 12, 0))); + } + + #[test] + fn mcmurdo_polar_day_in_december() { + assert!(is_daytime(&loc(-77.85, 166.67), dt(2024, 12, 15, 12, 0))); + } + + #[test] + fn mcmurdo_polar_night_in_june() { + assert!(!is_daytime(&loc(-77.85, 166.67), dt(2024, 6, 15, 12, 0))); + } + + #[test] + fn quito_day_at_local_morning() { + assert!(is_daytime(&loc(-0.18, -78.47), dt(2024, 3, 20, 12, 0))); + } + + #[test] + fn quito_night_at_local_after_midnight() { + assert!(!is_daytime(&loc(-0.18, -78.47), dt(2024, 3, 20, 6, 0))); + } + + #[test] + fn fiji_and_samoa_same_utc_both_daytime() { + let utc = dt(2024, 6, 21, 0, 0); + assert!(is_daytime(&loc(-18.14, 178.44), utc), "Suva should be day"); + assert!(is_daytime(&loc(-13.83, -171.77), utc), "Apia should be day"); + } + + #[test] + fn greenwich_noon_equinox_is_day() { + assert!(is_daytime(&loc(51.48, 0.0), dt(2024, 3, 20, 12, 0))); + } + + #[test] + fn greenwich_midnight_equinox_is_night() { + assert!(!is_daytime(&loc(51.48, 0.0), dt(2024, 3, 20, 0, 0))); + } + + #[test] + fn sydney_summer_local_afternoon_is_day() { + assert!(is_daytime(&loc(-33.87, 151.21), dt(2024, 12, 21, 2, 0))); + } + + #[test] + fn reykjavik_winter_noon_is_just_above_horizon() { + let alt = solar_altitude_deg(64.13, -21.94, dt(2024, 12, 21, 12, 0)); + assert!(alt > 0.0, "ReykjavΓ­k noon altitude was {alt}"); + assert!(alt < 5.0, "ReykjavΓ­k noon altitude was {alt}, expected < 5"); + } + + #[test] + fn greenwich_2025_noon_equinox_is_day() { + // 2025 is a non-leap year β€” exercises the `else` branch of + // `days_in_year`. March 20 noon UTC at Greenwich is well past sunrise. + assert!(is_daytime(&loc(51.48, 0.0), dt(2025, 3, 20, 12, 0))); + } +} diff --git a/src/weather/tomorrow_io.rs b/src/weather/tomorrow_io.rs index 81576c4..697ebdb 100644 --- a/src/weather/tomorrow_io.rs +++ b/src/weather/tomorrow_io.rs @@ -1,7 +1,7 @@ use crate::config::Config; use crate::display::translations::ll; use crate::errors::RustormyError; -use crate::models::{Language, Units, Weather, WeatherConditionIcon}; +use crate::models::{Language, Location, Units, Weather, WeatherConditionIcon}; use crate::weather::GetWeather; use reqwest::blocking::Client; @@ -130,8 +130,8 @@ impl WeatherValues { #[derive(Debug, serde::Deserialize)] struct LocationData { - // lat: f64, - // lon: f64, + lat: f64, + lon: f64, name: String, // #[serde(rename = "type")] // loc_type: String, @@ -139,22 +139,15 @@ struct LocationData { impl LocationData { pub fn name(self) -> String { - // Name comes in the local language and can be VERY long - // e.g. "αƒ‘αƒαƒ—αƒ£αƒ›αƒ˜, αƒαƒ­αƒαƒ αƒ˜αƒ‘ αƒαƒ•αƒ’αƒαƒœαƒαƒ›αƒ˜αƒ£αƒ αƒ˜ αƒ αƒ”αƒ‘αƒžαƒ£αƒ‘αƒšαƒ˜αƒ™αƒ, ბაαƒ₯αƒαƒ αƒ—αƒ•αƒ”αƒšαƒ" - // Let's hope that it's at least somewhat standardized like "{city}, [{some}, {regional}, {stuff}, ..] {country}" - // And return only the city name and country name (if city name isn't too long) - let parts: Vec<&str> = self.name.split(',').map(str::trim).collect(); - if parts.len() >= 2 { - let city = parts[0]; - let country = parts.last().unwrap_or(&""); - if city.len() <= 20 { - format!("{city}, {country}") - } else { - city.to_string() - } - } else { - self.name - } + crate::weather::tools::shorten_location_name(self.name) + } +} + +impl From for Location { + fn from(data: LocationData) -> Self { + let lat = data.lat; + let lon = data.lon; + Self::new(data.name(), lat, lon) } } @@ -185,9 +178,10 @@ impl WeatherResponse { wind_speed: data.values.wind_speed, wind_direction: data.values.wind_direction, uv_index: Some((data.values.uv_index * 10.0).round() / 10.0), + is_day: None, icon: data.values.icon(), description: data.values.description(config.language()).to_string(), - location_name: location.name(), + location: location.into(), }) } } @@ -262,7 +256,10 @@ mod test { assert_eq!(weather.uv_index, Some(2.)); assert_eq!(weather.icon, WeatherConditionIcon::LightShowers); assert_eq!(weather.description, "Light rain"); - assert_eq!(weather.location_name, "αƒ‘αƒαƒ—αƒ£αƒ›αƒ˜, ბაαƒ₯αƒαƒ αƒ—αƒ•αƒ”αƒšαƒ"); // shortened name + assert_eq!(weather.location.name, "αƒ‘αƒαƒ—αƒ£αƒ›αƒ˜, ბაαƒ₯αƒαƒ αƒ—αƒ•αƒ”αƒšαƒ"); // shortened name + assert_eq!(weather.location.latitude, 41.650_951_385_498_1); + assert_eq!(weather.location.longitude, 41.636_009_216_308_6); + assert_eq!(weather.is_day, None); } #[test] diff --git a/src/weather/tools.rs b/src/weather/tools.rs index 4226179..6c98a6d 100644 --- a/src/weather/tools.rs +++ b/src/weather/tools.rs @@ -62,3 +62,63 @@ pub fn owm_code_to_icon(code: u32) -> WeatherConditionIcon { _ => WeatherConditionIcon::Unknown, } } + +/// Shorten a comma-separated location name to "city, country" form. +/// If the city portion is longer than 20 chars, returns just the city. +/// If there are fewer than 2 comma-separated parts, returns the input +/// unchanged. +pub fn shorten_location_name(name: String) -> String { + let parts: Vec<&str> = name.split(',').map(str::trim).collect(); + if parts.len() >= 2 { + let city = parts[0]; + let country = parts.last().unwrap_or(&""); + if city.len() <= 20 { + format!("{city}, {country}") + } else { + city.to_string() + } + } else { + name + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shorten_passthrough_for_single_segment() { + assert_eq!(shorten_location_name("London".to_string()), "London"); + } + + #[test] + fn shorten_two_segments_returns_city_country() { + assert_eq!( + shorten_location_name("London, United Kingdom".to_string()), + "London, United Kingdom" + ); + } + + #[test] + fn shorten_many_segments_returns_first_and_last() { + assert_eq!( + shorten_location_name("αƒ‘αƒαƒ—αƒ£αƒ›αƒ˜, αƒαƒ­αƒαƒ αƒ˜αƒ‘ αƒαƒ•αƒ’αƒαƒœαƒαƒ›αƒ˜αƒ£αƒ αƒ˜ αƒ αƒ”αƒ‘αƒžαƒ£αƒ‘αƒšαƒ˜αƒ™αƒ, ბაαƒ₯αƒαƒ αƒ—αƒ•αƒ”αƒšαƒ".to_string()), + "αƒ‘αƒαƒ—αƒ£αƒ›αƒ˜, ბაαƒ₯αƒαƒ αƒ—αƒ•αƒ”αƒšαƒ" + ); + } + + #[test] + fn shorten_long_city_returns_just_city() { + let long_city = "VeryLongCityNameThatExceedsTwentyChars"; + assert!(long_city.len() > 20); + assert_eq!( + shorten_location_name(format!("{long_city}, Country")), + long_city + ); + } + + #[test] + fn shorten_empty_string() { + assert_eq!(shorten_location_name(String::new()), ""); + } +} diff --git a/src/weather/weather_api.rs b/src/weather/weather_api.rs index a29cfb4..e297108 100644 --- a/src/weather/weather_api.rs +++ b/src/weather/weather_api.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::errors::RustormyError; -use crate::models::{Units, Weather, WeatherConditionIcon}; +use crate::models::{Location, Units, Weather, WeatherConditionIcon}; use crate::weather::{GetWeather, tools}; use reqwest::blocking::Client; @@ -56,11 +56,11 @@ struct WeatherApiData { impl WeatherApiData { fn into_weather(self, config: &Config) -> Weather { - let location_name = self.location.location_name(); + let location = self.location.into(); let current = self.current; + let is_day = Some(current.is_day == 1); Weather { - location_name, temperature: current.temperature(config.units()), feels_like: current.feels_like(config.units()), humidity: current.humidity, @@ -69,9 +69,11 @@ impl WeatherApiData { wind_speed: current.wind_speed(config.units()), wind_direction: current.wind_degree, uv_index: Some(current.uv_index()), + is_day, dew_point: current.dew_point(config.units()), description: current.description().to_string(), icon: current.icon(), + location, } } } @@ -101,11 +103,19 @@ impl WeatherApiLocation { } } +impl From for Location { + fn from(location: WeatherApiLocation) -> Self { + let lat = location.lat; + let lon = location.lon; + Self::new(location.location_name(), lat, lon) + } +} + #[derive(Debug, serde::Deserialize)] struct WeatherApiCurrent { temp_c: f64, temp_f: f64, - // is_day: u8, + is_day: u8, condition: WeatherApiCondition, wind_mph: f64, wind_kph: f64, @@ -296,7 +306,7 @@ mod tests { WeatherApiResponse::Ok(data) => data.into_weather(&Config::default()), WeatherApiResponse::Err { .. } => panic!("Expected Ok variant"), }; - assert_eq!(weather.location_name, "Batumi, Ajaria, Georgia"); + assert_eq!(weather.location.name, "Batumi, Ajaria, Georgia"); assert_eq!(weather.temperature, 25.3); assert_eq!(weather.feels_like, 27.4); assert_eq!(weather.humidity, 74); @@ -308,6 +318,14 @@ mod tests { assert_eq!(weather.uv_index, Some(5.3)); assert_eq!(weather.description, "ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Π°Ρ ΠΎΠ±Π»Π°Ρ‡Π½ΠΎΡΡ‚ΡŒ"); assert_eq!(weather.icon, WeatherConditionIcon::PartlyCloudy); + assert_eq!(weather.is_day, Some(true)); + } + + #[test] + fn test_weather_api_is_day_zero_means_night() { + let json = r#"{"is_day":0,"temp_c":10.0,"temp_f":50.0,"condition":{"text":"Clear","code":1000},"wind_mph":5.0,"wind_kph":8.0,"wind_degree":180,"pressure_mb":1013.0,"pressure_in":29.9,"precip_mm":0.0,"precip_in":0.0,"humidity":50,"feelslike_c":9.0,"feelslike_f":48.0,"dewpoint_c":4.0,"dewpoint_f":39.0,"uv":3.0}"#; + let current: WeatherApiCurrent = serde_json::from_str(json).unwrap(); + assert_eq!(current.is_day, 0); } #[test] diff --git a/src/weather/weather_bit.rs b/src/weather/weather_bit.rs index 687f015..1cd7976 100644 --- a/src/weather/weather_bit.rs +++ b/src/weather/weather_bit.rs @@ -116,10 +116,10 @@ struct WeatherData { // ghi: f64, // gust: f64, // h_angle: f64, - // lat: f64, - // lon: f64, + lat: f64, + lon: f64, // ob_time: String, - // pod: String, + pod: String, precip: f64, pres: f64, rh: u8, @@ -153,6 +153,7 @@ impl WeatherData { } pub fn into_weather(self) -> Weather { + let is_day = Some(self.pod == "d"); Weather { temperature: self.temp, feels_like: self.app_temp, @@ -163,9 +164,10 @@ impl WeatherData { wind_speed: self.wind_spd, wind_direction: self.wind_dir, uv_index: Some(self.uv_index()), + is_day, icon: self.weather.icon(), description: self.weather.description, - location_name: self.city_name, + location: Location::new(self.city_name, self.lat, self.lon), } } } @@ -238,6 +240,9 @@ mod tests { "aqi": 42, "city_name": "London", "dewpt": 10.0, + "lat": 51.5, + "lon": -0.13, + "pod": "d", "precip": 0.0, "pres": 1015.0, "rh": 70, @@ -271,12 +276,53 @@ mod tests { assert_eq!(weather.uv_index, Some(5.0)); assert_eq!(weather.icon, WeatherConditionIcon::PartlyCloudy); assert_eq!(weather.description, "Partly cloudy"); - assert_eq!(weather.location_name, "London"); + assert_eq!(weather.location.name, "London"); + assert_eq!(weather.is_day, Some(true)); } WeatherApiResponse::Err { error } => panic!("Unexpected error: {error:?}"), } } + #[test] + fn test_weather_bit_is_day_from_pod_n() { + let json_data = r#" + { + "count": 1, + "data": [ + { + "app_temp": 15.0, + "aqi": 42, + "city_name": "London", + "dewpt": 10.0, + "lat": 51.5, + "lon": -0.13, + "pod": "n", + "precip": 0.0, + "pres": 1015.0, + "rh": 70, + "temp": 16.0, + "uv": 5.0, + "weather": { + "description": "Clear", + "icon": "c01n", + "code": 800 + }, + "wind_dir": 180, + "wind_spd": 3.5 + } + ] + } + "#; + let response: WeatherApiResponse = serde_json::from_str(json_data).unwrap(); + match response { + WeatherApiResponse::Ok { data, .. } => { + let weather = data.into_iter().next().unwrap().into_weather(); + assert_eq!(weather.is_day, Some(false)); + } + WeatherApiResponse::Err { .. } => panic!("expected Ok"), + } + } + #[test] fn test_parse_geocoding_api_error_response() { let json_data = r#" diff --git a/src/weather/world_weather_online.rs b/src/weather/world_weather_online.rs index 727b9cc..a7a7e77 100644 --- a/src/weather/world_weather_online.rs +++ b/src/weather/world_weather_online.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::errors::RustormyError; -use crate::models::{Language, Units, Weather, WeatherConditionIcon}; +use crate::models::{Language, Location, Units, Weather, WeatherConditionIcon}; use crate::weather::{GetWeather, tools}; use reqwest::blocking::Client; @@ -14,6 +14,7 @@ struct WwoRequestParams<'a> { format: &'a str, fx: &'a str, mca: &'a str, + includelocation: &'a str, } impl<'a> WwoRequestParams<'a> { @@ -28,6 +29,7 @@ impl<'a> WwoRequestParams<'a> { format: "json", fx: "no", mca: "no", + includelocation: "yes", } } } @@ -41,13 +43,14 @@ enum WwoResponse { #[derive(Debug, serde::Deserialize)] struct WwoWeatherData { - request: Vec, current_condition: Vec, + #[serde(default)] + nearest_area: Vec, } impl WwoWeatherData { fn into_weather(self, config: &Config) -> Result { - let location_name = self.location_name()?.to_string(); + let location = self.location()?; let condition = self.current_condition.into_iter().next().ok_or_else(|| { RustormyError::ApiReturnedError("No current condition data".to_string()) })?; @@ -62,28 +65,39 @@ impl WwoWeatherData { wind_speed: condition.wind_speed(config.units())?, wind_direction: condition.wind_direction()?, uv_index: condition.uv_index()?, + is_day: None, description: condition.desc(config.language())?.to_string(), icon: condition.icon()?, - location_name, + location, }) } - fn location_name(&self) -> Result<&str, RustormyError> { - self.request + fn location(&self) -> Result { + self.nearest_area .first() - .map(|r| r.query.as_str()) - .ok_or_else(|| { - RustormyError::ApiReturnedError("No location name available".to_string()) + .and_then(|a| { + let lat = a.latitude.parse::().ok()?; + let lon = a.longitude.parse::().ok()?; + let city = a.area_name.first()?.value.as_str(); + let country = a.country.first().map_or("", |c| c.value.as_str()); + let name = if country.is_empty() { + city.to_string() + } else { + tools::shorten_location_name(format!("{city}, {country}")) + }; + Some(Location::new(name, lat, lon)) }) + .ok_or_else(|| RustormyError::ApiReturnedError("No location data".to_string())) } } #[derive(Debug, serde::Deserialize)] -struct WwoRequestInfo { - #[allow(dead_code)] - #[serde(rename = "type")] - req_type: String, - query: String, +#[serde(rename_all = "camelCase")] +struct WwoNearestArea { + area_name: Vec, + country: Vec, + latitude: String, + longitude: String, } #[derive(Debug, serde::Deserialize)] @@ -131,7 +145,7 @@ impl WwoCurrentCondition { ) })?; - Ok(&desc.value) + Ok(desc.value.trim()) } fn temperature(&self, units: Units) -> Result { @@ -286,3 +300,34 @@ impl GetWeather for WorldWeatherOnline { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + const TEST_API_RESPONSE: &str = include_str!("../../tests/data/wwo_response.json"); + + #[test] + #[allow(clippy::float_cmp)] + fn test_parse_wwo_response() { + let response: WwoResponse = + serde_json::from_str(TEST_API_RESPONSE).expect("Failed to parse JSON"); + let WwoResponse::Ok { data } = response else { + panic!("Expected Ok variant"); + }; + + let weather = data + .into_weather(&Config::default()) + .expect("into_weather should succeed"); + + assert_eq!(weather.location.name, "London, United Kingdom"); + assert_eq!(weather.location.latitude, 51.517); + assert_eq!(weather.location.longitude, -0.106); + assert_eq!(weather.is_day, None); + assert_eq!(weather.temperature, 14.0); + assert_eq!(weather.humidity, 63); + assert_eq!(weather.icon, WeatherConditionIcon::Cloudy); + assert_eq!(weather.description, "Cloudy"); + } +} diff --git a/src/weather/yr.rs b/src/weather/yr.rs index 8928fcb..54dfdbb 100644 --- a/src/weather/yr.rs +++ b/src/weather/yr.rs @@ -4,7 +4,6 @@ use crate::errors::RustormyError; use crate::models::{Language, Weather, WeatherConditionIcon}; use crate::weather::Location; use crate::weather::open_meteo::OpenMeteo; -use crate::weather::openuv::get_uv_index; use crate::weather::tools::{apparent_temperature, dew_point}; use crate::weather::{GetWeather, LookUpCity}; use reqwest::blocking::Client; @@ -89,7 +88,6 @@ pub struct YrPrecipitationDetails { impl YrResponse { pub fn into_weather( self, - client: &Client, config: &Config, location: &Location, ) -> Result { @@ -107,9 +105,10 @@ impl YrResponse { .ok_or(RustormyError::ApiReturnedError( "No forecast data returned".to_string(), ))?; - let description = - symbol_code_to_description(&next_hours.summary.symbol_code, config.language()); - let icon = symbol_code_to_icon(&next_hours.summary.symbol_code); + let symbol_code = &next_hours.summary.symbol_code; + let description = symbol_code_to_description(symbol_code, config.language()); + let icon = symbol_code_to_icon(symbol_code); + let is_day = symbol_code_to_is_day(symbol_code); Ok(Weather { temperature: details.air_temperature, @@ -120,7 +119,8 @@ impl YrResponse { "No wind direction returned".to_string(), ))? .round() as u16, - uv_index: get_uv_index(client, config, location)?, + uv_index: None, + is_day, description, icon, humidity: details.relative_humidity.round() as u8, @@ -138,7 +138,7 @@ impl YrResponse { precipitation: details .precipitation_amount .unwrap_or_else(|| next_hours.details.precipitation_amount.unwrap_or(0.0)), - location_name: location.name.clone(), + location: location.clone(), }) } @@ -157,7 +157,7 @@ impl GetWeather for Yr { .header("User-Agent", YR_USER_AGENT) .send()?; let data: YrResponse = response.json()?; - data.into_weather(client, config, &location) + data.into_weather(config, &location) } } @@ -298,12 +298,15 @@ impl YrWeatherCode { } } -fn yr_weather_code(code: &str) -> Option { - let base = code - .strip_suffix("_day") - .or_else(|| code.strip_suffix("_night")) - .unwrap_or(code); - match base { +fn yr_weather_code(code: &str) -> Option<(YrWeatherCode, Option)> { + let (base, is_day) = if let Some(stripped) = code.strip_suffix("_day") { + (stripped, Some(true)) + } else if let Some(stripped) = code.strip_suffix("_night") { + (stripped, Some(false)) + } else { + (code, None) + }; + let parsed = match base { "clearsky" => Some(YrWeatherCode::ClearSky), "fair" => Some(YrWeatherCode::Fair), "partlycloudy" => Some(YrWeatherCode::PartlyCloudy), @@ -352,18 +355,23 @@ fn yr_weather_code(code: &str) -> Option { "heavysnowandthunder" => Some(YrWeatherCode::HeavySnowAndThunder), "fog" => Some(YrWeatherCode::Fog), _ => None, - } + }; + parsed.map(|p| (p, is_day)) } fn symbol_code_to_description(code: &str, lang: Language) -> String { yr_weather_code(code).map_or_else( || format!("{} ({code})", ll(lang, "Unknown")), - |c| c.description(lang), + |(c, _)| c.description(lang), ) } fn symbol_code_to_icon(code: &str) -> WeatherConditionIcon { - yr_weather_code(code).map_or(WeatherConditionIcon::Unknown, YrWeatherCode::to_icon) + yr_weather_code(code).map_or(WeatherConditionIcon::Unknown, |(c, _)| c.to_icon()) +} + +fn symbol_code_to_is_day(code: &str) -> Option { + yr_weather_code(code).and_then(|(_, is_day)| is_day) } fn get_location(client: &Client, config: &Config) -> Result { @@ -386,26 +394,46 @@ mod test { #[test] fn test_yr_weather_code_known() { - assert_eq!(yr_weather_code("clearsky"), Some(YrWeatherCode::ClearSky)); + assert_eq!( + yr_weather_code("clearsky"), + Some((YrWeatherCode::ClearSky, None)) + ); assert_eq!( yr_weather_code("clearsky_day"), - Some(YrWeatherCode::ClearSky) + Some((YrWeatherCode::ClearSky, Some(true))) ); assert_eq!( yr_weather_code("clearsky_night"), - Some(YrWeatherCode::ClearSky) + Some((YrWeatherCode::ClearSky, Some(false))) + ); + assert_eq!(yr_weather_code("fair"), Some((YrWeatherCode::Fair, None))); + assert_eq!( + yr_weather_code("fair_day"), + Some((YrWeatherCode::Fair, Some(true))) + ); + assert_eq!( + yr_weather_code("fair_night"), + Some((YrWeatherCode::Fair, Some(false))) + ); + assert_eq!(yr_weather_code("fog"), Some((YrWeatherCode::Fog, None))); + assert_eq!( + yr_weather_code("heavyrain"), + Some((YrWeatherCode::HeavyRain, None)) ); - assert_eq!(yr_weather_code("fair"), Some(YrWeatherCode::Fair)); - assert_eq!(yr_weather_code("fair_day"), Some(YrWeatherCode::Fair)); - assert_eq!(yr_weather_code("fair_night"), Some(YrWeatherCode::Fair)); - assert_eq!(yr_weather_code("fog"), Some(YrWeatherCode::Fog)); - assert_eq!(yr_weather_code("heavyrain"), Some(YrWeatherCode::HeavyRain)); assert_eq!( yr_weather_code("snowandthunder"), - Some(YrWeatherCode::SnowAndThunder) + Some((YrWeatherCode::SnowAndThunder, None)) ); } + #[test] + fn test_symbol_code_to_is_day() { + assert_eq!(symbol_code_to_is_day("clearsky_day"), Some(true)); + assert_eq!(symbol_code_to_is_day("clearsky_night"), Some(false)); + assert_eq!(symbol_code_to_is_day("clearsky"), None); + assert_eq!(symbol_code_to_is_day("xyzzy"), None); + } + #[test] fn test_yr_weather_code_unknown() { assert_eq!(yr_weather_code("notacode"), None); @@ -416,11 +444,11 @@ mod test { fn test_yr_weather_code_csv_typos() { assert_eq!( yr_weather_code("lightssleetshowersandthunder"), - Some(YrWeatherCode::LightSleetShowersAndThunder) + Some((YrWeatherCode::LightSleetShowersAndThunder, None)) ); assert_eq!( yr_weather_code("lightssnowshowersandthunder"), - Some(YrWeatherCode::LightSnowShowersAndThunder) + Some((YrWeatherCode::LightSnowShowersAndThunder, None)) ); } @@ -452,7 +480,6 @@ mod test { serde_json::from_str(TEST_API_RESPONSE).expect("Failed to parse JSON"); let weather = data .into_weather( - &Client::new(), &Config::default(), &Location { name: "Test Location".to_string(), diff --git a/tests/data/open_meteo_weather_response.json b/tests/data/open_meteo_weather_response.json index becb544..3bdde61 100644 --- a/tests/data/open_meteo_weather_response.json +++ b/tests/data/open_meteo_weather_response.json @@ -28,6 +28,7 @@ "surface_pressure": 1004.1, "wind_speed_10m": 7.0, "wind_direction_10m": 258, - "weather_code": 1 + "weather_code": 1, + "is_day": 1 } } diff --git a/tests/data/wwo_response.json b/tests/data/wwo_response.json new file mode 100644 index 0000000..bc93107 --- /dev/null +++ b/tests/data/wwo_response.json @@ -0,0 +1,64 @@ +{ + "data": { + "request": [ + { + "type": "City", + "query": "London, United Kingdom" + } + ], + "nearest_area": [ + { + "areaName": [ + { + "value": "London" + } + ], + "country": [ + { + "value": "United Kingdom" + } + ], + "region": [ + { + "value": "City of London, Greater London" + } + ], + "latitude": "51.517", + "longitude": "-0.106" + } + ], + "current_condition": [ + { + "observation_time": "08:01 PM", + "temp_C": "14", + "temp_F": "58", + "weatherCode": "119", + "weatherIconUrl": [ + { + "value": "https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0004_black_low_cloud.png" + } + ], + "weatherDesc": [ + { + "value": "Cloudy" + } + ], + "windspeedMiles": "8", + "windspeedKmph": "13", + "winddirDegree": "38", + "winddir16Point": "NE", + "precipMM": "0.0", + "precipInches": "0.0", + "humidity": "63", + "visibility": "10", + "visibilityMiles": "6", + "pressure": "1011", + "pressureInches": "30", + "cloudcover": "0", + "FeelsLikeC": "13", + "FeelsLikeF": "56", + "uvIndex": "0" + } + ] + } +} From 07367c1dbf2a4db812483aeac6aab38e835a6b4c Mon Sep 17 00:00:00 2001 From: Ilia Agafonov Date: Wed, 6 May 2026 03:42:36 +0700 Subject: [PATCH 2/3] :memo: Update changelog for day/night icons and location handling changes --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56135c6..fdf364c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,22 @@ This project adheres to [Semantic Versioning](https://semver.org/). - Live mode key bindings: `q` / `Esc` / `Ctrl+C` quit immediately, `r` forces an immediate refresh. - Optional one-line footer in live mode showing key hints and the last-update timestamp. Toggle via `live_mode_footer = true|false` in the config file or `--no-footer` on the CLI. +- Distinct night-time icons and ASCII art for Clear and Partly Cloudy conditions. The moon (πŸŒ™) replaces the sun + (β˜€οΈ) at night, and Partly Cloudy switches to a moon-and-cloud combination. Day/night is detected automatically + per location and updates live as the sun crosses the horizon + ([#29](https://github.com/Tairesh/rustormy/pull/29)). +- `is_day`, `latitude`, and `longitude` fields in JSON output. + +### Changed + +- JSON output: `location_name` is now nested as `location.name`, alongside `location.latitude` and + `location.longitude`. Scripts parsing JSON output should switch from `location_name` to `location.name`. ### Fixed - Live mode now uses the terminal's alternate screen buffer, so your existing terminal contents are preserved on exit ([#28](https://github.com/Tairesh/rustormy/pull/28)). +- World Weather Online: trailing whitespace in weather descriptions (e.g. `"Cloudy "`) is now trimmed. ## [0.4.4] - 2026-04-07 From 5f9dbab54974593c508542d4a4d523c00ee4055d Mon Sep 17 00:00:00 2001 From: Ilia Agafonov Date: Wed, 6 May 2026 03:46:41 +0700 Subject: [PATCH 3/3] :bug: Clamp sin_alt before asin to avoid NaN Floating-point rounding can push sin_alt slightly outside [-1, 1], making asin() return NaN at poles or exact solstice/equinox times. --- src/weather/sun.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/weather/sun.rs b/src/weather/sun.rs index 9f2ad5c..9db44b6 100644 --- a/src/weather/sun.rs +++ b/src/weather/sun.rs @@ -40,7 +40,7 @@ fn solar_altitude_deg(lat_deg: f64, lon_deg: f64, now: DateTime) -> f64 { let h = hour_angle_deg.to_radians(); let sin_alt = lat.sin() * decl.sin() + lat.cos() * decl.cos() * h.cos(); - sin_alt.asin().to_degrees() + sin_alt.clamp(-1.0, 1.0).asin().to_degrees() } #[cfg(test)]