1use chrono::{Local, NaiveDate};
4
5#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StatsRange {
27 Last7Days,
29 Last30Days,
31 Last6Months,
33 LastYear,
35 AllTime,
37}
38
39impl StatsRange {
40 #[must_use]
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Last7Days => "last_7_days",
45 Self::Last30Days => "last_30_days",
46 Self::Last6Months => "last_6_months",
47 Self::LastYear => "last_year",
48 Self::AllTime => "all_time",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
72pub struct SummaryParams {
73 pub(crate) start: NaiveDate,
75 pub(crate) end: NaiveDate,
77 pub(crate) project: Option<String>,
79 pub(crate) branches: Option<String>,
81}
82
83impl SummaryParams {
84 #[must_use]
86 pub fn today() -> Self {
87 let today = Local::now().date_naive();
88 Self {
89 start: today,
90 end: today,
91 project: None,
92 branches: None,
93 }
94 }
95
96 #[must_use]
98 pub fn for_range(start: NaiveDate, end: NaiveDate) -> Self {
99 Self {
100 start,
101 end,
102 project: None,
103 branches: None,
104 }
105 }
106
107 #[must_use]
109 pub fn project(mut self, project: &str) -> Self {
110 self.project = Some(project.to_owned());
111 self
112 }
113
114 #[must_use]
116 pub fn branches(mut self, branches: &str) -> Self {
117 self.branches = Some(branches.to_owned());
118 self
119 }
120
121 #[must_use]
140 pub fn cache_key(&self) -> String {
141 let base = format!(
142 "summaries:{}:{}",
143 self.start.format("%Y-%m-%d"),
144 self.end.format("%Y-%m-%d"),
145 );
146 match &self.project {
147 Some(p) => format!("{base}:project:{p}"),
148 None => base,
149 }
150 }
151
152 #[must_use]
156 pub(crate) fn to_query_pairs(&self) -> Vec<(String, String)> {
157 let mut pairs = vec![
158 (
159 "start".to_owned(),
160 self.start.format("%Y-%m-%d").to_string(),
161 ),
162 ("end".to_owned(), self.end.format("%Y-%m-%d").to_string()),
163 ];
164 if let Some(p) = &self.project {
165 pairs.push(("project".to_owned(), p.clone()));
166 }
167 if let Some(b) = &self.branches {
168 pairs.push(("branches".to_owned(), b.clone()));
169 }
170 pairs
171 }
172}
173
174#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn date(y: i32, m: u32, d: u32) -> NaiveDate {
181 NaiveDate::from_ymd_opt(y, m, d).unwrap()
182 }
183
184 #[test]
185 fn today_has_equal_start_and_end() {
186 let p = SummaryParams::today();
187 assert_eq!(p.start, p.end);
188 assert_eq!(p.start, Local::now().date_naive());
189 }
190
191 #[test]
192 fn for_range_stores_dates() {
193 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12));
194 assert_eq!(p.start, date(2025, 1, 6));
195 assert_eq!(p.end, date(2025, 1, 12));
196 }
197
198 #[test]
199 fn to_query_pairs_formats_dates_as_yyyy_mm_dd() {
200 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12));
201 let pairs = p.to_query_pairs();
202 assert_eq!(pairs[0], ("start".to_owned(), "2025-01-06".to_owned()));
203 assert_eq!(pairs[1], ("end".to_owned(), "2025-01-12".to_owned()));
204 }
205
206 #[test]
207 fn to_query_pairs_omits_optional_fields_when_none() {
208 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12));
209 let pairs = p.to_query_pairs();
210 assert_eq!(pairs.len(), 2);
211 }
212
213 #[test]
214 fn to_query_pairs_includes_project_when_set() {
215 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12)).project("my-saas");
216 let pairs = p.to_query_pairs();
217 assert!(pairs.iter().any(|(k, v)| k == "project" && v == "my-saas"));
218 }
219
220 #[test]
221 fn to_query_pairs_includes_branches_when_set() {
222 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12)).branches("main,dev");
223 let pairs = p.to_query_pairs();
224 assert!(pairs
225 .iter()
226 .any(|(k, v)| k == "branches" && v == "main,dev"));
227 }
228
229 #[test]
230 fn project_builder_sets_project() {
231 let p = SummaryParams::today().project("acme");
232 assert_eq!(p.project.as_deref(), Some("acme"));
233 }
234
235 #[test]
236 fn branches_builder_sets_branches() {
237 let p = SummaryParams::today().branches("main");
238 assert_eq!(p.branches.as_deref(), Some("main"));
239 }
240
241 #[test]
242 fn full_params_produce_four_pairs() {
243 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12))
244 .project("proj")
245 .branches("main");
246 let pairs = p.to_query_pairs();
247 assert_eq!(pairs.len(), 4);
248 }
249
250 #[test]
253 fn cache_key_no_project() {
254 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12));
255 assert_eq!(p.cache_key(), "summaries:2025-01-06:2025-01-12");
256 }
257
258 #[test]
259 fn cache_key_with_project() {
260 let p = SummaryParams::for_range(date(2025, 1, 6), date(2025, 1, 12)).project("my-saas");
261 assert_eq!(
262 p.cache_key(),
263 "summaries:2025-01-06:2025-01-12:project:my-saas"
264 );
265 }
266
267 #[test]
270 fn stats_range_as_str_last_7_days() {
271 assert_eq!(StatsRange::Last7Days.as_str(), "last_7_days");
272 }
273
274 #[test]
275 fn stats_range_as_str_all_time() {
276 assert_eq!(StatsRange::AllTime.as_str(), "all_time");
277 }
278
279 #[test]
280 fn stats_range_all_variants_have_distinct_str() {
281 use std::collections::HashSet;
282 let variants = [
283 StatsRange::Last7Days,
284 StatsRange::Last30Days,
285 StatsRange::Last6Months,
286 StatsRange::LastYear,
287 StatsRange::AllTime,
288 ];
289 let strs: HashSet<&str> = variants.iter().map(|v| v.as_str()).collect();
290 assert_eq!(strs.len(), variants.len(), "all variants must be distinct");
291 }
292}