Skip to main content

omni_dev/drive/sheets/
date_value.rs

1//! Sheets' relative-date keywords, the date-condition operand shape they
2//! feed into, and the condition-value validators both callers' `validate_*`
3//! functions need.
4//!
5//! Shared by [`crate::drive::sheets::validation`] (`DATE_AFTER`/
6//! `DATE_BEFORE`/`DATE_EQ`) and [`crate::drive::sheets::conditional_format`]
7//! (`BooleanRule` conditions of the same shape) — extracted here, issue
8//! #1793, since it's generic over both callers' condition enums rather than
9//! coupled to either one. The `reject_*` validators below are the same
10//! reasoning applied to the actual value-rejection logic: both files'
11//! condition enums overlap on the numeric/text/date value shapes, and this
12//! is where a fix to one (e.g. the NaN/reversed-range checks) reaches both
13//! rather than only the file it was written in.
14
15use crate::drive::sheets::types::ConditionValue;
16
17/// One of Sheets' six `RelativeDate` values.
18///
19/// Usable wherever a date condition takes a single value
20/// (`DATE_AFTER`/`DATE_BEFORE`/`DATE_EQ`); `DATE_BETWEEN` requires two
21/// absolute dates and never accepts one of these.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RelativeDate {
24    /// The 365 days up to and including today.
25    PastYear,
26    /// The 30 days up to and including today.
27    PastMonth,
28    /// The 7 days up to and including today.
29    PastWeek,
30    /// The day before today.
31    Yesterday,
32    /// Today.
33    Today,
34    /// The day after today.
35    Tomorrow,
36}
37
38impl RelativeDate {
39    /// Every variant, in no particular order. `parse` derives from this (via
40    /// [`Self::as_sheets_str`]) rather than keeping its own hardcoded string
41    /// table, so the two can't drift apart on a typo; the
42    /// `relative_date_all_covers_every_variant` test's exhaustive match
43    /// forces this list to grow alongside the enum.
44    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    /// Matches case- and separator-insensitively (`past-week`, `past_week`,
65    /// `Past Week` all match) so the CLI value doesn't force one style.
66    ///
67    /// Deriving this from [`Self::as_sheets_str`] via [`Self::ALL`] costs a
68    /// per-candidate allocation (up to 6, plus one for `normalized`) instead
69    /// of a single hand-written match — negligible for a one-shot CLI
70    /// parse of 6 short constants, and deliberate: it's what keeps this
71    /// keyword table and `as_sheets_str` from drifting apart on a typo (see
72    /// `relative_date_all_covers_every_variant`). Don't "optimize" this back
73    /// into a second hardcoded match.
74    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/// A date condition's operand.
83///
84/// Either a literal date string (passed through untouched, the same
85/// trust-the-caller stance as every other numeric/text value in this file —
86/// Sheets parses it at evaluation time) or one of the six [`RelativeDate`]
87/// keywords.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum DateValue {
90    /// A literal date string, untouched.
91    Absolute(String),
92    /// One of Sheets' relative-date keywords.
93    Relative(RelativeDate),
94}
95
96impl DateValue {
97    /// Never fails: anything that isn't a recognized relative keyword is
98    /// treated as a literal date string.
99    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
124/// `NaN > x` and `x > NaN` are both `false`, so a NaN bound must be checked
125/// explicitly or it silently reaches the API.
126///
127/// Shared by `validation.rs` and `conditional_format.rs` (issue #1793) — the
128/// same numeric-range check both files' `--*-between` flags need.
129pub(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
155/// Like [`reject_blank`], but only a zero-length string counts as empty — a
156/// whitespace-only value is a meaningful thing to search for.
157pub(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
173/// `--date-between` takes two absolute dates only (see its own doc comment):
174/// it never accepts a relative keyword, so one must be rejected loudly here
175/// rather than silently reaching the API as a literal `userEnteredValue`
176/// string. Also mirrors [`reject_reversed_range`]'s ordering check for the
177/// numeric family, on a best-effort basis: a bound that doesn't parse as an
178/// ISO `YYYY-MM-DD` date is trusted through untouched, the same
179/// trust-the-caller stance the rest of this file takes for formats Sheets
180/// itself will parse at evaluation time.
181pub(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        // Exhaustive match, no `_` arm: adding a `RelativeDate` variant
226        // without adding it to `RelativeDate::ALL` fails to compile here,
227        // which is what forces `parse` (derived from `ALL`) to cover it too.
228        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    // ── shared validators (used by both `validation.rs` and
245    // `conditional_format.rs`) ──────────────────────────────────────────
246
247    #[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}