use crate::drive::sheets::types::ConditionValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelativeDate {
PastYear,
PastMonth,
PastWeek,
Yesterday,
Today,
Tomorrow,
}
impl RelativeDate {
const ALL: [Self; 6] = [
Self::PastYear,
Self::PastMonth,
Self::PastWeek,
Self::Yesterday,
Self::Today,
Self::Tomorrow,
];
pub(crate) const fn as_sheets_str(self) -> &'static str {
match self {
Self::PastYear => "PAST_YEAR",
Self::PastMonth => "PAST_MONTH",
Self::PastWeek => "PAST_WEEK",
Self::Yesterday => "YESTERDAY",
Self::Today => "TODAY",
Self::Tomorrow => "TOMORROW",
}
}
pub(crate) fn parse(s: &str) -> Option<Self> {
let normalized = s.to_ascii_lowercase().replace(['-', '_', ' '], "");
Self::ALL
.into_iter()
.find(|rd| rd.as_sheets_str().to_ascii_lowercase().replace('_', "") == normalized)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateValue {
Absolute(String),
Relative(RelativeDate),
}
impl DateValue {
pub fn parse(raw: String) -> Self {
match RelativeDate::parse(&raw) {
Some(rd) => Self::Relative(rd),
None => Self::Absolute(raw),
}
}
pub(crate) fn is_blank(&self) -> bool {
matches!(self, Self::Absolute(s) if s.trim().is_empty())
}
pub(crate) fn into_condition_value(self) -> ConditionValue {
match self {
Self::Absolute(s) => ConditionValue {
user_entered_value: Some(s),
relative_date: None,
},
Self::Relative(rd) => ConditionValue {
user_entered_value: None,
relative_date: Some(rd.as_sheets_str().to_string()),
},
}
}
}
pub(crate) fn reject_reversed_range(min: f64, max: f64, flag: &str) -> Result<(), String> {
if min.is_nan() || max.is_nan() || min > max {
Err(format!(
"{flag}'s first value ({min}) must not exceed the second ({max})"
))
} else {
Ok(())
}
}
pub(crate) fn reject_nan(n: f64, flag: &str) -> Result<(), String> {
if n.is_nan() {
Err(format!("{flag} must not be NaN"))
} else {
Ok(())
}
}
pub(crate) fn reject_blank(text: &str, flag: &str) -> Result<(), String> {
if text.trim().is_empty() {
Err(format!("{flag} must not be empty"))
} else {
Ok(())
}
}
pub(crate) fn reject_empty(text: &str, flag: &str) -> Result<(), String> {
if text.is_empty() {
Err(format!("{flag} must not be empty"))
} else {
Ok(())
}
}
pub(crate) fn reject_blank_date(date: &DateValue, flag: &str) -> Result<(), String> {
if date.is_blank() {
Err(format!("{flag} must not be empty"))
} else {
Ok(())
}
}
pub(crate) fn reject_invalid_date_between(start: &str, end: &str) -> Result<(), String> {
reject_blank(start, "--date-between")?;
reject_blank(end, "--date-between")?;
if RelativeDate::parse(start).is_some() || RelativeDate::parse(end).is_some() {
return Err(
"--date-between only accepts absolute dates, not a relative keyword like 'today'"
.to_string(),
);
}
if let (Some(start_date), Some(end_date)) = (parse_iso_date(start), parse_iso_date(end)) {
if start_date > end_date {
return Err(format!(
"--date-between's first value ({start}) must not be after the second ({end})"
));
}
}
Ok(())
}
pub(crate) fn parse_iso_date(s: &str) -> Option<chrono::NaiveDate> {
chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn date_value_parse_is_case_and_separator_insensitive() {
for input in ["past-week", "PAST_WEEK", "Past Week"] {
assert!(matches!(
DateValue::parse(input.to_string()),
DateValue::Relative(RelativeDate::PastWeek)
));
}
assert!(matches!(
DateValue::parse("2024-01-01".to_string()),
DateValue::Absolute(s) if s == "2024-01-01"
));
}
#[test]
fn relative_date_all_covers_every_variant() {
fn assert_is_a_relative_date(rd: RelativeDate) {
match rd {
RelativeDate::PastYear
| RelativeDate::PastMonth
| RelativeDate::PastWeek
| RelativeDate::Yesterday
| RelativeDate::Today
| RelativeDate::Tomorrow => {}
}
}
for rd in RelativeDate::ALL {
assert_is_a_relative_date(rd);
assert_eq!(RelativeDate::parse(rd.as_sheets_str()), Some(rd));
}
}
#[test]
fn reject_reversed_range_rejects_nan_and_reversed_bounds() {
assert!(reject_reversed_range(1.0, 2.0, "--flag").is_ok());
assert!(reject_reversed_range(2.0, 1.0, "--flag").is_err());
assert!(reject_reversed_range(f64::NAN, 1.0, "--flag").is_err());
}
#[test]
fn reject_invalid_date_between_rejects_a_relative_keyword_and_a_reversed_range() {
assert!(reject_invalid_date_between("2024-01-01", "2024-01-02").is_ok());
let err = reject_invalid_date_between("today", "2024-01-02").unwrap_err();
assert!(err.contains("relative keyword"), "{err}");
let err = reject_invalid_date_between("2024-01-02", "2024-01-01").unwrap_err();
assert!(err.contains("must not be after"), "{err}");
}
#[test]
fn parse_iso_date_accepts_only_the_yyyy_mm_dd_shape() {
assert!(parse_iso_date("2024-01-01").is_some());
assert!(parse_iso_date("not-a-date").is_none());
}
}