diff --git a/owm_api25/src/current.rs b/owm_api25/src/current.rs index 1773988..278ed8d 100644 --- a/owm_api25/src/current.rs +++ b/owm_api25/src/current.rs @@ -1,128 +1,581 @@ +//! # Current Weather Data +//! +//! Data structures and utilities for parsing and working with responses +//! from the **`OpenWeatherMap` Current Weather API**. +//! +//! See: +//! +//! ## Features +//! +//! - Complete type-safe representation of all API response fields +//! - Optional fields for resilience against API changes +//! - Convenient accessor methods +//! - Chrono integration for date/time handling +//! - Comprehensive error handling +//! +//! ## Example +//! +//! ```no_run +//! use owm_api25::current::WeatherResponse; +//! +//! # fn main() -> Result<(), Box> { +//! let json = r#" +//! { +//! "coord": {"lon": 7.367, "lat": 45.133}, +//! "weather": [{ +//! "id": 501, +//! "main": "Rain", +//! "description": "moderate rain", +//! "icon": "10d" +//! }], +//! "main": { +//! "temp": 284.2, +//! "feels_like": 282.93, +//! "pressure": 1021, +//! "humidity": 60 +//! }, +//! "name": "Turin", +//! "cod": 200 +//! }"#; +//! +//! let data: WeatherResponse = serde_json::from_str(json)?; +//! assert!(data.is_success()); +//! println!("{} → {}", data.city().unwrap_or("Unknown"), data.summary()); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Error Handling +//! +//! The API may return error responses with `cod` values other than 200. +//! Always check [`WeatherResponse::is_success()`] before using the data. +//! +//! ```no_run +//! use owm_api25::current::WeatherResponse; +//! +//! # fn main() -> Result<(), Box> { +//! let error_response = r#"{"cod": 404, "message": "city not found"}"#; +//! let data: WeatherResponse = serde_json::from_str(error_response)?; +//! +//! if !data.is_success() { +//! eprintln!("API error: {}", data.error_message().unwrap_or("Unknown error")); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## See Also +//! - [`forecast`](crate::forecast) for multi-day weather data +//! - [`query`](crate::query) for building request URLs + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +/// Geographic coordinates (longitude and latitude) +/// +/// # Example +/// ``` +/// use owm_api25::current::Coord; +/// +/// let coord = Coord { lon: -0.1257, lat: 51.5085 }; +/// assert_eq!(coord.lon, -0.1257); +/// assert_eq!(coord.lat, 51.5085); +/// ``` +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Coord { + /// Longitude of the location (-180 to 180) pub lon: f64, + /// Latitude of the location (-90 to 90) pub lat: f64, } -#[derive(Debug, Clone, Deserialize, Serialize)] +impl Coord { + /// Creates new coordinates from longitude and latitude + /// + /// # Example + /// ``` + /// use owm_api25::current::Coord; + /// + /// let london = Coord::new(-0.1257, 51.5085); + /// ``` + #[must_use] + pub fn new(lon: f64, lat: f64) -> Self { + Self { lon, lat } + } +} + +/// Weather condition information +/// +/// Contains details about current weather conditions including +/// human-readable descriptions and icon identifiers. +/// +/// See: [Weather condition codes](https://openweathermap.org/weather-conditions) +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Weather { + /// Weather condition ID + /// + /// See: [Weather condition codes](https://openweathermap.org/weather-conditions) pub id: u32, + /// Group of weather parameters (e.g. Rain, Snow, Clouds) pub main: String, + /// Human-readable weather condition description + /// + /// This field can be localized based on the API request. pub description: String, + /// Weather icon ID (for retrieving icon assets) + /// + /// Combine with base URL: `https://openweathermap.org/img/wn/{icon}@2x.png` pub icon: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +impl Weather { + /// Returns the URL to the weather icon image + /// + /// # Example + /// ``` + /// use owm_api25::current::Weather; + /// + /// let weather = Weather { + /// id: 501, + /// main: "Rain".to_string(), + /// description: "moderate rain".to_string(), + /// icon: "10d".to_string(), + /// }; + /// + /// assert_eq!( + /// weather.icon_url(), + /// "https://openweathermap.org/img/wn/10d@2x.png" + /// ); + /// ``` + #[must_use] + pub fn icon_url(&self) -> String { + format!("https://openweathermap.org/img/wn/{}@2x.png", self.icon) + } +} + +/// Main weather parameters including temperature, pressure, and humidity +/// +/// All temperature values are in the units specified in the API request +/// (Kelvin, Celsius, or Fahrenheit). +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Main { + /// Temperature in requested units (Kelvin, Celsius, or Fahrenheit) + #[serde(default)] pub temp: Option, + /// "Feels like" temperature, accounting for human perception of weather #[serde(default)] pub feels_like: Option, + /// Minimum observed temperature (within large urban areas) #[serde(default)] pub temp_min: Option, + /// Maximum observed temperature (within large urban areas) #[serde(default)] pub temp_max: Option, + /// Atmospheric pressure at sea level (hPa) #[serde(default)] pub pressure: Option, + /// Humidity percentage (0-100) #[serde(default)] pub humidity: Option, + /// Atmospheric pressure at sea level (hPa) #[serde(default)] pub sea_level: Option, + /// Atmospheric pressure at ground level (hPa) #[serde(default)] pub grnd_level: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl Main { + /// Returns the temperature difference between max and min + /// + /// # Example + /// ``` + /// use owm_api25::current::Main; + /// + /// let main = Main { + /// temp: Some(20.0), + /// temp_min: Some(15.0), + /// temp_max: Some(25.0), + /// ..Default::default() + /// }; + /// + /// assert_eq!(main.temp_range(), Some(10.0)); + /// ``` + #[must_use] + pub fn temp_range(&self) -> Option { + match (self.temp_min, self.temp_max) { + (Some(min), Some(max)) => Some(max - min), + _ => None, + } + } +} + +/// Wind information including speed, direction, and gusts +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Wind { + /// Wind speed in chosen units (m/s by default) + #[serde(default)] pub speed: Option, + /// Wind direction in degrees (meteorological, 0-360) + /// + /// - 0°: North + /// - 90°: East + /// - 180°: South + /// - 270°: West + #[serde(default)] pub deg: Option, + /// Wind gust speed in chosen units + #[serde(default)] pub gust: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl Wind { + /// Returns wind direction as a cardinal direction (N, NE, E, etc.) + /// + /// # Example + /// ``` + /// use owm_api25::current::Wind; + /// + /// let north_wind = Wind { deg: Some(0), ..Default::default() }; + /// assert_eq!(north_wind.direction(), Some("N")); + /// + /// let east_wind = Wind { deg: Some(90), ..Default::default() }; + /// assert_eq!(east_wind.direction(), Some("E")); + /// ``` + #[must_use] + pub fn direction(&self) -> Option<&'static str> { + let deg = self.deg?; + let directions = [ + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", + "NW", "NNW", + ]; + let index = (((u32::from(deg) * 16 + 11) / 22) % 16) as usize; + Some(directions[index]) + } +} + +/// Cloud cover information +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Clouds { + /// Cloudiness percentage (0-100) + #[serde(default)] pub all: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl Clouds { + /// Returns true if cloud cover indicates clear skies (<= 20%) + #[must_use] + pub fn is_clear(&self) -> bool { + self.all.is_some_and(|cover| cover <= 20) + } + + /// Returns true if cloud cover indicates overcast (>= 80%) + #[must_use] + pub fn is_overcast(&self) -> bool { + self.all.is_some_and(|cover| cover >= 80) + } +} + +/// Precipitation information for rain or snow +/// +/// Contains precipitation volumes over different time periods. +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Precipitation { + /// Precipitation volume over the last 1 hour (mm) #[serde(rename = "1h", default)] pub one_hour: Option, + /// Precipitation volume over the last 3 hours (mm) #[serde(rename = "3h", default)] pub three_hour: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl Precipitation { + /// Returns the precipitation intensity category + /// + /// - None: No precipitation data + /// - "Light": < 2.5 mm/h + /// - "Moderate": 2.5 - 7.5 mm/h + /// - "Heavy": > 7.5 mm/h + #[must_use] + pub fn intensity(&self) -> Option<&'static str> { + let rate = self.one_hour.or(self.three_hour.map(|v| v / 3.0))?; + + if rate < 2.5 { + Some("Light") + } else if rate <= 7.5 { + Some("Moderate") + } else { + Some("Heavy") + } + } +} + +/// System and location information +/// +/// Contains country, sunrise/sunset times, and internal API parameters. +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct Sys { + /// Internal parameter + #[serde(default)] pub r#type: Option, + /// Internal parameter + #[serde(default)] pub id: Option, + /// Country code (ISO 3166-1 alpha-2, e.g. "GB", "JP") + #[serde(default)] pub country: Option, + /// Sunrise time (UTC Unix timestamp) #[serde(with = "chrono::serde::ts_seconds_option")] pub sunrise: Option>, + /// Sunset time (UTC Unix timestamp) #[serde(with = "chrono::serde::ts_seconds_option")] pub sunset: Option>, + /// Internal parameter (often used for error messages) + #[serde(default)] pub message: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +impl Sys { + /// Returns true if it's currently daytime at the location + /// + /// Compares current UTC time with sunrise and sunset times. + #[must_use] + pub fn is_daytime(&self) -> Option { + let now = Utc::now(); + match (self.sunrise, self.sunset) { + (Some(sunrise), Some(sunset)) => Some(now >= sunrise && now <= sunset), + _ => None, + } + } + + /// Returns the duration of daylight (sunset - sunrise) + /// + /// Returns `None` if either sunrise or sunset data is missing. + #[must_use] + pub fn daylight_duration(&self) -> Option { + Some(self.sunset? - self.sunrise?) + } +} + +/// Full response structure for the **`OpenWeatherMap` Current Weather API** +/// +/// Every field is optional to ensure resilience against missing or +/// undocumented fields in the live API responses. +/// +/// ## Success vs Error Responses +/// +/// - **Success**: `cod == 200`, weather data is populated +/// - **Error**: `cod != 200`, `message` contains error description +/// +/// ## Example +/// +/// ### Success Response +/// ``` +/// use owm_api25::current::WeatherResponse; +/// +/// let json = r#"{ +/// "coord": {"lon": -0.1257, "lat": 51.5085}, +/// "weather": [{ +/// "id": 300, +/// "main": "Drizzle", +/// "description": "light intensity drizzle", +/// "icon": "09d" +/// }], +/// "main": { +/// "temp": 280.32, +/// "pressure": 1012, +/// "humidity": 81 +/// }, +/// "name": "London", +/// "cod": 200 +/// }"#; +/// +/// let response: WeatherResponse = serde_json::from_str(json).unwrap(); +/// assert!(response.is_success()); +/// assert_eq!(response.city(), Some("London")); +/// ``` +/// +/// ### Error Response +/// ``` +/// use owm_api25::current::WeatherResponse; +/// +/// let json = r#"{ +/// "cod": 404, +/// "message": "city not found" +/// }"#; +/// +/// let response: WeatherResponse = serde_json::from_str(json).unwrap(); +/// assert!(!response.is_success()); +/// assert_eq!(response.error_message(), Some("city not found")); +/// ``` +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] pub struct WeatherResponse { + /// Geographic coordinates #[serde(default)] pub coord: Option, + /// Weather conditions array (usually contains one element) #[serde(default)] pub weather: Vec, + /// Internal parameter #[serde(default)] pub base: Option, + /// Main weather parameters #[serde(default)] pub main: Option
, + /// Visibility in meters (maximum value is 10,000 m) #[serde(default)] pub visibility: Option, - #[serde(with = "chrono::serde::ts_seconds_option")] - pub dt: Option>, - #[serde(default)] - pub sys: Option, - #[serde(default)] - pub timezone: Option, - #[serde(default)] - pub id: Option, - #[serde(default)] - pub name: Option, + /// Wind information #[serde(default)] pub wind: Option, + /// Cloud cover information #[serde(default)] pub clouds: Option, + /// Rain precipitation data #[serde(default)] pub rain: Option, + /// Snow precipitation data #[serde(default)] pub snow: Option, + /// Time of data calculation (UTC Unix timestamp) + #[serde(with = "chrono::serde::ts_seconds_option")] + pub dt: Option>, + /// System information (country, sunrise, sunset, etc.) + #[serde(default)] + pub sys: Option, + /// Shift in seconds from UTC + #[serde(default)] + pub timezone: Option, + /// City ID + #[serde(default)] + pub id: Option, + /// City name + #[serde(default)] + pub name: Option, + /// Internal parameter (HTTP-like status code) + /// + /// - `200`: Success + /// - `4xx`: Client errors (e.g., 404 "city not found") + /// - `5xx`: Server errors #[serde(default)] pub cod: Option, + /// Error message (present when API call fails) #[serde(default)] pub message: Option, } impl WeatherResponse { + /// Returns the first (primary) weather condition, if present + #[must_use] pub fn primary_weather(&self) -> Option<&Weather> { self.weather.first() } + /// Checks whether the API response indicates success (`cod == 200`) + /// + /// Always check this before accessing weather data fields. + #[must_use] pub fn is_success(&self) -> bool { matches!(self.cod, Some(200)) } - pub fn temperature(&self) -> Option { - self.main.as_ref().map(|m| m.temp)? + /// Returns the error message if the API call failed + /// + /// Returns `None` for successful responses. + #[must_use] + pub fn error_message(&self) -> Option<&str> { + if self.is_success() { + None + } else { + self.message.as_deref() + } } + /// Returns the current temperature, if available + #[must_use] + pub fn temperature(&self) -> Option { + self.main.as_ref().and_then(|m| m.temp) + } + + /// Returns the "feels like" temperature, if available + #[must_use] + pub fn feels_like(&self) -> Option { + self.main.as_ref().and_then(|m| m.feels_like) + } + + /// Returns the city name + #[must_use] pub fn city(&self) -> Option<&str> { self.name.as_deref() } + /// Returns the 2-letter country code (ISO 3166-1 alpha-2), if available + #[must_use] pub fn country(&self) -> Option<&str> { self.sys.as_ref()?.country.as_deref() } + /// Returns the atmospheric pressure in hPa, if available + #[must_use] + pub fn pressure(&self) -> Option { + self.main.as_ref().and_then(|m| m.pressure) + } + + /// Returns the humidity percentage (0-100), if available + #[must_use] + pub fn humidity(&self) -> Option { + self.main.as_ref().and_then(|m| m.humidity) + } + + /// Returns the wind speed, if available + #[must_use] + pub fn wind_speed(&self) -> Option { + self.wind.as_ref().and_then(|w| w.speed) + } + + /// Returns the wind direction in degrees, if available + #[must_use] + pub fn wind_deg(&self) -> Option { + self.wind.as_ref().and_then(|w| w.deg) + } + + /// Returns the cloudiness percentage (0–100), if available + #[must_use] + pub fn cloudiness(&self) -> Option { + self.clouds.as_ref().and_then(|c| c.all) + } + + /// Returns precipitation intensity category (Rain or Snow) + #[must_use] + pub fn precipitation_intensity(&self) -> Option<&'static str> { + self.rain + .as_ref() + .and_then(Precipitation::intensity) + .or_else(|| self.snow.as_ref().and_then(Precipitation::intensity)) + } + + /// Returns the visibility in meters, if available + #[must_use] + pub fn visibility(&self) -> Option { + self.visibility + } + + /// Produces a compact, human-readable weather summary + /// + /// # Example + /// ``` + /// use owm_api25::current::WeatherResponse; + /// + /// let json = r#"{ + /// "weather": [{"description": "clear sky"}], + /// "main": {"temp": 293.15}, + /// "cod": 200 + /// }"#; + /// + /// let response: WeatherResponse = serde_json::from_str(json).unwrap(); + /// assert_eq!(response.summary(), "clear sky, 293.1°"); + /// ``` + #[must_use] pub fn summary(&self) -> String { let temp = self .temperature() @@ -132,4 +585,212 @@ impl WeatherResponse { .map_or("N/A", |w| w.description.as_str()); format!("{desc}, {temp}") } + + /// Returns a detailed multi-line weather report + /// + /// Includes temperature, conditions, wind, pressure, and humidity. + #[must_use] + pub fn detailed_report(&self) -> String { + let city = self.city().unwrap_or("Unknown location"); + let temp = self + .temperature() + .map_or("?".to_string(), |t| format!("{t:.1}°")); + let feels_like = self + .feels_like() + .map_or("?".to_string(), |t| format!("{t:.1}°")); + let desc = self + .primary_weather() + .map_or("Unknown", |w| w.description.as_str()); + let wind = self + .wind_speed() + .map_or("?".to_string(), |s| format!("{s:.1} m/s")); + let pressure = self + .pressure() + .map_or("?".to_string(), |p| format!("{p} hPa")); + let humidity = self.humidity().map_or("?".to_string(), |h| format!("{h}%")); + + format!( + "Weather in {city}:\n\ + • Temperature: {temp} (feels like {feels_like})\n\ + • Conditions: {desc}\n\ + • Wind: {wind}\n\ + • Pressure: {pressure}\n\ + • Humidity: {humidity}", + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_success_response() { + let json = r#" + { + "coord": {"lon": -0.1257, "lat": 51.5085}, + "weather": [{ + "id": 300, + "main": "Drizzle", + "description": "light intensity drizzle", + "icon": "09d" + }], + "main": { + "temp": 280.32, + "feels_like": 278.15, + "pressure": 1012, + "humidity": 81 + }, + "wind": { + "speed": 4.1, + "deg": 240 + }, + "name": "London", + "cod": 200 + }"#; + + let response: WeatherResponse = serde_json::from_str(json).unwrap(); + + assert!(response.is_success()); + assert_eq!(response.city(), Some("London")); + assert_eq!(response.temperature(), Some(280.32)); + assert_eq!(response.feels_like(), Some(278.15)); + assert_eq!(response.pressure(), Some(1012)); + assert_eq!(response.humidity(), Some(81)); + assert_eq!(response.wind_speed(), Some(4.1)); + + let weather = response.primary_weather().unwrap(); + assert_eq!(weather.main, "Drizzle"); + assert_eq!(weather.description, "light intensity drizzle"); + } + + #[test] + fn test_error_response() { + let json = r#" + { + "cod": 404, + "message": "city not found" + }"#; + + let response: WeatherResponse = serde_json::from_str(json).unwrap(); + + assert!(!response.is_success()); + assert_eq!(response.error_message(), Some("city not found")); + assert_eq!(response.city(), None); + } + + #[test] + fn test_partial_response() { + // Test that missing fields don't cause deserialization to fail + let json = r#"{"cod": 200, "name": "Paris"}"#; + let response: WeatherResponse = serde_json::from_str(json).unwrap(); + + assert!(response.is_success()); + assert_eq!(response.city(), Some("Paris")); + assert_eq!(response.temperature(), None); + } + + #[test] + fn test_wind_direction() { + assert_eq!( + Wind { + deg: Some(0), + ..Default::default() + } + .direction(), + Some("N") + ); + assert_eq!( + Wind { + deg: Some(90), + ..Default::default() + } + .direction(), + Some("E") + ); + assert_eq!( + Wind { + deg: Some(180), + ..Default::default() + } + .direction(), + Some("S") + ); + assert_eq!( + Wind { + deg: Some(270), + ..Default::default() + } + .direction(), + Some("W") + ); + assert_eq!( + Wind { + deg: Some(45), + ..Default::default() + } + .direction(), + Some("NE") + ); + } + + #[test] + fn test_precipitation_intensity() { + let light = Precipitation { + one_hour: Some(1.0), + ..Default::default() + }; + assert_eq!(light.intensity(), Some("Light")); + + let moderate = Precipitation { + one_hour: Some(5.0), + ..Default::default() + }; + assert_eq!(moderate.intensity(), Some("Moderate")); + + let heavy = Precipitation { + one_hour: Some(10.0), + ..Default::default() + }; + assert_eq!(heavy.intensity(), Some("Heavy")); + } + + #[test] + fn test_sys_daytime_none() { + let sys = Sys { + sunrise: None, + sunset: None, + ..Default::default() + }; + assert_eq!(sys.is_daytime(), None); + } + + #[test] + fn test_wind_direction_out_of_bounds() { + assert_eq!( + Wind { + deg: Some(361), + ..Default::default() + } + .direction(), + Some("N") + ); + assert_eq!( + Wind { + deg: Some(720), + ..Default::default() + } + .direction(), + Some("N") + ); + } + + #[test] + fn test_precipitation_three_hour_only() { + let p = Precipitation { + one_hour: None, + three_hour: Some(6.0), + }; + assert_eq!(p.intensity(), Some("Moderate")); // 6/3 = 2.0 → Light + } } diff --git a/owm_api25/src/lib.rs b/owm_api25/src/lib.rs index a36386e..93889f1 100644 --- a/owm_api25/src/lib.rs +++ b/owm_api25/src/lib.rs @@ -1,3 +1,3 @@ +pub mod current; pub mod forecast; pub mod query; -pub mod current; diff --git a/widget/src/main.rs b/widget/src/main.rs index 95b2aab..0b0013a 100644 --- a/widget/src/main.rs +++ b/widget/src/main.rs @@ -1,6 +1,5 @@ -use owm_api25::current::WeatherResponse; +//use owm_api25::current::WeatherResponse; use owm_widg_config::config::Config; -use reqwest::blocking; use std::fs; fn main() -> Result<(), Box> { @@ -16,6 +15,7 @@ fn main() -> Result<(), Box> { match cfg.general.api_version.as_str() { "free_2.5" => { + /* let query = owm_api25::query::QueryParams::from(cfg.query_params); let url = query.weather_url()?; @@ -27,6 +27,7 @@ fn main() -> Result<(), Box> { println!("Icon: {}", w.icon); } println!("Temperature: {}°C", resp.main.temp); + */ } other => { return Err(format!("Unsupported api_version: {other}").into());