gitlab_time_report/
filters.rs

1//! Contains functions to filter and group [`TimeLog`].
2
3use crate::model::{Label, Milestone, TimeLog, TrackableItem, User};
4use chrono::{Duration, Local, NaiveDate};
5use std::collections::{BTreeMap, HashSet};
6
7/// Groups a list of nodes by a given filter. Returns an Iterator over a `BTreeMap`.
8/// # Parameter
9/// - `nodes`: A collection of time log nodes. Can be any collection that can be turned into an iterator.
10/// - `filter`: A function to group the nodes by, usually a closure. It takes a reference to a time
11///   log and returns a reference to the item to group by.
12///
13/// The lifetime annotation `'a` means that the references to `T` and `TimeLog` must both live at
14/// least as long as each other.
15pub fn group_by_filter<'a, T, I, F>(
16    nodes: I,
17    filter: F,
18) -> impl Iterator<Item = (&'a T, Vec<&'a TimeLog>)>
19where
20    T: Ord + 'a,
21    I: IntoIterator<Item = &'a TimeLog>,
22    F: Fn(&'a TimeLog) -> &'a T,
23{
24    let mut grouped = BTreeMap::<&'a T, Vec<&'a TimeLog>>::new();
25    for node in nodes {
26        let key = filter(node);
27        grouped.entry(key).or_default().push(node);
28    }
29    grouped.into_iter()
30}
31
32/// Groups a slice of nodes by their user and returns a iterator over a `BTreeMap`.
33/// # Example
34/// ```
35/// # use gitlab_time_report::model::{TimeLog, User};
36/// # use gitlab_time_report::filters::group_by_user;
37/// # use std::collections::BTreeMap;
38/// let nodes = vec![
39///     TimeLog { user: User { name: "user1".to_string(), username: "user1".to_string() }, ..Default::default() },
40///     TimeLog { user: User { name: "user1".to_string(), username: "user1".to_string() }, ..Default::default() },
41///     TimeLog { user: User { name: "user2".to_string(), username: "user2".to_string() }, ..Default::default() }
42/// ];
43/// let grouped_by_user = group_by_user(&nodes).collect::<BTreeMap<_, _>>();
44/// assert_eq!(grouped_by_user.len(), 2);
45/// assert_eq!(grouped_by_user.get(&User { name: "user1".to_string(), username: "user1".to_string() }).unwrap().len(), 2);
46/// assert_eq!(grouped_by_user.get(&User { name: "user2".to_string(), username: "user2".to_string() }).unwrap().len(), 1);
47/// ```
48pub fn group_by_user<'a>(
49    nodes: impl IntoIterator<Item = &'a TimeLog>,
50) -> impl Iterator<Item = (&'a User, Vec<&'a TimeLog>)> {
51    group_by_filter(nodes, |node| &node.user)
52}
53
54/// Filter the `TimeLog` by milestone and returns an iterator over `(Option<&Milestone>, Vec<&TimeLog>)`.
55pub fn group_by_milestone<'a>(
56    nodes: impl IntoIterator<Item = &'a TimeLog>,
57) -> impl Iterator<Item = (Option<&'a Milestone>, Vec<&'a TimeLog>)> {
58    group_by_filter(nodes, |node| &node.trackable_item.common.milestone)
59        .map(|(milestone, nodes)| (milestone.as_ref(), nodes))
60}
61
62/// Group the `TimeLog` by type (Issue/MR) and return an iterator. Will return a `String` containing the type name.
63/// Because `TrackableItemKind` is an enum where each variant carries data, it is not sensible to group directly on
64/// this type, because if the data inside is different, it counts as a different element.
65pub fn group_by_type<'a, 'b>(
66    nodes: impl IntoIterator<Item = &'a TimeLog>,
67) -> impl Iterator<Item = (String, Vec<&'a TimeLog>)> {
68    let mut grouped = BTreeMap::<String, Vec<&'a TimeLog>>::new();
69    for node in nodes {
70        let key = node.trackable_item.kind.to_string();
71        grouped.entry(key).or_default().push(node);
72    }
73    grouped.into_iter()
74}
75
76/// Group the `TimeLog` by trackable item (Issue/MR) and return an iterator.
77pub fn group_by_trackable_item<'a>(
78    time_logs: impl IntoIterator<Item = &'a TimeLog>,
79) -> impl Iterator<Item = (&'a TrackableItem, Vec<&'a TimeLog>)> {
80    group_by_filter(time_logs, |node| &node.trackable_item)
81}
82
83/// Group the `TimeLog`s by labels and return an iterator.
84/// Note that items with multiple labels are included multiple times.
85/// Items with no labels are included in the label `other_label`, if set.
86/// # Parameters
87/// - `selected_labels`: A list of labels that should be included in the result.
88///   If this `Some`, only `TimeLog`s with at least one of the selected labels
89///   are grouped under the corresponding label. If this is `None`, all labels are selected.
90/// - `other_label`: The label used for items without any of the selected labels.
91///   If this is `Some`, all items not in `selected_labels` are grouped under this label.
92///   If this is `None`, all items not in `selected_labels` are ignored.
93pub fn group_by_label<'a>(
94    nodes: impl IntoIterator<Item = &'a TimeLog>,
95    selected_labels: Option<&HashSet<String>>,
96    other_label: Option<&'a Label>,
97) -> impl Iterator<Item = (Option<&'a Label>, Vec<&'a TimeLog>)> {
98    let mut label_map = BTreeMap::<Option<&Label>, Vec<&TimeLog>>::new();
99
100    for time_log in nodes {
101        let labels = &time_log.trackable_item.common.labels.labels;
102
103        if labels.is_empty() {
104            // TrackableItem has no labels
105            // Add None entry if selected_labels is not set
106            if selected_labels.is_none() {
107                label_map.entry(None).or_default().push(time_log);
108                continue;
109            }
110
111            // Add to the "Other" label if selected_labels is set
112            if other_label.is_some() {
113                label_map.entry(other_label).or_default().push(time_log);
114            }
115            continue;
116        }
117
118        let mut matched_any_selected_label = false;
119
120        for label in labels {
121            // True if selected_labels is None or if the label is in the selected_labels
122            let should_include_label =
123                selected_labels.is_none_or(|sel_labels| sel_labels.contains(&label.title));
124
125            if should_include_label {
126                label_map.entry(Some(label)).or_default().push(time_log);
127                matched_any_selected_label = true;
128            }
129        }
130
131        // When label is not under the selected one, add to the "Other" label
132        if other_label.is_some() && !matched_any_selected_label {
133            label_map.entry(other_label).or_default().push(time_log);
134        }
135    }
136    label_map.into_iter()
137}
138
139/// Filter the `TimeLog` by date and return an iterator. The dates are both inclusive.
140pub fn filter_by_date<'a>(
141    nodes: impl IntoIterator<Item = &'a TimeLog>,
142    start: NaiveDate,
143    end: NaiveDate,
144) -> impl Iterator<Item = &'a TimeLog> {
145    nodes.into_iter().filter(move |node| {
146        let spent_day = node.spent_at.with_timezone(&Local).date_naive();
147        spent_day >= start && spent_day <= end
148    })
149}
150
151/// Returns the time logs in the last X days. The number of days is specified as `Duration`.
152/// 1 day is the current day, 2 days is today and yesterday.
153pub fn filter_by_last_n_days<'a>(
154    time_logs: impl IntoIterator<Item = &'a TimeLog>,
155    days: Duration,
156) -> impl Iterator<Item = &'a TimeLog> {
157    let end: NaiveDate = Local::now().date_naive();
158    let start: NaiveDate = end - days + Duration::days(1);
159    filter_by_date(time_logs, start, end)
160}
161
162/// Returns the total time spent on the project.
163#[must_use]
164pub fn total_time_spent<'a>(time_logs: impl IntoIterator<Item = &'a TimeLog>) -> Duration {
165    time_logs
166        .into_iter()
167        .map(|node| node.time_spent)
168        .sum::<Duration>()
169}
170
171/// Returns the total time spent on the project by every user.
172pub fn total_time_spent_by_user<'a>(
173    time_logs: impl IntoIterator<Item = &'a TimeLog>,
174) -> impl Iterator<Item = (&'a User, Duration)> {
175    group_by_user(time_logs).map(|(user, timelogs)| (user, total_time_spent(timelogs)))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::model::{
182        Issue, Labels, MergeRequest, TrackableItem, TrackableItemFields, TrackableItemKind,
183    };
184
185    const NUMBER_OF_LOGS: usize = 5;
186
187    #[expect(clippy::too_many_lines)]
188    fn get_timelogs() -> [TimeLog; NUMBER_OF_LOGS] {
189        let user1 = User {
190            name: "user1".to_string(),
191            username: "user1".to_string(),
192        };
193        let user2 = User {
194            name: "user2".to_string(),
195            username: "user2".to_string(),
196        };
197
198        [
199            TimeLog {
200                spent_at: Local::now() - Duration::days(2),
201                time_spent: Duration::seconds(3600),
202                summary: None,
203                user: user1.clone(),
204                trackable_item: TrackableItem::default(),
205            },
206            TimeLog {
207                spent_at: Local::now() - Duration::days(5),
208                time_spent: Duration::seconds(7200),
209                summary: Some("First entry".to_string()),
210                user: user2.clone(),
211                trackable_item: TrackableItem::default(),
212            },
213            TimeLog {
214                spent_at: Local::now() - Duration::days(1),
215                time_spent: Duration::seconds(3600),
216                summary: Some("test".to_string()),
217                user: user2.clone(),
218                trackable_item: TrackableItem {
219                    common: TrackableItemFields {
220                        id: 1,
221                        title: "Second Issue".to_string(),
222                        milestone: Some(Milestone {
223                            title: "End of Elaboration".to_string(),
224                            ..Default::default()
225                        }),
226                        labels: Labels {
227                            labels: vec![Label {
228                                title: "Documentation".into(),
229                            }],
230                        },
231                        time_estimate: Duration::seconds(7200),
232                        ..Default::default()
233                    },
234                    kind: TrackableItemKind::Issue(Issue::default()),
235                },
236            },
237            TimeLog {
238                spent_at: Local::now(),
239                time_spent: Duration::seconds(1800),
240                summary: Some("test".to_string()),
241                user: user1.clone(),
242                trackable_item: TrackableItem {
243                    common: TrackableItemFields {
244                        title: "First MR".into(),
245                        milestone: None,
246                        labels: Labels {
247                            labels: vec![
248                                Label {
249                                    title: "Documentation".into(),
250                                },
251                                Label {
252                                    title: "Development".into(),
253                                },
254                            ],
255                        },
256                        time_estimate: Duration::seconds(1800),
257                        ..Default::default()
258                    },
259                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
260                },
261            },
262            TimeLog {
263                spent_at: Local::now(),
264                time_spent: Duration::seconds(5400),
265                summary: Some("Fix a big bug".to_string()),
266                user: User {
267                    name: "user3".to_string(),
268                    username: "user3".to_string(),
269                },
270                trackable_item: TrackableItem {
271                    common: TrackableItemFields {
272                        id: 1,
273                        title: "Second MR".into(),
274                        labels: Labels {
275                            labels: vec![
276                                Label {
277                                    title: "Bug".into(),
278                                },
279                                Label {
280                                    title: "Development".into(),
281                                },
282                            ],
283                        },
284                        time_estimate: Duration::seconds(3600),
285                        ..Default::default()
286                    },
287                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
288                },
289            },
290        ]
291    }
292
293    #[test]
294    fn test_group_by_user() {
295        const NUMBER_OF_USERS: usize = 3;
296        const NUMBER_OF_USER1_LOGS: usize = 2;
297        const NUMBER_OF_USER2_LOGS: usize = 2;
298        const NUMBER_OF_USER3_LOGS: usize = 1;
299
300        let input = get_timelogs();
301        assert_eq!(input.len(), NUMBER_OF_LOGS);
302
303        let output = group_by_user(&input).collect::<BTreeMap<_, _>>();
304        let user1 = User {
305            name: "user1".to_string(),
306            username: "user1".to_string(),
307        };
308        let user2 = User {
309            name: "user2".to_string(),
310            username: "user2".to_string(),
311        };
312        let user3 = User {
313            name: "user3".to_string(),
314            username: "user3".to_string(),
315        };
316
317        assert_eq!(output.len(), NUMBER_OF_USERS);
318        assert_eq!(output.get(&user1).unwrap().len(), NUMBER_OF_USER1_LOGS);
319        assert_eq!(output.get(&user2).unwrap().len(), NUMBER_OF_USER2_LOGS);
320        assert_eq!(output.get(&user3).unwrap().len(), NUMBER_OF_USER3_LOGS);
321    }
322
323    #[test]
324    fn test_group_by_milestone() {
325        const NUMBER_OF_MILESTONES: usize = 2;
326        const NUMBER_OF_NONE: usize = 4;
327        const NUMBER_OF_SOME: usize = 1;
328
329        let input = get_timelogs();
330        assert_eq!(input.len(), NUMBER_OF_LOGS);
331
332        let milestone_none = None;
333        let milestone_some = Some(&Milestone {
334            title: "End of Elaboration".to_string(),
335            ..Default::default()
336        });
337
338        let output = group_by_milestone(&input).collect::<BTreeMap<_, _>>();
339
340        assert_eq!(output.len(), NUMBER_OF_MILESTONES);
341
342        let output_none = output.get(&milestone_none).unwrap();
343        let output_some = output.get(&milestone_some).unwrap();
344        assert_eq!(output_none.len(), NUMBER_OF_NONE);
345        assert_eq!(output_some.len(), NUMBER_OF_SOME);
346
347        // Verify the correct timelogs are grouped
348        assert!(output_none.contains(&&input[0]));
349        assert!(output_none.contains(&&input[1]));
350        assert!(output_some.contains(&&input[2]));
351        assert!(output_none.contains(&&input[3]));
352        assert!(output_none.contains(&&input[4]));
353    }
354
355    #[test]
356    fn test_group_by_type() {
357        const NUMBER_OF_TYPES: usize = 2;
358        const NUMBER_OF_ISSUES: usize = 3;
359        const NUMBER_OF_MERGE_REQUESTS: usize = 2;
360
361        let input = get_timelogs();
362        assert_eq!(input.len(), NUMBER_OF_LOGS);
363
364        let output = group_by_type(&input).collect::<BTreeMap<_, _>>();
365
366        assert_eq!(output.len(), NUMBER_OF_TYPES);
367        assert_eq!(output.get("Issue").unwrap().len(), NUMBER_OF_ISSUES);
368        assert_eq!(
369            output.get("Merge Request").unwrap().len(),
370            NUMBER_OF_MERGE_REQUESTS
371        );
372    }
373
374    #[test]
375    fn test_group_by_trackable_item() {
376        const NUMBER_OF_ITEMS: usize = 4;
377        const NUMBER_OF_ISSUE_0: usize = 2;
378        const NUMBER_OF_ISSUE_1: usize = 1;
379        const NUMBER_OF_MR_0: usize = 1;
380        const NUMBER_OF_MR_1: usize = 1;
381
382        let input = get_timelogs();
383        let mut result = group_by_trackable_item(&input).collect::<BTreeMap<_, _>>();
384        assert_eq!(result.len(), NUMBER_OF_ITEMS);
385
386        let item_1 = result.pop_first().unwrap();
387        assert_eq!(
388            std::mem::discriminant(&item_1.0.kind),
389            std::mem::discriminant(&TrackableItemKind::Issue(Issue::default()))
390        );
391        assert_eq!(item_1.0.common.id, 0);
392        assert_eq!(item_1.1.len(), NUMBER_OF_ISSUE_0);
393
394        let item_2 = result.pop_first().unwrap();
395        assert_eq!(
396            std::mem::discriminant(&item_2.0.kind),
397            std::mem::discriminant(&TrackableItemKind::MergeRequest(MergeRequest::default()))
398        );
399        assert_eq!(item_2.0.common.id, 0);
400        assert_eq!(item_2.1.len(), NUMBER_OF_MR_0);
401
402        let item_3 = result.pop_first().unwrap();
403        assert_eq!(
404            std::mem::discriminant(&item_3.0.kind),
405            std::mem::discriminant(&TrackableItemKind::Issue(Issue::default()))
406        );
407        assert_eq!(item_3.0.common.id, 1);
408        assert_eq!(item_3.1.len(), NUMBER_OF_ISSUE_1);
409
410        let item_4 = result.pop_first().unwrap();
411        assert_eq!(
412            std::mem::discriminant(&item_4.0.kind),
413            std::mem::discriminant(&TrackableItemKind::MergeRequest(MergeRequest::default()))
414        );
415        assert_eq!(item_4.0.common.id, 1);
416        assert_eq!(item_4.1.len(), NUMBER_OF_MR_1);
417    }
418
419    #[test]
420    fn test_group_by_label_contains_selected_labels() {
421        const NUMBER_OF_SELECTED_LABELS: usize = 2;
422
423        let input = get_timelogs();
424
425        let label_documentation = Some(&Label {
426            title: "Documentation".to_string(),
427        });
428        let label_development = Some(&Label {
429            title: "Development".to_string(),
430        });
431        let label_bug = Some(&Label {
432            title: "Bug".to_string(),
433        });
434        let label_others = Some(&Label {
435            title: "Others".to_string(),
436        });
437
438        #[expect(clippy::unnecessary_literal_unwrap)]
439        let label_filter = HashSet::from([
440            label_development.unwrap().title.clone(),
441            label_documentation.unwrap().title.clone(),
442        ]);
443        assert_eq!(label_filter.len(), NUMBER_OF_SELECTED_LABELS);
444
445        let result = group_by_label(&input, Some(&label_filter), None).collect::<BTreeMap<_, _>>();
446
447        assert_eq!(result.len(), NUMBER_OF_SELECTED_LABELS);
448        assert!(result.contains_key(&label_documentation));
449        assert!(result.contains_key(&label_development));
450        assert!(!result.contains_key(&label_bug));
451        assert!(!result.contains_key(&label_others));
452        assert!(!result.contains_key(&None));
453    }
454
455    #[test]
456    fn test_group_by_label_contains_items_without_labels() {
457        const NUMBER_OF_LABELS_INCLUDING_NO_LABEL: usize = 4;
458        const NUMBER_OF_NO_LABEL: usize = 2;
459        const TIME_SPENT_BY_NO_LABEL: Duration = Duration::seconds(10800);
460
461        let time_logs = get_timelogs();
462        let result = group_by_label(&time_logs, None, None).collect::<BTreeMap<_, _>>();
463
464        assert_eq!(result.len(), NUMBER_OF_LABELS_INCLUDING_NO_LABEL);
465        assert!(result.contains_key(&None));
466        let no_label = result.get(&None).unwrap();
467        assert_eq!(no_label.len(), NUMBER_OF_NO_LABEL);
468        assert_eq!(
469            no_label.iter().map(|t| t.time_spent).sum::<Duration>(),
470            TIME_SPENT_BY_NO_LABEL
471        );
472    }
473
474    #[test]
475    fn test_group_by_label_none_selected_labels_contains_all_labels() {
476        const NUMBER_OF_ALL_LABELS: usize = 3;
477
478        let input = get_timelogs();
479
480        let label_documentation = Some(&Label {
481            title: "Documentation".to_string(),
482        });
483        let label_development = Some(&Label {
484            title: "Development".to_string(),
485        });
486        let label_bug = Some(&Label {
487            title: "Bug".to_string(),
488        });
489        let label_others = Some(&Label {
490            title: "Others".to_string(),
491        });
492
493        let result = group_by_label(&input, None, None).collect::<BTreeMap<_, _>>();
494        // All labels + 1 for No label
495        assert_eq!(result.len(), NUMBER_OF_ALL_LABELS + 1);
496        assert!(result.contains_key(&label_documentation));
497        assert!(result.contains_key(&label_development));
498        assert!(result.contains_key(&label_bug));
499        assert!(result.contains_key(&None));
500        assert!(!result.contains_key(&label_others));
501    }
502
503    #[test]
504    fn test_group_by_label_with_other_label() {
505        const NUMBER_OF_SELECTED_LABELS: usize = 2;
506        const NUMBER_OF_DOCUMENTATION: usize = 2;
507        const NUMBER_OF_DEVELOPMENT: usize = 2;
508        const NUMBER_OF_OTHERS: usize = 2;
509
510        const TIME_SPENT_BY_DOCUMENTATION: Duration = Duration::seconds(5400);
511        const TIME_SPENT_BY_DEVELOPMENT: Duration = Duration::seconds(7200);
512        const TIME_SPENT_BY_OTHERS: Duration = Duration::seconds(10800);
513
514        let input = get_timelogs();
515        assert_eq!(input.len(), NUMBER_OF_LOGS);
516
517        let label_documentation = Some(&Label {
518            title: "Documentation".to_string(),
519        });
520        let label_development = Some(&Label {
521            title: "Development".to_string(),
522        });
523        let label_bug = Some(&Label {
524            title: "Bug".to_string(),
525        });
526        let label_others = Label {
527            title: "Others".to_string(),
528        };
529
530        #[expect(clippy::unnecessary_literal_unwrap)]
531        let label_filter = HashSet::from([
532            label_development.unwrap().title.clone(),
533            label_documentation.unwrap().title.clone(),
534        ]);
535        assert_eq!(label_filter.len(), NUMBER_OF_SELECTED_LABELS);
536
537        let result = group_by_label(&input, Some(&label_filter), Some(&label_others))
538            .collect::<BTreeMap<_, _>>();
539
540        // Selected labels + 1 for the "Other" label
541        assert_eq!(result.len(), NUMBER_OF_SELECTED_LABELS + 1);
542
543        // Check the contents of the labels
544        let result_documentation = result.get(&label_documentation).unwrap();
545        assert_eq!(result_documentation.len(), NUMBER_OF_DOCUMENTATION);
546        assert_eq!(
547            result_documentation
548                .iter()
549                .map(|t| t.time_spent)
550                .sum::<Duration>(),
551            TIME_SPENT_BY_DOCUMENTATION
552        );
553
554        let result_development = result.get(&label_development).unwrap();
555        assert_eq!(result_development.len(), NUMBER_OF_DEVELOPMENT);
556        assert_eq!(
557            result_development
558                .iter()
559                .map(|t| t.time_spent)
560                .sum::<Duration>(),
561            TIME_SPENT_BY_DEVELOPMENT
562        );
563
564        let result_others = result.get(&Some(&label_others)).unwrap();
565        assert_eq!(result_others.len(), NUMBER_OF_OTHERS);
566        assert_eq!(
567            result_others.iter().map(|t| t.time_spent).sum::<Duration>(),
568            TIME_SPENT_BY_OTHERS
569        );
570        assert!(!result.contains_key(&label_bug));
571    }
572
573    #[test]
574    fn test_group_by_label_without_other_label() {
575        const NUMBER_OF_LABELS: usize = 3;
576        const NUMBER_OF_DOCUMENTATION: usize = 2;
577        const NUMBER_OF_DEVELOPMENT: usize = 2;
578        const NUMBER_OF_BUGS: usize = 1;
579
580        const TIME_SPENT_BY_DOCUMENTATION: Duration = Duration::seconds(5400);
581        const TIME_SPENT_BY_DEVELOPMENT: Duration = Duration::seconds(7200);
582        const TIME_SPENT_BY_BUGS: Duration = Duration::seconds(5400);
583
584        let input = get_timelogs();
585        assert_eq!(input.len(), NUMBER_OF_LOGS);
586
587        let label_documentation = Some(&Label {
588            title: "Documentation".to_string(),
589        });
590        let label_development = Some(&Label {
591            title: "Development".to_string(),
592        });
593        let label_bug = Some(&Label {
594            title: "Bug".to_string(),
595        });
596
597        #[expect(clippy::unnecessary_literal_unwrap)]
598        let label_filter = HashSet::from([
599            label_development.unwrap().title.clone(),
600            label_documentation.unwrap().title.clone(),
601            label_bug.unwrap().title.clone(),
602        ]);
603
604        let result = group_by_label(&input, Some(&label_filter), None).collect::<BTreeMap<_, _>>();
605        assert_eq!(result.len(), NUMBER_OF_LABELS);
606
607        // Check the contents of the labels
608        let result_documentation = result.get(&label_documentation).unwrap();
609        assert_eq!(result_documentation.len(), NUMBER_OF_DOCUMENTATION);
610        assert_eq!(
611            result_documentation
612                .iter()
613                .map(|t| t.time_spent)
614                .sum::<Duration>(),
615            TIME_SPENT_BY_DOCUMENTATION
616        );
617
618        let result_development = result.get(&label_development).unwrap();
619        assert_eq!(result_development.len(), NUMBER_OF_DEVELOPMENT);
620        assert_eq!(
621            result_development
622                .iter()
623                .map(|t| t.time_spent)
624                .sum::<Duration>(),
625            TIME_SPENT_BY_DEVELOPMENT
626        );
627
628        let result_bug = result.get(&label_bug).unwrap();
629        assert_eq!(result_bug.len(), NUMBER_OF_BUGS);
630        assert_eq!(
631            result_bug.iter().map(|t| t.time_spent).sum::<Duration>(),
632            TIME_SPENT_BY_BUGS
633        );
634
635        assert!(!result.contains_key(&None));
636    }
637
638    #[test]
639    fn test_total_time_spent_by_user() {
640        const NUMBER_OF_USERS: usize = 3;
641        const TIME_SPENT_BY_USER_1: Duration = Duration::seconds(5400);
642        const TIME_SPENT_BY_USER_2: Duration = Duration::seconds(10800);
643        const TIME_SPENT_BY_USER_3: Duration = Duration::seconds(5400);
644
645        let input = get_timelogs();
646        assert_eq!(input.len(), NUMBER_OF_LOGS);
647
648        let totals = total_time_spent_by_user(&input).collect::<BTreeMap<_, _>>();
649
650        let user1 = User {
651            name: "user1".to_string(),
652            username: "user1".to_string(),
653        };
654        let user2 = User {
655            name: "user2".to_string(),
656            username: "user2".to_string(),
657        };
658        let user3 = User {
659            name: "user3".to_string(),
660            username: "user3".to_string(),
661        };
662
663        assert_eq!(totals.len(), NUMBER_OF_USERS);
664        assert_eq!(totals.get(&user1), Some(&TIME_SPENT_BY_USER_1));
665        assert_eq!(totals.get(&user2), Some(&TIME_SPENT_BY_USER_2));
666        assert_eq!(totals.get(&user3), Some(&TIME_SPENT_BY_USER_3));
667    }
668
669    #[test]
670    fn test_filter_by_dates() {
671        const NUMBER_OF_FILTERED_LOGS: usize = 3;
672
673        let input = get_timelogs();
674        assert_eq!(input.len(), NUMBER_OF_LOGS);
675
676        // should take today and yesterday
677        let end = Local::now().date_naive();
678        let start = end - Duration::days(1);
679        let output = filter_by_date(&input, start, end).collect::<Vec<_>>();
680
681        assert_eq!(output.len(), NUMBER_OF_FILTERED_LOGS);
682        for node in output {
683            let spent_day = node.spent_at.with_timezone(&Local).date_naive();
684            assert!(spent_day >= start && spent_day <= end);
685        }
686    }
687
688    #[test]
689    fn test_filter_by_last_n_days() {
690        const NUMBER_OF_FILTERED_LOGS: usize = 3;
691        const NUMBER_OF_DAYS: i64 = 2;
692
693        let input = get_timelogs();
694        let output =
695            filter_by_last_n_days(&input, Duration::days(NUMBER_OF_DAYS)).collect::<Vec<_>>();
696
697        let end = Local::now();
698        let start = end - Duration::days(NUMBER_OF_DAYS);
699
700        assert_eq!(output.len(), NUMBER_OF_FILTERED_LOGS);
701        for log in output {
702            assert!(log.spent_at >= start && log.spent_at <= end);
703        }
704    }
705}