omni_dev/drive/sheets/
date_value.rs1use crate::drive::sheets::types::ConditionValue;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RelativeDate {
24 PastYear,
26 PastMonth,
28 PastWeek,
30 Yesterday,
32 Today,
34 Tomorrow,
36}
37
38impl RelativeDate {
39 const ALL: [Self; 6] = [
45 Self::PastYear,
46 Self::PastMonth,
47 Self::PastWeek,
48 Self::Yesterday,
49 Self::Today,
50 Self::Tomorrow,
51 ];
52
53 pub(crate) const fn as_sheets_str(self) -> &'static str {
54 match self {
55 Self::PastYear => "PAST_YEAR",
56 Self::PastMonth => "PAST_MONTH",
57 Self::PastWeek => "PAST_WEEK",
58 Self::Yesterday => "YESTERDAY",
59 Self::Today => "TODAY",
60 Self::Tomorrow => "TOMORROW",
61 }
62 }
63
64 pub(crate) fn parse(s: &str) -> Option<Self> {
75 let normalized = s.to_ascii_lowercase().replace(['-', '_', ' '], "");
76 Self::ALL
77 .into_iter()
78 .find(|rd| rd.as_sheets_str().to_ascii_lowercase().replace('_', "") == normalized)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum DateValue {
90 Absolute(String),
92 Relative(RelativeDate),
94}
95
96impl DateValue {
97 pub fn parse(raw: String) -> Self {
100 match RelativeDate::parse(&raw) {
101 Some(rd) => Self::Relative(rd),
102 None => Self::Absolute(raw),
103 }
104 }
105
106 pub(crate) fn is_blank(&self) -> bool {
107 matches!(self, Self::Absolute(s) if s.trim().is_empty())
108 }
109
110 pub(crate) fn into_condition_value(self) -> ConditionValue {
111 match self {
112 Self::Absolute(s) => ConditionValue {
113 user_entered_value: Some(s),
114 relative_date: None,
115 },
116 Self::Relative(rd) => ConditionValue {
117 user_entered_value: None,
118 relative_date: Some(rd.as_sheets_str().to_string()),
119 },
120 }
121 }
122}
123
124pub(crate) fn reject_reversed_range(min: f64, max: f64, flag: &str) -> Result<(), String> {
130 if min.is_nan() || max.is_nan() || min > max {
131 Err(format!(
132 "{flag}'s first value ({min}) must not exceed the second ({max})"
133 ))
134 } else {
135 Ok(())
136 }
137}
138
139pub(crate) fn reject_nan(n: f64, flag: &str) -> Result<(), String> {
140 if n.is_nan() {
141 Err(format!("{flag} must not be NaN"))
142 } else {
143 Ok(())
144 }
145}
146
147pub(crate) fn reject_blank(text: &str, flag: &str) -> Result<(), String> {
148 if text.trim().is_empty() {
149 Err(format!("{flag} must not be empty"))
150 } else {
151 Ok(())
152 }
153}
154
155pub(crate) fn reject_empty(text: &str, flag: &str) -> Result<(), String> {
158 if text.is_empty() {
159 Err(format!("{flag} must not be empty"))
160 } else {
161 Ok(())
162 }
163}
164
165pub(crate) fn reject_blank_date(date: &DateValue, flag: &str) -> Result<(), String> {
166 if date.is_blank() {
167 Err(format!("{flag} must not be empty"))
168 } else {
169 Ok(())
170 }
171}
172
173pub(crate) fn reject_invalid_date_between(start: &str, end: &str) -> Result<(), String> {
182 reject_blank(start, "--date-between")?;
183 reject_blank(end, "--date-between")?;
184 if RelativeDate::parse(start).is_some() || RelativeDate::parse(end).is_some() {
185 return Err(
186 "--date-between only accepts absolute dates, not a relative keyword like 'today'"
187 .to_string(),
188 );
189 }
190 if let (Some(start_date), Some(end_date)) = (parse_iso_date(start), parse_iso_date(end)) {
191 if start_date > end_date {
192 return Err(format!(
193 "--date-between's first value ({start}) must not be after the second ({end})"
194 ));
195 }
196 }
197 Ok(())
198}
199
200pub(crate) fn parse_iso_date(s: &str) -> Option<chrono::NaiveDate> {
201 chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn date_value_parse_is_case_and_separator_insensitive() {
211 for input in ["past-week", "PAST_WEEK", "Past Week"] {
212 assert!(matches!(
213 DateValue::parse(input.to_string()),
214 DateValue::Relative(RelativeDate::PastWeek)
215 ));
216 }
217 assert!(matches!(
218 DateValue::parse("2024-01-01".to_string()),
219 DateValue::Absolute(s) if s == "2024-01-01"
220 ));
221 }
222
223 #[test]
224 fn relative_date_all_covers_every_variant() {
225 fn assert_is_a_relative_date(rd: RelativeDate) {
229 match rd {
230 RelativeDate::PastYear
231 | RelativeDate::PastMonth
232 | RelativeDate::PastWeek
233 | RelativeDate::Yesterday
234 | RelativeDate::Today
235 | RelativeDate::Tomorrow => {}
236 }
237 }
238 for rd in RelativeDate::ALL {
239 assert_is_a_relative_date(rd);
240 assert_eq!(RelativeDate::parse(rd.as_sheets_str()), Some(rd));
241 }
242 }
243
244 #[test]
248 fn reject_reversed_range_rejects_nan_and_reversed_bounds() {
249 assert!(reject_reversed_range(1.0, 2.0, "--flag").is_ok());
250 assert!(reject_reversed_range(2.0, 1.0, "--flag").is_err());
251 assert!(reject_reversed_range(f64::NAN, 1.0, "--flag").is_err());
252 }
253
254 #[test]
255 fn reject_invalid_date_between_rejects_a_relative_keyword_and_a_reversed_range() {
256 assert!(reject_invalid_date_between("2024-01-01", "2024-01-02").is_ok());
257 let err = reject_invalid_date_between("today", "2024-01-02").unwrap_err();
258 assert!(err.contains("relative keyword"), "{err}");
259 let err = reject_invalid_date_between("2024-01-02", "2024-01-01").unwrap_err();
260 assert!(err.contains("must not be after"), "{err}");
261 }
262
263 #[test]
264 fn parse_iso_date_accepts_only_the_yyyy_mm_dd_shape() {
265 assert!(parse_iso_date("2024-01-01").is_some());
266 assert!(parse_iso_date("not-a-date").is_none());
267 }
268}