use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeatherConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub openweathermap_key: String,
pub default_location: Option<String>,
#[serde(default)]
pub units: WeatherUnits,
#[serde(default = "default_lang")]
pub language: String,
}
fn default_true() -> bool {
true
}
fn default_lang() -> String {
"en".to_string()
}
impl Default for WeatherConfig {
fn default() -> Self {
Self {
enabled: true,
openweathermap_key: String::new(),
default_location: None,
units: WeatherUnits::Metric,
language: default_lang(),
}
}
}
impl WeatherConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
pub fn resolve_env_vars(&mut self) {
if self.openweathermap_key.is_empty() || self.openweathermap_key.starts_with('$') {
self.openweathermap_key = std::env::var("OPENWEATHERMAP_API_KEY")
.or_else(|_| std::env::var("OWM_API_KEY"))
.unwrap_or_default();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum WeatherUnits {
#[default]
Metric,
Imperial,
Kelvin,
}
impl WeatherUnits {
fn to_owm_string(&self) -> &str {
match self {
WeatherUnits::Metric => "metric",
WeatherUnits::Imperial => "imperial",
WeatherUnits::Kelvin => "standard",
}
}
fn temp_symbol(&self) -> &str {
match self {
WeatherUnits::Metric => "°C",
WeatherUnits::Imperial => "°F",
WeatherUnits::Kelvin => "K",
}
}
fn speed_unit(&self) -> &str {
match self {
WeatherUnits::Metric => "m/s",
WeatherUnits::Imperial => "mph",
WeatherUnits::Kelvin => "m/s",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentWeather {
pub location: String,
pub country: String,
pub coords: Coordinates,
pub condition: WeatherCondition,
pub temperature: Temperature,
pub feels_like: f32,
pub humidity: u8,
pub pressure: u32,
pub wind: Wind,
pub clouds: u8,
pub visibility: u32,
pub sunrise: chrono::DateTime<chrono::Utc>,
pub sunset: chrono::DateTime<chrono::Utc>,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub units: WeatherUnits,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Coordinates {
pub lat: f64,
pub lon: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeatherCondition {
pub id: u32,
pub main: String,
pub description: String,
pub icon: String,
}
impl WeatherCondition {
pub fn emoji(&self) -> &str {
match self.main.as_str() {
"Clear" => "☀️",
"Clouds" => "☁️",
"Rain" | "Drizzle" => "🌧️",
"Thunderstorm" => "⛈️",
"Snow" => "🌨️",
"Mist" | "Fog" | "Haze" => "🌫️",
"Smoke" | "Dust" | "Sand" | "Ash" => "💨",
"Squall" | "Tornado" => "🌪️",
_ => "🌡️",
}
}
pub fn icon_url(&self) -> String {
format!("https://openweathermap.org/img/wn/{}@2x.png", self.icon)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Temperature {
pub current: f32,
pub min: f32,
pub max: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Wind {
pub speed: f32,
pub deg: u16,
pub gust: Option<f32>,
}
impl Wind {
pub fn direction(&self) -> &str {
match self.deg {
0..=22 => "N",
23..=67 => "NE",
68..=112 => "E",
113..=157 => "SE",
158..=202 => "S",
203..=247 => "SW",
248..=292 => "W",
293..=337 => "NW",
338..=360 => "N",
_ => "?",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Forecast {
pub location: String,
pub country: String,
pub periods: Vec<ForecastPeriod>,
pub units: WeatherUnits,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForecastPeriod {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub condition: WeatherCondition,
pub temperature: Temperature,
pub feels_like: f32,
pub humidity: u8,
pub wind: Wind,
pub pop: u8,
pub rain: Option<f32>,
pub snow: Option<f32>,
}
pub struct WeatherClient {
config: WeatherConfig,
}
impl WeatherClient {
const OWM_BASE: &'static str = "https://api.openweathermap.org/data/2.5";
pub fn new(config: &WeatherConfig) -> Result<Self> {
let mut config = config.clone();
config.resolve_env_vars();
Ok(Self { config })
}
pub fn is_configured(&self) -> bool {
self.config.enabled && !self.config.openweathermap_key.is_empty()
}
pub async fn current(&self, location: &str) -> Result<CurrentWeather> {
let url = format!(
"{}/weather?q={}&appid={}&units={}&lang={}",
Self::OWM_BASE,
urlencoding::encode(location),
self.config.openweathermap_key,
self.config.units.to_owm_string(),
self.config.language
);
let response: serde_json::Value = self.api_get(&url).await?;
self.parse_current_weather(response)
}
pub async fn current_by_coords(&self, lat: f64, lon: f64) -> Result<CurrentWeather> {
let url = format!(
"{}/weather?lat={}&lon={}&appid={}&units={}&lang={}",
Self::OWM_BASE,
lat,
lon,
self.config.openweathermap_key,
self.config.units.to_owm_string(),
self.config.language
);
let response: serde_json::Value = self.api_get(&url).await?;
self.parse_current_weather(response)
}
pub async fn forecast(&self, location: &str, days: u8) -> Result<Forecast> {
let cnt = (days as u32 * 8).min(40);
let url = format!(
"{}/forecast?q={}&cnt={}&appid={}&units={}&lang={}",
Self::OWM_BASE,
urlencoding::encode(location),
cnt,
self.config.openweathermap_key,
self.config.units.to_owm_string(),
self.config.language
);
let response: serde_json::Value = self.api_get(&url).await?;
self.parse_forecast(response)
}
pub async fn forecast_by_coords(&self, lat: f64, lon: f64, days: u8) -> Result<Forecast> {
let cnt = (days as u32 * 8).min(40);
let url = format!(
"{}/forecast?lat={}&lon={}&cnt={}&appid={}&units={}&lang={}",
Self::OWM_BASE,
lat,
lon,
cnt,
self.config.openweathermap_key,
self.config.units.to_owm_string(),
self.config.language
);
let response: serde_json::Value = self.api_get(&url).await?;
self.parse_forecast(response)
}
pub async fn current_default(&self) -> Result<CurrentWeather> {
let location = self
.config
.default_location
.clone()
.ok_or_else(|| DrivenError::Config("No default location set".into()))?;
self.current(&location).await
}
pub fn format_temp(&self, temp: f32) -> String {
format!("{:.1}{}", temp, self.config.units.temp_symbol())
}
pub fn format_wind(&self, wind: &Wind) -> String {
format!(
"{:.1} {} {}",
wind.speed,
self.config.units.speed_unit(),
wind.direction()
)
}
fn parse_current_weather(&self, data: serde_json::Value) -> Result<CurrentWeather> {
let weather = data["weather"][0].clone();
let main = &data["main"];
let wind = &data["wind"];
let sys = &data["sys"];
Ok(CurrentWeather {
location: data["name"].as_str().unwrap_or_default().to_string(),
country: sys["country"].as_str().unwrap_or_default().to_string(),
coords: Coordinates {
lat: data["coord"]["lat"].as_f64().unwrap_or(0.0),
lon: data["coord"]["lon"].as_f64().unwrap_or(0.0),
},
condition: WeatherCondition {
id: weather["id"].as_u64().unwrap_or(0) as u32,
main: weather["main"].as_str().unwrap_or_default().to_string(),
description: weather["description"].as_str().unwrap_or_default().to_string(),
icon: weather["icon"].as_str().unwrap_or_default().to_string(),
},
temperature: Temperature {
current: main["temp"].as_f64().unwrap_or(0.0) as f32,
min: main["temp_min"].as_f64().unwrap_or(0.0) as f32,
max: main["temp_max"].as_f64().unwrap_or(0.0) as f32,
},
feels_like: main["feels_like"].as_f64().unwrap_or(0.0) as f32,
humidity: main["humidity"].as_u64().unwrap_or(0) as u8,
pressure: main["pressure"].as_u64().unwrap_or(0) as u32,
wind: Wind {
speed: wind["speed"].as_f64().unwrap_or(0.0) as f32,
deg: wind["deg"].as_u64().unwrap_or(0) as u16,
gust: wind["gust"].as_f64().map(|g| g as f32),
},
clouds: data["clouds"]["all"].as_u64().unwrap_or(0) as u8,
visibility: data["visibility"].as_u64().unwrap_or(10000) as u32,
sunrise: chrono::DateTime::from_timestamp(
sys["sunrise"].as_i64().unwrap_or(0),
0,
)
.unwrap_or_default(),
sunset: chrono::DateTime::from_timestamp(
sys["sunset"].as_i64().unwrap_or(0),
0,
)
.unwrap_or_default(),
timestamp: chrono::DateTime::from_timestamp(
data["dt"].as_i64().unwrap_or(0),
0,
)
.unwrap_or_default(),
units: self.config.units,
})
}
fn parse_forecast(&self, data: serde_json::Value) -> Result<Forecast> {
let city = &data["city"];
let list = data["list"]
.as_array()
.ok_or_else(|| DrivenError::Parse("Invalid forecast data".into()))?;
let periods = list
.iter()
.map(|p| {
let weather = &p["weather"][0];
let main = &p["main"];
let wind = &p["wind"];
ForecastPeriod {
timestamp: chrono::DateTime::from_timestamp(
p["dt"].as_i64().unwrap_or(0),
0,
)
.unwrap_or_default(),
condition: WeatherCondition {
id: weather["id"].as_u64().unwrap_or(0) as u32,
main: weather["main"].as_str().unwrap_or_default().to_string(),
description: weather["description"].as_str().unwrap_or_default().to_string(),
icon: weather["icon"].as_str().unwrap_or_default().to_string(),
},
temperature: Temperature {
current: main["temp"].as_f64().unwrap_or(0.0) as f32,
min: main["temp_min"].as_f64().unwrap_or(0.0) as f32,
max: main["temp_max"].as_f64().unwrap_or(0.0) as f32,
},
feels_like: main["feels_like"].as_f64().unwrap_or(0.0) as f32,
humidity: main["humidity"].as_u64().unwrap_or(0) as u8,
wind: Wind {
speed: wind["speed"].as_f64().unwrap_or(0.0) as f32,
deg: wind["deg"].as_u64().unwrap_or(0) as u16,
gust: wind["gust"].as_f64().map(|g| g as f32),
},
pop: (p["pop"].as_f64().unwrap_or(0.0) * 100.0) as u8,
rain: p["rain"]["3h"].as_f64().map(|r| r as f32),
snow: p["snow"]["3h"].as_f64().map(|s| s as f32),
}
})
.collect();
Ok(Forecast {
location: city["name"].as_str().unwrap_or_default().to_string(),
country: city["country"].as_str().unwrap_or_default().to_string(),
periods,
units: self.config.units,
})
}
async fn api_get(&self, url: &str) -> Result<serde_json::Value> {
let client = reqwest::Client::new();
let response = client
.get(url)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let error = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!(
"Weather API error ({}): {}",
status, error
)));
}
response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = WeatherConfig::default();
assert!(config.enabled);
assert_eq!(config.units, WeatherUnits::Metric);
}
#[test]
fn test_units() {
assert_eq!(WeatherUnits::Metric.temp_symbol(), "°C");
assert_eq!(WeatherUnits::Imperial.temp_symbol(), "°F");
assert_eq!(WeatherUnits::Kelvin.temp_symbol(), "K");
}
#[test]
fn test_wind_direction() {
let wind = Wind {
speed: 5.0,
deg: 90,
gust: None,
};
assert_eq!(wind.direction(), "E");
}
#[test]
fn test_condition_emoji() {
let condition = WeatherCondition {
id: 800,
main: "Clear".to_string(),
description: "clear sky".to_string(),
icon: "01d".to_string(),
};
assert_eq!(condition.emoji(), "☀️");
}
}