1use crate::model::{TimeLog, TrackableItem};
5use chrono::{Local, NaiveDate};
6use std::collections::HashSet;
7
8#[derive(Debug)]
10pub enum ValidationProblem {
11 ExcessiveHours { max_hours: u16 },
13 MissingSummary,
15 FutureDate,
17 DuplicateEntry,
19 BeforeStartDate { start_date: NaiveDate },
21}
22
23pub struct ValidationResult<'a> {
25 pub time_log: &'a TimeLog,
27 pub problems: Vec<ValidationProblem>,
29}
30
31impl ValidationResult<'_> {
32 #[must_use]
34 pub fn is_valid(&self) -> bool {
35 self.problems.is_empty()
36 }
37
38 #[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
47pub trait Validator {
49 fn validate_single(&mut self, time_log: &TimeLog) -> Vec<ValidationProblem>;
52}
53
54pub struct TimeLogValidator {
88 validators: Vec<Box<dyn Validator>>,
89}
90
91impl Default for TimeLogValidator {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl TimeLogValidator {
99 #[must_use]
101 pub fn new() -> Self {
102 Self {
103 validators: Vec::new(),
104 }
105 }
106
107 #[must_use]
108 pub fn with_validator(mut self, validator: impl Validator + 'static) -> Self {
119 self.validators.push(Box::new(validator));
120 self
121 }
122
123 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 for validator in &mut self.validators {
132 problems.extend(validator.validate_single(time_log));
134 }
135
136 ValidationResult { time_log, problems }
137 })
138 .collect();
139
140 validation_results
141 }
142}
143
144pub struct ExcessiveHoursValidator {
146 max_hours: u16,
147}
148
149impl ExcessiveHoursValidator {
150 #[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
170pub 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
182pub 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
194pub struct DuplicatesValidator {
196 seen: HashSet<(String, NaiveDate, i64, Option<String>, TrackableItem)>,
197}
198
199impl 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
233pub 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 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 summary: None,
314 spent_at: Local::now() + TimeDelta::days(1),
315 time_spent: Duration::hours(15),
316 ..Default::default()
317 },
318 TimeLog {
319 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}