openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
use time::PrimitiveDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;

use crate::{Error, Result};

const API_DATE_TIME_FORMAT: &[FormatItem<'_>] =
    format_description!("[year]-[month]-[day]T[hour]:[minute]");

pub(crate) fn validate_coordinates(latitude: f64, longitude: f64) -> Result<()> {
    if !(-90.0..=90.0).contains(&latitude) {
        return Err(Error::InvalidParam {
            field: "latitude",
            reason: "must be between -90 and 90 degrees".into(),
        });
    }
    if !(-180.0..=180.0).contains(&longitude) {
        return Err(Error::InvalidParam {
            field: "longitude",
            reason: "must be between -180 and 180 degrees".into(),
        });
    }

    Ok(())
}

pub(crate) fn format_hour(value: PrimitiveDateTime) -> Result<String> {
    value
        .format(API_DATE_TIME_FORMAT)
        .map_err(|err| Error::InvalidParam {
            field: "hour_range",
            reason: err.to_string(),
        })
}

pub(crate) fn validate_at_most<T>(field: &'static str, value: Option<T>, max: T) -> Result<()>
where
    T: Copy + std::fmt::Display + PartialOrd,
{
    if value.is_some_and(|value| value > max) {
        return Err(Error::InvalidParam {
            field,
            reason: format!("must be at most {max}"),
        });
    }

    Ok(())
}

pub(crate) fn is_non_zero_u8(value: Option<u8>) -> bool {
    value.is_some_and(|value| value > 0)
}

pub(crate) fn is_non_zero_u16(value: Option<u16>) -> bool {
    value.is_some_and(|value| value > 0)
}

pub(crate) fn validate_ordered_range<T>(
    field: &'static str,
    range: Option<(T, T)>,
    reason: &'static str,
) -> Result<()>
where
    T: PartialOrd,
{
    if let Some((start, end)) = range
        && start > end
    {
        return Err(Error::InvalidParam {
            field,
            reason: reason.into(),
        });
    }

    Ok(())
}

pub(crate) fn validate_fixed_range_windows<D, H>(
    date_range: Option<(D, D)>,
    hour_range: Option<(H, H)>,
    relative_windows: &[(&'static str, bool)],
) -> Result<()>
where
    D: Copy + PartialOrd,
    H: Copy + PartialOrd,
{
    validate_ordered_range(
        "date_range",
        date_range,
        "start date must be before or equal to end date",
    )?;
    validate_ordered_range(
        "hour_range",
        hour_range,
        "start hour must be before or equal to end hour",
    )?;

    if date_range.is_some() && hour_range.is_some() {
        return Err(Error::MutuallyExclusive {
            first: "date_range",
            second: "hour_range",
        });
    }

    for (field, is_set) in relative_windows {
        if date_range.is_some() && *is_set {
            return Err(Error::MutuallyExclusive {
                first: "date_range",
                second: field,
            });
        }
        if hour_range.is_some() && *is_set {
            return Err(Error::MutuallyExclusive {
                first: "hour_range",
                second: field,
            });
        }
    }

    Ok(())
}

pub(crate) fn validate_optional_solar_angle(
    field: &'static str,
    value: Option<f32>,
    range: std::ops::RangeInclusive<f32>,
) -> Result<()> {
    if let Some(value) = value {
        validate_solar_angle(field, value, range)?;
    }

    Ok(())
}

fn validate_solar_angle(
    field: &'static str,
    value: f32,
    range: std::ops::RangeInclusive<f32>,
) -> Result<()> {
    // Open-Meteo uses NaN as an explicit tracker-mode sentinel for solar angles.
    if value.is_nan() || range.contains(&value) {
        return Ok(());
    }

    Err(Error::InvalidParam {
        field,
        reason: format!(
            "must be between {} and {}, or NaN",
            range.start(),
            range.end()
        ),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use time::macros::{date, datetime};

    #[test]
    fn validates_coordinate_bounds() {
        assert!(validate_coordinates(90.0, 180.0).is_ok());

        let err = validate_coordinates(90.1, 0.0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "latitude",
                ..
            }
        ));

        let err = validate_coordinates(0.0, 180.1).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "longitude",
                ..
            }
        ));
    }

    #[test]
    fn validates_upper_bounds() {
        assert!(validate_at_most("forecast_days", Some(8_u8), 8).is_ok());
        assert!(validate_at_most("forecast_days", None::<u8>, 8).is_ok());

        let err = validate_at_most("forecast_days", Some(9_u8), 8).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "forecast_days",
                ..
            }
        ));
    }

    #[test]
    fn detects_non_zero_optional_windows() {
        assert!(!is_non_zero_u8(None));
        assert!(!is_non_zero_u8(Some(0)));
        assert!(is_non_zero_u8(Some(1)));

        assert!(!is_non_zero_u16(None));
        assert!(!is_non_zero_u16(Some(0)));
        assert!(is_non_zero_u16(Some(1)));
    }

    #[test]
    fn validates_range_windows() {
        let err = validate_fixed_range_windows(
            Some((date!(2026 - 04 - 30), date!(2026 - 04 - 29))),
            None::<(PrimitiveDateTime, PrimitiveDateTime)>,
            &[],
        )
        .unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidParam {
                field: "date_range",
                ..
            }
        ));

        let err = validate_fixed_range_windows(
            Some((date!(2026 - 04 - 29), date!(2026 - 04 - 30))),
            Some((datetime!(2026-04-29 00:00), datetime!(2026-04-29 01:00))),
            &[],
        )
        .unwrap_err();
        assert!(matches!(
            err,
            Error::MutuallyExclusive {
                first: "date_range",
                second: "hour_range"
            }
        ));

        let err = validate_fixed_range_windows(
            Some((date!(2026 - 04 - 29), date!(2026 - 04 - 30))),
            None::<(PrimitiveDateTime, PrimitiveDateTime)>,
            &[("forecast_days", true)],
        )
        .unwrap_err();
        assert!(matches!(
            err,
            Error::MutuallyExclusive {
                first: "date_range",
                second: "forecast_days"
            }
        ));
    }
}