use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use crate::error::{Error, Result};
use crate::types::wire::wire_enum;
wire_enum! {
pub enum TimeFrameUnit {
Minute => "Min",
Hour => "Hour",
Day => "Day",
Week => "Week",
Month => "Month",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TimeFrame {
amount: u32,
unit: TimeFrameUnit,
}
impl TimeFrame {
pub fn new(amount: u32, unit: TimeFrameUnit) -> Result<Self> {
Self::validate(amount, &unit)?;
Ok(Self { amount, unit })
}
#[must_use]
pub fn amount(&self) -> u32 {
self.amount
}
#[must_use]
pub fn unit(&self) -> &TimeFrameUnit {
&self.unit
}
#[must_use]
pub fn minute() -> Self {
Self {
amount: 1,
unit: TimeFrameUnit::Minute,
}
}
#[must_use]
pub fn hour() -> Self {
Self {
amount: 1,
unit: TimeFrameUnit::Hour,
}
}
#[must_use]
pub fn day() -> Self {
Self {
amount: 1,
unit: TimeFrameUnit::Day,
}
}
#[must_use]
pub fn week() -> Self {
Self {
amount: 1,
unit: TimeFrameUnit::Week,
}
}
#[must_use]
pub fn month() -> Self {
Self {
amount: 1,
unit: TimeFrameUnit::Month,
}
}
fn validate(amount: u32, unit: &TimeFrameUnit) -> Result<()> {
let invalid = |reason: &str| Err(Error::InvalidRequest(reason.to_owned()));
if amount == 0 {
return invalid("amount must be a positive integer value");
}
match unit {
TimeFrameUnit::Minute if amount > 59 => {
invalid("minute units can only be used with amounts between 1 and 59")
}
TimeFrameUnit::Hour if amount > 23 => {
invalid("hour units can only be used with amounts between 1 and 23")
}
TimeFrameUnit::Day | TimeFrameUnit::Week if amount != 1 => {
invalid("day and week units can only be used with amount 1")
}
TimeFrameUnit::Month if !matches!(amount, 1 | 2 | 3 | 6 | 12) => {
invalid("month units can only be used with amount 1, 2, 3, 6, or 12")
}
_ => Ok(()),
}
}
}
impl fmt::Display for TimeFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.amount, self.unit.as_str())
}
}
impl FromStr for TimeFrame {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
let split = value
.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| Error::InvalidRequest(format!("{value:?} has no time frame unit")))?;
let (amount, unit) = value.split_at(split);
let amount = amount
.parse::<u32>()
.map_err(|_| Error::InvalidRequest(format!("{value:?} has no time frame amount")))?;
Self::new(amount, TimeFrameUnit::from(unit))
}
}
impl Serialize for TimeFrame {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for TimeFrame {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::from_str(&raw).map_err(de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shorthand_constructors_build_the_documented_units() {
assert_eq!(TimeFrame::minute().to_string(), "1Min");
assert_eq!(TimeFrame::hour().to_string(), "1Hour");
assert_eq!(TimeFrame::day().to_string(), "1Day");
assert_eq!(TimeFrame::week().to_string(), "1Week");
assert_eq!(TimeFrame::month().to_string(), "1Month");
}
#[test]
fn valid_multiples_are_accepted() {
for (amount, unit) in [
(1, TimeFrameUnit::Minute),
(59, TimeFrameUnit::Minute),
(23, TimeFrameUnit::Hour),
(1, TimeFrameUnit::Day),
(1, TimeFrameUnit::Week),
(12, TimeFrameUnit::Month),
] {
assert!(
TimeFrame::new(amount, unit.clone()).is_ok(),
"{amount}{unit} should be valid"
);
}
}
#[test]
fn invalid_multiples_are_rejected_locally() {
for (amount, unit) in [
(0, TimeFrameUnit::Minute),
(60, TimeFrameUnit::Minute),
(24, TimeFrameUnit::Hour),
(2, TimeFrameUnit::Day),
(2, TimeFrameUnit::Week),
(4, TimeFrameUnit::Month),
(5, TimeFrameUnit::Month),
] {
assert!(
TimeFrame::new(amount, unit.clone()).is_err(),
"{amount}{unit} should be rejected"
);
}
}
#[test]
fn parses_from_its_own_rendering() {
for text in ["1Min", "15Min", "1Hour", "4Hour", "1Day", "1Week", "3Month"] {
let parsed: TimeFrame = text.parse().unwrap();
assert_eq!(parsed.to_string(), text);
}
}
#[test]
fn parsing_rejects_malformed_input() {
for text in ["Min", "", "15", "0Min", "60Min"] {
assert!(text.parse::<TimeFrame>().is_err(), "{text:?} should fail");
}
}
#[test]
fn serializes_to_the_query_parameter_form() {
let frame = TimeFrame::new(5, TimeFrameUnit::Minute).unwrap();
assert_eq!(serde_json::to_string(&frame).unwrap(), r#""5Min""#);
let decoded: TimeFrame = serde_json::from_str(r#""5Min""#).unwrap();
assert_eq!(decoded, frame);
}
#[test]
fn an_unknown_unit_is_left_for_the_api_to_reject() {
let frame = TimeFrame::new(2, TimeFrameUnit::from("Quarter")).unwrap();
assert_eq!(frame.to_string(), "2Quarter");
}
}