gitlab_time_report/
validation.rs

1//! This module contains methods to validate [`TimeLog`]s and report possible problems.
2//! They are implemented via the validator chain pattern.
3
4use crate::model::{TimeLog, TrackableItem};
5use chrono::{Local, NaiveDate};
6use std::collections::HashSet;
7
8/// Possible problems in [`TimeLog`] that can be found during validation.
9#[derive(Debug)]
10pub enum ValidationProblem {
11    /// The time spent exceeds the maximum allowed.
12    ExcessiveHours { max_hours: u16 },
13    /// No summary was entered.
14    MissingSummary,
15    /// Entered date is in the future.
16    FutureDate,
17    /// Duplicate entry has been found (same user, date, trackable item, time spent and summary)
18    DuplicateEntry,
19    /// `TimeLog` is before the configured project start date.
20    BeforeStartDate { start_date: NaiveDate },
21}
22
23/// Stores the result of a validation run.
24pub struct ValidationResult<'a> {
25    /// The time log that was validated.
26    pub time_log: &'a TimeLog,
27    /// The problems found in this time log.
28    pub problems: Vec<ValidationProblem>,
29}
30
31impl ValidationResult<'_> {
32    /// Returns true if there are no problems found in this time log.
33    #[must_use]
34    pub fn is_valid(&self) -> bool {
35        self.problems.is_empty()
36    }
37
38    /// Returns true if there is at least one problem of the given type.
39    #[must_use]
40    pub fn has_problems(&self, problem_type: &ValidationProblem) -> bool {
41        self.problems
42            .iter()
43            .any(|i| std::mem::discriminant(i) == std::mem::discriminant(problem_type))
44    }
45}
46
47/// Trait functions that a validator must implement.
48pub trait Validator {
49    /// Validate each [`TimeLog`] on its own. Returns a Vec containing the problems found
50    /// or an empty Vec if there are none.
51    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem>;
52}
53
54/// The main validator that runs all validators.  Add the other validators to this one with
55/// [`TimeLogValidator::with_validator`], then call [`TimeLogValidator::validate`] to run the validation.
56/// # Example
57/// ```
58/// # use gitlab_time_report::validation::{TimeLogValidator, ValidationProblem, ExcessiveHoursValidator, HasSummaryValidator};
59/// # use gitlab_time_report::model::TimeLog;
60/// # use chrono::Local;
61/// let time_logs = [
62///     TimeLog{ summary: None, ..Default::default() },
63///     TimeLog{ summary: Some("Code Review".to_string()), ..Default::default() }
64/// ];
65///
66/// let mut validator = TimeLogValidator::new()
67///     .with_validator(ExcessiveHoursValidator::new(10))
68///     .with_validator(HasSummaryValidator);
69/// let results = validator.validate(&time_logs);
70///
71/// // Assertions to check the results, you don't need to do this in your code
72/// assert!(results[0].has_problems(&ValidationProblem::MissingSummary));
73/// assert!(!results[0].has_problems(&ValidationProblem::ExcessiveHours{ max_hours: 10 }));
74/// assert!(results[1].is_valid());
75///
76/// for result in results {
77///     if result.is_valid() { continue; }
78///     for problem in &result.problems {
79///         match problem {
80///             ValidationProblem::ExcessiveHours { max_hours } => println!("Time spent exceeds maximum of {max_hours} hours"),
81///             ValidationProblem::MissingSummary => println!("No summary was entered"),
82///             _ => {}
83///         }
84///     }
85/// }
86/// ```
87pub struct TimeLogValidator {
88    validators: Vec<Box<dyn Validator>>,
89}
90
91/// Added for <https://rust-lang.github.io/rust-clippy/master/index.html#new_without_default>
92impl Default for TimeLogValidator {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl TimeLogValidator {
99    /// Create a new [`TimeLogValidator`].
100    #[must_use]
101    pub fn new() -> Self {
102        Self {
103            validators: Vec::new(),
104        }
105    }
106
107    #[must_use]
108    /// Add a new validator to the chain. See [`Validator`] for more information.
109    /// # Example
110    /// ```
111    /// # use gitlab_time_report::validation::{TimeLogValidator, ValidationProblem, ExcessiveHoursValidator, HasSummaryValidator};
112    /// # use gitlab_time_report::model::TimeLog;
113    /// # use chrono::Local;
114    /// let mut validator = TimeLogValidator::new()
115    ///     .with_validator(ExcessiveHoursValidator::new(10))
116    ///     .with_validator(HasSummaryValidator);
117    /// ```
118    pub fn with_validator(mut self, validator: impl Validator + 'static) -> Self {
119        self.validators.push(Box::new(validator));
120        self
121    }
122
123    /// Run all validators on the given time logs.
124    pub fn validate<'a>(&mut self, time_logs: &'a [TimeLog]) -> Vec<ValidationResult<'a>> {
125        let validation_results: Vec<ValidationResult<'a>> = time_logs
126            .iter()
127            .map(|time_log| {
128                let mut problems = Vec::new();
129
130                // Run all validators on this time log
131                for validator in &mut self.validators {
132                    // Append the problems Vec with the problems found by this validator
133                    problems.extend(validator.validate_single(time_log));
134                }
135
136                ValidationResult { time_log, problems }
137            })
138            .collect();
139
140        validation_results
141    }
142}
143
144/// Validates that a single time log does not exceed the given maximum hours.
145pub struct ExcessiveHoursValidator {
146    max_hours: u16,
147}
148
149impl ExcessiveHoursValidator {
150    /// Set the maximum hours allowed.
151    #[must_use]
152    pub fn new(max_hours: u16) -> Self {
153        Self { max_hours }
154    }
155}
156
157impl Validator for ExcessiveHoursValidator {
158    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem> {
159        let hours = time_log.time_spent.num_hours();
160
161        match hours > i64::from(self.max_hours) {
162            true => vec![ValidationProblem::ExcessiveHours {
163                max_hours: self.max_hours,
164            }],
165            false => Vec::new(),
166        }
167    }
168}
169
170/// Validates that a time log has a summary.
171pub struct HasSummaryValidator;
172
173impl Validator for HasSummaryValidator {
174    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem> {
175        match time_log.summary.is_none() {
176            true => vec![ValidationProblem::MissingSummary],
177            false => Vec::new(),
178        }
179    }
180}
181
182/// Validates that a time log has no date in the future.
183pub struct NoFutureDateValidator;
184
185impl Validator for NoFutureDateValidator {
186    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem> {
187        if time_log.spent_at > Local::now() {
188            return vec![ValidationProblem::FutureDate];
189        }
190        Vec::new()
191    }
192}
193
194/// Validates that a time log does not contain duplicates (same user, date, trackable item, time spent and summary)
195pub struct DuplicatesValidator {
196    seen: HashSet<(String, NaiveDate, i64, Option<String>, TrackableItem)>,
197}
198
199/// Added for <https://rust-lang.github.io/rust-clippy/master/index.html#new_without_default>
200impl Default for DuplicatesValidator {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206impl DuplicatesValidator {
207    #[must_use]
208    pub fn new() -> Self {
209        Self {
210            seen: HashSet::new(),
211        }
212    }
213}
214
215impl Validator for DuplicatesValidator {
216    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem> {
217        let key = (
218            time_log.user.name.clone(),
219            time_log.spent_at.date_naive(),
220            time_log.time_spent.num_seconds(),
221            time_log.summary.clone(),
222            time_log.trackable_item.clone(),
223        );
224
225        if self.seen.insert(key) {
226            Vec::new()
227        } else {
228            vec![ValidationProblem::DuplicateEntry]
229        }
230    }
231}
232
233/// Validates that a time log date is not before the project start date.
234pub struct BeforeStartDateValidator {
235    start_date: NaiveDate,
236}
237
238impl BeforeStartDateValidator {
239    #[must_use]
240    pub fn new(start_date: NaiveDate) -> Self {
241        Self { start_date }
242    }
243}
244
245impl Validator for BeforeStartDateValidator {
246    fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem> {
247        let log_date = time_log.spent_at.date_naive();
248        if log_date < self.start_date {
249            return vec![ValidationProblem::BeforeStartDate {
250                start_date: self.start_date,
251            }];
252        }
253        Vec::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::model::{MergeRequest, TimeLog, TrackableItemFields, TrackableItemKind};
261    use chrono::{Duration, Local, TimeDelta};
262
263    const NUMBER_OF_LOGS: usize = 7;
264    fn get_time_logs() -> [TimeLog; NUMBER_OF_LOGS] {
265        [
266            TimeLog {
267                summary: Some("Valid Time log".to_string()),
268                spent_at: Local::now() - TimeDelta::days(1),
269                time_spent: Duration::hours(4),
270                trackable_item: TrackableItem {
271                    common: TrackableItemFields {
272                        title: "test".to_string(),
273                        ..Default::default()
274                    },
275                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
276                },
277                ..Default::default()
278            },
279            TimeLog {
280                summary: Some("Excessive Hours".to_string()),
281                spent_at: Local::now() - TimeDelta::days(1),
282                time_spent: Duration::hours(12),
283                ..Default::default()
284            },
285            TimeLog {
286                summary: None,
287                spent_at: Local::now() - TimeDelta::days(1),
288                time_spent: Duration::hours(5) + Duration::minutes(30),
289                ..Default::default()
290            },
291            TimeLog {
292                summary: Some("Future Date".to_string()),
293                spent_at: Local::now() + TimeDelta::hours(1),
294                time_spent: Duration::hours(3),
295                ..Default::default()
296            },
297            TimeLog {
298                // Duplicate entry
299                summary: Some("Valid Time log".to_string()),
300                spent_at: Local::now() - TimeDelta::days(1),
301                time_spent: Duration::hours(4),
302                trackable_item: TrackableItem {
303                    common: TrackableItemFields {
304                        title: "test".to_string(),
305                        ..Default::default()
306                    },
307                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
308                },
309                ..Default::default()
310            },
311            TimeLog {
312                // No Summary, Future Date and Excessive Hours
313                summary: None,
314                spent_at: Local::now() + TimeDelta::days(1),
315                time_spent: Duration::hours(15),
316                ..Default::default()
317            },
318            TimeLog {
319                // Should not trigger a duplicate entry problem
320                summary: Some("Same time spent & spent_at as 'Summary missing' timelog, but different summary".to_string()),
321                spent_at: Local::now() - TimeDelta::days(1),
322                time_spent: Duration::hours(5) + Duration::minutes(30),
323                ..Default::default()
324            }
325        ]
326    }
327
328    #[test]
329    fn test_excessive_hours_validator() {
330        const EXCESSIVE_HOURS_LIMIT: u16 = 10;
331
332        let time_logs = get_time_logs();
333        let expected_problem = ValidationProblem::ExcessiveHours {
334            max_hours: EXCESSIVE_HOURS_LIMIT,
335        };
336
337        let mut validator = TimeLogValidator::new()
338            .with_validator(ExcessiveHoursValidator::new(EXCESSIVE_HOURS_LIMIT));
339
340        let results = validator.validate(&time_logs);
341        assert_eq!(results.len(), NUMBER_OF_LOGS);
342        for (i, result) in results.iter().enumerate() {
343            match i {
344                1 | 5 => assert!(result.has_problems(&expected_problem)),
345                _ => assert!(result.is_valid()),
346            }
347        }
348    }
349
350    #[test]
351    fn test_has_summary_validator() {
352        let time_logs = get_time_logs();
353        let expected_problem = ValidationProblem::MissingSummary;
354
355        let mut validator = TimeLogValidator::new().with_validator(HasSummaryValidator);
356
357        let results = validator.validate(&time_logs);
358        assert_eq!(results.len(), NUMBER_OF_LOGS);
359        for (i, result) in results.iter().enumerate() {
360            match i {
361                2 | 5 => assert!(result.has_problems(&expected_problem)),
362                _ => assert!(result.is_valid()),
363            }
364        }
365    }
366
367    #[test]
368    fn test_future_date_validator() {
369        let time_logs = get_time_logs();
370        let expected_problem = ValidationProblem::FutureDate;
371
372        let mut validator = TimeLogValidator::new().with_validator(NoFutureDateValidator);
373
374        let results = validator.validate(&time_logs);
375        assert_eq!(results.len(), NUMBER_OF_LOGS);
376        for (i, result) in results.iter().enumerate() {
377            match i {
378                3 | 5 => assert!(result.has_problems(&expected_problem)),
379                _ => assert!(result.is_valid()),
380            }
381        }
382    }
383
384    #[test]
385    fn test_duplicates_validator() {
386        let time_logs = get_time_logs();
387        let expected_problem = ValidationProblem::DuplicateEntry;
388
389        let mut validator = TimeLogValidator::new().with_validator(DuplicatesValidator::new());
390
391        let results = validator.validate(&time_logs);
392        assert_eq!(results.len(), NUMBER_OF_LOGS);
393        for (i, result) in results.iter().enumerate() {
394            match i {
395                4 => assert!(result.has_problems(&expected_problem)),
396                _ => assert!(result.is_valid()),
397            }
398        }
399    }
400
401    #[test]
402    fn test_before_start_date_validator() {
403        let time_logs = get_time_logs();
404        let start_date = Local::now().date_naive();
405
406        let expected_problem = ValidationProblem::BeforeStartDate { start_date };
407
408        let mut validator =
409            TimeLogValidator::new().with_validator(BeforeStartDateValidator::new(start_date));
410
411        let results = validator.validate(&time_logs);
412        assert_eq!(results.len(), NUMBER_OF_LOGS);
413        for (i, result) in results.iter().enumerate() {
414            match i {
415                0 | 1 | 2 | 4 | 6 => assert!(result.has_problems(&expected_problem)),
416                _ => assert!(result.is_valid()),
417            }
418        }
419    }
420
421    #[test]
422    fn test_all_validators() {
423        const EXCESSIVE_HOURS_LIMIT: u16 = 10;
424
425        let time_logs = get_time_logs();
426        let mut validator = TimeLogValidator::new()
427            .with_validator(ExcessiveHoursValidator::new(EXCESSIVE_HOURS_LIMIT))
428            .with_validator(HasSummaryValidator)
429            .with_validator(NoFutureDateValidator)
430            .with_validator(DuplicatesValidator::new());
431
432        let excessive_hours_validator = ValidationProblem::ExcessiveHours {
433            max_hours: EXCESSIVE_HOURS_LIMIT,
434        };
435
436        let results = validator.validate(&time_logs);
437
438        assert_eq!(results.len(), NUMBER_OF_LOGS);
439        assert!(results[0].is_valid());
440
441        assert_eq!(results[1].problems.len(), 1);
442        assert!(results[1].has_problems(&excessive_hours_validator));
443
444        assert_eq!(results[2].problems.len(), 1);
445        assert!(results[2].has_problems(&ValidationProblem::MissingSummary));
446
447        assert_eq!(results[3].problems.len(), 1);
448        assert!(results[3].has_problems(&ValidationProblem::FutureDate));
449
450        assert_eq!(results[4].problems.len(), 1);
451        assert!(results[4].has_problems(&ValidationProblem::DuplicateEntry));
452
453        assert_eq!(results[5].problems.len(), 3);
454        assert!(results[5].has_problems(&excessive_hours_validator));
455        assert!(results[5].has_problems(&ValidationProblem::MissingSummary));
456        assert!(results[5].has_problems(&ValidationProblem::FutureDate));
457
458        assert!(results[6].is_valid());
459    }
460}