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<()> {
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"
}
));
}
}