Skip to main content

waka_api/
params.rs

1//! Query parameter builders for `WakaTime` API endpoints.
2
3use chrono::{Local, NaiveDate};
4
5// ─────────────────────────────────────────────────────────────────────────────
6// StatsRange
7// ─────────────────────────────────────────────────────────────────────────────
8
9/// Predefined time ranges accepted by the `GET /users/current/stats/{range}`
10/// endpoint.
11///
12/// This enum is `#[non_exhaustive]` — new ranges may be added in future
13/// minor versions as the `WakaTime` API evolves. Always include a wildcard
14/// arm when matching on it.
15///
16/// # Example
17///
18/// ```rust
19/// use waka_api::StatsRange;
20///
21/// let path_segment = StatsRange::Last7Days.as_str();
22/// assert_eq!(path_segment, "last_7_days");
23/// ```
24#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StatsRange {
27    /// The last 7 days.
28    Last7Days,
29    /// The last 30 days.
30    Last30Days,
31    /// The last 6 months.
32    Last6Months,
33    /// The last year.
34    LastYear,
35    /// All time since the account was created.
36    AllTime,
37}
38
39impl StatsRange {
40    /// Returns the URL path segment for this range as expected by the API.
41    #[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/// Query parameters for the `GET /users/current/summaries` endpoint.
54///
55/// # Example
56///
57/// ```rust
58/// use waka_api::SummaryParams;
59///
60/// // Fetch today's summary
61/// let p = SummaryParams::today();
62///
63/// // Fetch the last 7 days, filtered to one project
64/// use chrono::NaiveDate;
65/// let p = SummaryParams::for_range(
66///     NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(),
67///     NaiveDate::from_ymd_opt(2025, 1, 12).unwrap(),
68/// )
69/// .project("my-saas");
70/// ```
71#[derive(Debug, Clone)]
72pub struct SummaryParams {
73    /// Inclusive start date (`start_date` query param, `YYYY-MM-DD`).
74    pub(crate) start: NaiveDate,
75    /// Inclusive end date (`end_date` query param, `YYYY-MM-DD`).
76    pub(crate) end: NaiveDate,
77    /// Optional project filter.
78    pub(crate) project: Option<String>,
79    /// Optional comma-separated branch filter.
80    pub(crate) branches: Option<String>,
81}
82
83impl SummaryParams {
84    /// Creates params covering only today's date (local timezone).
85    #[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    /// Creates params covering the given inclusive date range.
97    #[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    /// Filters results to the named project (builder, consumes `self`).
108    #[must_use]
109    pub fn project(mut self, project: &str) -> Self {
110        self.project = Some(project.to_owned());
111        self
112    }
113
114    /// Filters results to the named branches, comma-separated (builder).
115    #[must_use]
116    pub fn branches(mut self, branches: &str) -> Self {
117        self.branches = Some(branches.to_owned());
118        self
119    }
120
121    /// Returns a stable cache key string that uniquely identifies this set of
122    /// parameters.
123    ///
124    /// Format: `summaries:{start}:{end}` (with optional `:project:{name}` suffix).
125    ///
126    /// # Example
127    ///
128    /// ```rust
129    /// use waka_api::SummaryParams;
130    /// use chrono::NaiveDate;
131    ///
132    /// let p = SummaryParams::for_range(
133    ///     NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(),
134    ///     NaiveDate::from_ymd_opt(2025, 1, 12).unwrap(),
135    /// ).project("my-saas");
136    ///
137    /// assert_eq!(p.cache_key(), "summaries:2025-01-06:2025-01-12:project:my-saas");
138    /// ```
139    #[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    /// Converts to a list of `(key, value)` pairs suitable for a query string.
153    ///
154    /// Dates are formatted as `YYYY-MM-DD` as required by the `WakaTime` API.
155    #[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// ─── Unit tests ───────────────────────────────────────────────────────────────
175
176#[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    // ── cache_key ──────────────────────────────────────────────────────────────
251
252    #[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    // ── StatsRange ─────────────────────────────────────────────────────────────
268
269    #[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}