Skip to main content

gie_client/common/
date_range.rs

1use std::ops::RangeInclusive;
2
3use crate::error::GieError;
4
5use super::types::{GieDate, format_date};
6
7/// Inclusive date range used in query filters.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct DateRange {
10    start: GieDate,
11    end: GieDate,
12}
13
14impl DateRange {
15    /// Creates a validated date range (`start <= end`).
16    pub fn new(start: GieDate, end: GieDate) -> Result<Self, GieError> {
17        if start <= end {
18            Ok(Self { start, end })
19        } else {
20            Err(GieError::InvalidDateRangeInput(format!(
21                "from must be less than or equal to to (from={}, to={})",
22                format_date(start),
23                format_date(end)
24            )))
25        }
26    }
27
28    /// Inclusive range start.
29    pub fn start(self) -> GieDate {
30        self.start
31    }
32
33    /// Inclusive range end.
34    pub fn end(self) -> GieDate {
35        self.end
36    }
37
38    /// Alias for [`Self::start`] kept for compatibility with `from` query parameter naming.
39    pub fn from(self) -> GieDate {
40        self.start()
41    }
42
43    /// Alias for [`Self::end`] kept for compatibility with `to` query parameter naming.
44    pub fn to(self) -> GieDate {
45        self.end()
46    }
47
48    /// Returns `true` when `date` is inside this range.
49    pub fn contains(self, date: GieDate) -> bool {
50        self.start <= date && date <= self.end
51    }
52
53    /// Returns `true` when ranges share at least one date.
54    pub fn intersects(self, other: Self) -> bool {
55        self.start <= other.end && other.start <= self.end
56    }
57
58    /// Returns `true` when this range covers exactly one day.
59    pub fn is_single_day(self) -> bool {
60        self.start == self.end
61    }
62
63    /// Converts range into `(start, end)` bounds.
64    pub fn into_bounds(self) -> (GieDate, GieDate) {
65        (self.start, self.end)
66    }
67
68    /// Returns the standard inclusive range representation.
69    pub fn as_inclusive(self) -> RangeInclusive<GieDate> {
70        self.start..=self.end
71    }
72}
73
74impl TryFrom<(GieDate, GieDate)> for DateRange {
75    type Error = GieError;
76
77    fn try_from(value: (GieDate, GieDate)) -> Result<Self, Self::Error> {
78        Self::new(value.0, value.1)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::common::types::parse_date;
86
87    fn test_date(value: &str) -> GieDate {
88        parse_date(value).unwrap()
89    }
90
91    #[test]
92    fn accepts_valid_bounds() {
93        let range = DateRange::new(test_date("2026-03-01"), test_date("2026-03-10")).unwrap();
94
95        assert_eq!(range.start(), test_date("2026-03-01"));
96        assert_eq!(range.end(), test_date("2026-03-10"));
97    }
98
99    #[test]
100    fn rejects_invalid_bounds() {
101        let error = DateRange::new(test_date("2026-03-10"), test_date("2026-03-01")).unwrap_err();
102        assert!(matches!(error, GieError::InvalidDateRangeInput(_)));
103    }
104
105    #[test]
106    fn keeps_from_to_aliases() {
107        let range = DateRange::new(test_date("2026-03-01"), test_date("2026-03-10")).unwrap();
108
109        assert_eq!(range.from(), test_date("2026-03-01"));
110        assert_eq!(range.to(), test_date("2026-03-10"));
111    }
112
113    #[test]
114    fn detects_contains_correctly() {
115        let range = DateRange::new(test_date("2026-03-01"), test_date("2026-03-10")).unwrap();
116
117        assert!(range.contains(test_date("2026-03-01")));
118        assert!(range.contains(test_date("2026-03-10")));
119        assert!(range.contains(test_date("2026-03-05")));
120        assert!(!range.contains(test_date("2026-02-28")));
121        assert!(!range.contains(test_date("2026-03-11")));
122    }
123
124    #[test]
125    fn detects_intersection_correctly() {
126        let left = DateRange::new(test_date("2026-03-01"), test_date("2026-03-10")).unwrap();
127        let overlap = DateRange::new(test_date("2026-03-10"), test_date("2026-03-15")).unwrap();
128        let disjoint = DateRange::new(test_date("2026-03-11"), test_date("2026-03-20")).unwrap();
129
130        assert!(left.intersects(overlap));
131        assert!(!left.intersects(disjoint));
132    }
133
134    #[test]
135    fn exposes_shape_helpers() {
136        let single = DateRange::new(test_date("2026-03-05"), test_date("2026-03-05")).unwrap();
137        let multiple = DateRange::new(test_date("2026-03-01"), test_date("2026-03-10")).unwrap();
138
139        assert!(single.is_single_day());
140        assert!(!multiple.is_single_day());
141        assert_eq!(
142            multiple.into_bounds(),
143            (test_date("2026-03-01"), test_date("2026-03-10"))
144        );
145    }
146
147    #[test]
148    fn can_be_built_via_try_from_tuple() {
149        let range =
150            DateRange::try_from((test_date("2026-03-01"), test_date("2026-03-10"))).unwrap();
151
152        assert_eq!(range.start(), test_date("2026-03-01"));
153        assert_eq!(range.end(), test_date("2026-03-10"));
154    }
155}